Modern C++

여기에서는 C++11, C++14, and C++17 에대해서 게임 개발에 필요한것부터 정리 하겠다. 아래의 목록과 같이 설명을 하고, 더 설명이 필요한 부분이 있다면, 아래에 더 섹션을 추가할 예정이다.

Auto

C++ 에서는 variable 앞에 항상 타입이 있었다. 예를 들어서, 함수의 인자 타입을 제약조건에서 벗어나려면, template 을 사용해서 하는 방법이 있었다. 하지만, 뭔가 파이썬 처럼 자동 추론 해주는 키워드가 있을까? 생각이든다. 정답은 있다. 일종의 조커 카드 키워드 인 auto 라는 키워드가 있다. 즉 이 키워드가 하는 역활은 compiler 에게 type deduction 을 부탁하는거다. (알아서 잘 맞춰봐 라는 명령을 날리는것 하고 똑같다.) 하지만, compiler 에게 맡기는건 항상 언제나 문제를 일으킨다. 예를들어서, 참조나 포인터 값을 추론 하라고 한다고 하면, 또한 const 를 사용한다면 어떻게 될까? 라는 질문을 할수 있다. 물론 auto 가 주는 편의한 점도 있다. 하지만 이것을 무분별하게 사용한다면 readability 또 떨어지지만, 진짜 진짜 타이핑이 길어지는 경우는 지양해야한다. 예를 들어서, loop 에 iterator 를 정의할때는 지향한다.

Brace {} Initialization

그다음은 brace {} Intialization 이다. 최신 OpenSource 를 보다보면 {} 이런식으로 사용하는걸 볼수 있다. 일단 variable initialization 을 보자. 처음에 int a = 10; 기존에는 이렇게 Initialization 을 했었다. 하지만 또 다른 방법은, 그 아래와 같이 {} b 와 c 를 0 으로 initialize 한걸 볼수 있다. 또 확인을 해보면 vector 등 container 초기화랑 되게 잘어울린다는걸 확인 할 수 잇다. 그리고 중괄호의 초기화 같은 경우, 축소 반환 방지라는게 있다. 이 말은 type conversion 이 깐깐해진다.

아래에서 또 intializer_list 라는게 있는데 만약 list 로 받는다고 가정을 할때, 만약에 생성자에 인자를 두개나 세개만 받는게 있다고 하면 initializer_list 의 생성자가 호출이 된다. 즉, 우선권을 얻어버린다.

#include <iostream>
#include <vector>
using namespace std;

class Knight
{
public:
    Knight(){}
    Knight(int a, int b){} 
    Knight(initializer_list<int> li) // 초기화할때 리스트
    {
        cout << "Knight(Initialize List)" << endl;
    }
}

int main()
{
    int a = 10;
    int b{0};                   // int b(0);
    int c{0};                   // int c(0):

    Knight k1;
    Knight k2 = k1              // 복사 생성자 (대입 연산자)
    Knight k3 { k2 };           // Knight 초기화

    vector<int> v1{1, 2, 3, 4}; // vector 초기화 1, 2, 3, 4 push_back

    int x = 0;
    double y{x};                // error

    Knight k4{};                // 기본생성자

    Knight k5{1, 2, 3, 4, 5};
    return 0;
}

결론을 내자면 괄호 초기화할때 () 기본으로 간다, 뭔가 모던함을 보여주려면 {} 사용해도 된다. 근데 주로 vector 같은 경우에는 {} 초기화해도 된다.

nullptr

C 코드나 C style 인 C++ 코드에서 보면 NULL 을 종종 볼수 있을거다. 실제 이 값을 보면 0 이라는 값을 가지고 있다. 에를 들어서, #define NULL 0 이렇게 선언이 되어있어서 사용되었었다. 그런데 문제점은 만약 함수에서 정수 인자로 받는것과 pointer 로 받는게 있다고 하면 Null로 넘겨주면 정수인자로 받는 함수만 사용된다. 그래서 nullptr 의 자주 사용되며, 장점이된다. 도대체 그럼 nullptr 은 더 객체같은 존재다. 아래의 코드는 간단한 nullptr 의 구현부이다.

  • 보너스 : 선언하자마자 객체를 만들고 싶다면 class 뒤에, instantiate 하고 싶은 name 을 주면 된다.
class NullPtr
{
public:
    // 그 어떤 타입의 포인터와도 치환 가능
    template<typename T>
    operator T*() const
    {
        return 0;
    }
    // 그 어떤 타입의 멤버 포인터와도 치환 가능
    template<typename C, typename T>
    operator T C::*() const
    {
        return 0;
    }
    void operator&() = delete; // 주소갑 & 을 막는다.
};

using

전에는 typedef 를 사용했던 이유는 뭔가 type 이름이 길어졌을때, 다른 이름으로 만든다음에 설정을 해주었었다. 사실 modern c++ 에서 using 을 사용하는게 결국은 typedef 랑 같다. 근데 사용하는 방법의 차이점을 알고 사용하면 굉장히 괜찮은 코드가 나올것이 분명하다. 한번 사용해보는 코드를 봐보자. 일단 using 을 사용함으로써 되게 가독성이 올라간다. 그리고 제일 중요한건 template 의 사용이다. typedef 는 template 을 사용할수 없다.


typedef __int64 id;
using id2 = int;

// 1 ) 직관성
typedef void (*MyFunc)();
using MyFunc2 = void(*)();

// 2) Template
template<typename T>
using List = std::list<T>;

// 과거엔
template<typename T>
struct List2
{
    typedef std::list<T> type;
};

int main()
{
    List2<int>::type li2;
}

enum class

enum 은 너무 친숙하지만, modern c++ 에서는 살짝 나누어져있다. 일단 enum class 의 장점을 알아보자.

  1. 이름 공간 관리 (scoped)
  2. 암묵적인 변환 금지

일단 아래와 같이 봤을때, 만약 enum 값들 중에 같은값을 가지고 있으면, 재정의가 필요하다고 에러창을 보여지는걸 볼수 있다. 그래서 이게 전의 enum 의 단점이다. 보너스: enum 의 Type 을 지정이 가능하다. 그래서 enum class 를 사용해서 enum 의 범위를 지정시켜줘서, 똑같은 element 가 enum 에 있다고 한들 문제가 없어진다.

// unscped enum
enum PlayerType
{
    PT_Knight,
    PT_Archer,
    PT_Mage
}

enum PlayerType : char
{
    PT_Knight,
    PT_Archer,
    PT_Mage,
}

enum MonsterType
{
    PT_Knight // 실수로
}

enum class ObjectType
{
    Player,
    Monster,
    Projectile,
}

int main()
{
    double value = PT_Knight;                               // 에전의 enum 을 사용했을시 허용됬었다.

    double value = ObjectType::Player;                      // 허용 x
    double value = static_cast<double>(ObjectType::Player)  // 명시적만 허용
}

delete

어? 설마 동적 할당에 대한 delete 였나? 라고 생각할수 있지만, 그런 keyword 가 아니다. 가끔씩은 compiler 에게 기본적으로 만들어진 생성자나 복사생성자를 부를때가 있다. 그럴때 뭔가 막고자 할때 그 함수를 없앤다가 더 말이 맞다. 과거의 코드를 한번 봐보자

class Knight
{
public:

private:
    // 정의도지 않은 비공개 (private) 함수 --> 하지만 구현부에서는 돌아갈수있다. 그래서 완벽하게 막는 행위는 아니다.
    void operator=(const Knight& k);
    friend class Admin; // admin 에게는 허락 해주겠다.
private:
    int _hp = 100;
}

class Admin
{
public:
    void CopyKnight(const Knight& k)
    {

    }
}

int main()
{
    Knight k1;
    Knight k2;

    // 복사 연산자
    k1 = k2;
    return 0;
}

과연 modern c++ 에서는 이걸 어떻게 해결했을까?

class Knight
{
public:
    void operator=(const Knight& k) = delete
}

class Admin
{
public:
    void CopyKnight(const Knight& k)
    {

    }
}

int main()
{
    Knight k1;
    Knight k2;

    // 복사 연산자
    k1 = k2; // delete 되버림
    return 0;
}

override and final

c# 에서 뭔가 친숙한 keyword 이지만, c++ 에서 어떻게 사용됬는지 한번 확인을 해보자.

class Creature
{
public:
};

class Player : public Creature
{
public:
    virtual void Attack()
    {
        cout << "Player Attack" << endl;
    }
};

class Knight : public Player
{
public:
    virtual void Attack() override
    {
        cout << "Kngiht" << endl;
    }
    virtual void Attack() const //  member 변수를 변경 할수 없음
private:
    int _stamina = 100;
};

class Pet : public Knight
{
public:
    virtual void Attack() final // 마지막 봉인 : 자식에게 그만 주겠당
    {
        cout << "Pet" << endl;
    }
};

int main()
{
    Player* player = new Knight();
    player->Attack();
    return 0;
}

rvalue

c++11 에서 제일 혁신적인 변화를 일으켰던 친구 중에 하나가 rvalue 이다. 즉 오른값과 std::move 이다. 왼값(lvalue) 와 오른값(rvalue) 에 대해서 알아보자. lvalue 란 단일식을 넘어서 계속 지속되는 개체 그리고 rvalue 는 lvalue 가 아닌 나머지 (임시 값, 열거형, 람다 i++ 등) 있다.

아래를 보면 a 는 왼값이고, 3 은 오른값이다. 왼값은 다시 사용해서 다른 오른값으로 대체 가능하지만, 오른값과 왼값을 바꿔서 뜨면 식이 수정할수 없는 왼값이라는 에러가 뜬다.

int main()
{
    int a = 3;

    a = 4;
    // 3 = a; Error : 식이 수정할수 없는 왼값이어야 된다. 

}

아래의 코드를 잠깐 봐보자. 우리가 일반적으로 함수에다가 객체를 pas_by_value 로 했을때는 객체가 복사가 이루어져서 원본 데이터가 변경되지 않는다. 그래서 원본 데이터를 수정하려면 reference 로 인자를 바꿔서 보내줬었다. 즉 k1 은 왼쪽값을 넘겨줘서 바꿔줬었다. 하지만 만약에 대표적인 오른값인 Knight() 를 넘겼다고 가정하자. 그러면 임시의 객체를 생성해서 넘겨주는건데, 오른값이라 허용이 되지 않는걸 확인할수 있다. 하지만, 읽기 용도로는 const 를 사용해서 할수 있다. 하지만 const 를 사용시에는 Knight 의 멤버함수나 멤버변수를 변경 못한다는 점에서 문제가 있다. 그러면 이걸 해결할수 있는 방법이 뭘까? 하면 오른값참조를 허용하게 하는 && 이다.

그럼 왜 구지 이걸 활용해야될까? 일단 RValue 같은 경우 원본 수정도 다해도되고, 함수가 다사용할때 사라지니까 마음대로 해! 라는 느낌이다. 즉 이게 이동 대상이 된다.

class Knight
{
public:
    int _hp = 100;
};

void TestKnight_LValueRef(Knight& knight){ knight._hp = 200 }
void TestKnight_ConstLValueRef(const Knight& knight){}          // 하지만 멤버 변수나 method 를 사용할수 없다. 원본 수정 No No...
void TestKnight_RValueRef(Knight&& knight){}                    // 오른값을 받는 특별한 아이를 지정. 이동대상!

int main()
{
    Knight k1;
    TestKnight_LValueRef(k1);
    TestKnight_LValueRef(Knight());         // 오른값으로 넘겨 줬을때는 Ref 로 넘길수 없다.
    TestKnight_ConstLValueRef(Knight());    // 허용 --> Knight() 가 잠시 사용하다가 없어질수 있지만, 읽기 용도로 쓰일수 있음
    TestKnight_RValueRef(k1);               // 왼값을 허용이 안된다.
    TestKnight_RValueRef(Knight());
    TestKnight_RValueRef(static_cast<Knight&&>(k1));
}

만약 객체가 커졌더라면, 이게 어뗘한 이점이 있는지 확인을 해보자.

class Pet
{

};

class Knight
{
public:
    Knight(){ cout << "Knight()" << endl;}
    Knight(const Knight& knight)
    {
        cout << "const Knight" << endl;
    }
    ~Knight()
    {
        if(_pet)
            delete _pet;
    }

    // 이동 생성자
    Knight(Knight&& knight);

    void operator=(const Knight& knight)
    {
        cout << "operator=(const Knight&)" << endl;
        _hp = knight._hp;
        
        if (knight._pet)
            _pet = new Pet(*knight._pet);
    }

    // 이동 대입 연산자
    void operator=(Knight&& knight) noexcept
    {
        // 소유권을 넘겨버림
        cout << "operator=(Knight&&) "<< endl;
        _hp = knight._hp;
        _pet = knight._pet;

        knight._pet = nullptr;
    }
    
public:
    int _hp = 100;
    Pet* _pet = nullptr;
};

int main()
{
    Knight k2;
    k2._pet = new Pet();
    k2._hp = 1000;

    Knight k3;
    k3 = static_cast<Knight&&>(k2); // k2 는 버리고 k3 에서 k2 의 pet 을 뺐어온다. 원본은 날려도 된다. 즉 이동 가능!

    k4 = std::move(k3); // 오른쪽값 참조로 캐스팅 ---> static_cast<Knight&&>(k3); 이러면 k3 를 버리고 k4 가 소유권을 얕복으로 가져

    std::unique_ptr<Knight> uptr = std::make_unique<Knight>(); // 세상에 하나만 존재
    std::unique_ptr<Knight> uptr2 = uptr; // 복사 X
    std::unique_ptr<Knight> uptr2 = std::move(uptr); // 이렇게 이용
    return 0;
}

forwarding reference

Forwarding Reference 는 C++17 에서 나왔다. 오른값참조와 조금 비슷하다. 근데 주의할점은 무조건 && 이 오른쪽 참조라고 생각을 하면 안된다. 일단 오른쪽 참조값을 할수 있는 이동생성자가 만들어졌고, 그리고 오른쪽 참조값을 받는 함수도 보인다. 하지만 template 이 들어있는 함수를 봐보면, 뭔가 오른쪽 참조값도 되고 왼쪽 참조값도 들어가지는걸 볼수 있다. 또한 auto 를 사용했을때도 오른값참조가 아닌 왼값참조로 되어있고, 또 std::move 를 사용해서 오른쪽값으로 참조로 넘겨준 값을 줬을때, 오른쪽값으로 되어있다는걸 볼수있다. 이 케이스가 바로 forwarding reference 인데, 특이한 케이스 즉 type deduction 을 할시에 생겨날때 주로 일어난다. 즉 카멜레온 같은존재이다. 근데 예외상황은 있다 template 을 사용한다고 해서 다 전달 참조가 아니라 만약 Test_ForwardingRef 함수앞에 인자로 const 가 들고 있게 되면(즉 읽기 전용) 왼값이 에러가 난다. 바로 오른값만 된다.

class Knight
{
public:
    Knight(){ cout << "Default Constructor" << endl;}
    Knight(const Knight& knight){ cout << "const Knight& knight" << endl; }
    Knight(Knight&&) noexcept {cout << "Move Constructor" << endl;}
    ~Knight(){ cout << "~Knight" << endl; }
};

void Test_RValueRef(Knight&& k)
{

}

void Test_Copy(Knight k)
{

}

template<typename T>
void Test_ForwardingRef(T&& param)
{
    Test_Copy(std::forward<T>(param));
}

int main()
{
    Knight k1;
    Test_RValueRef(std::move(k1));
    Test_ForwardingRef(std::move(k1));
    Test_ForwardingRef(k1);         // 경우에따라서 왼쪽 참조가 될수도 있고 오른쪽 참조가 될수도 있다.

    auto&& k2 = k1;                 // 참조는 참조인데 오른값이 아니다. 왼값참조로 되어있다!?
    auto&& k3 = std::move(k1);

    // 일반적일때는 사용되지 않지만, type deduction 할때 일어난다. 전달참조가 일어난다.

    return 0;
}

즉 전달 참조를 구별하는 방법을 알아보았다. 만약에 입력값이 오른값인지 왼값인지 모를때는 구별하는 방법이 필요하다. 만약에 왼값을 std::move 를 사용하면 모든 소유권을 다 뺏는다는 소리니까 굉장히 좋지 않다. 오른값은 왼값이 아니고, 단일식에서 벗어나면 사용하지 못하고, 오른값참조는 오른값만 참조할 수 있는 참조 타입이였다. 아래를 구체적으로 보면 왼값이다.

int main()
{
    Knight& k4 = k1;
    Knight&& k5 = std::move(k1);

    // Test_RValueRef(k5);         // 어 오른값을 안받네?

    Test_RValueRef(std::move(k5));
}

lambda

일단 함수 객체를 빠르게 만드는 문법이다. 새로 추가된 문법은 아니지만, struct 를 사용하지 않고 한줄로 함수를 구현할수 있다는 점에서는 정말 좋다. python 에서는 익명함수라고도 한다. 그리고 람다에 의해 만들어진 실행시점의 객체를 closure 라고 불린다. 그리고 함수 객체 내부에 변수를 저장하는 개념과 유사한걸 capture 라고 불린다. capture 에 대해서는 생각을 해보면 스냅샷을 찍는것과 마찬가지이다. 캡처에도 모드가 존재하는데, 기본 방식은 복사방식(=), 참조 방식(&) 이다. 그리고 변수 마다 캡처모드를 지정해서 사용가능한데, 이게 더 가독성이 높고, 전체의 인자를 = 또는 & 를 하는건 지양한다.

enum class ItemType
{
    None,
    Armor,
    Weapon,
    Jewelry,
    Consumable,
}

enum class Rarity
{
    Common,
    Rare,
    Unique
};

class Item
{
public:
    Item(){}
    Item(int itemId, Rarity rarity, ItemType type) : _itemId(itemId), _rarity(Rarity), _type(type)
    {

    }
public:
    int _itemId;
    Rarity _rarity = Rarity::Common;
    ItemType _type = ItemType::None;
}

int main()
{
    vector<Item> v;
    v.push_back(Item(1, Rarity::Common, ItemType::Weapon));
    v.push_back(Item(2, Rarity::Common, ItemType::Armor));
    v.push_back(Item(3, Rarity::Rare, ItemType::Jewelry));
    v.push_back(Item(4, Rarity::Unique, ItemType::Weapon));

    // lambda = 함수 객체를 손쉽게 만드는 문법
    {   
        // [](인자) {구현부} 기본 형식 --> lambda expression
        auto isUniqueLambda = [](Item& item){ return item._rarity == Rarity::Unique; }
        auto findIt = std::find_if(v.begin(), v.end(), isUniqueLambda)
        if (findIt != v.end())
            cout << "Item Id:" findIt->_itemId << endl;
    }

    {
        int itemId = 4;
        auto findByItemLambda = [=](Item& item){ return item._itemid == _itemId; };
        itemId = 10;
        auto findByItemLambda = [&](Item& item){ return item._itemid == _itemId; }; // 10 으로 바뀌었다.
    }

    {
        int itemId = 4;
        Rarity rarity = Rarity::Unique;
        ItemType type = ItemType::Weapon;
        auto findByItem = [=](Item& item)
        {
            return item._itemId == itemId && item._rarity == rarity && item._type == type;
        }

        auto findByItem = [itemId, rarity, type](Item& item)
        {
            return item._itemId == itemId && item._rarity == rarity && item._type == type; 
        }
    }

    {
        // bug-case
        class Knight
        {
        public:
            void ResetHpJob()
            {
                // auto f = [this](){} --> [=](){}
                // {
                //     this->_hp = 200;
                // }                                    // 버그
                return f;
            }
        public:
            int _hp = 100;
        }

        Knight* k = new Knight();
        auto job = k->RequestHpJob();
        delete k;
        job();
    }
}

smart pointer

smart pointer 포인터가 똑똑하다? C++ 의 장점이자 단점은 Memory 를 직접 건든다는거다. 하지만 단점중에 알아볼건 바로 dangling pointer 이다. 잠깐 살펴보자. 아래의 코드를 보자면, 뭔가 Knight 에 대한 세팅을 다해줬는데, _target 을 지워버린 셈이다. 이럴때 문제가 바로 crash 가 일어나지 않고, _target->_hp 에 쓰레기 값이 들어가 있는걸 볼수 있다. 즉 _target 에 참조하고 있는애들을 다 nullptr 로 바꿔줘야한다.

class Knight
{
public:
    Knight(){}
    ~Knight(){}
    void Attack()
    {
        if (_target)
        {
            _target->_hp = _damage;
            cout << "Hp:" << _target->_hp << endl;
        }
    }
public:
    int _hp = 100;
    int _damage = 10;
    Knight* _target = nullptr;
}

int main()
{
    Knight* k1 = new Knight();
    Knight* k2 = new Knight();
    k1->_target = k2;
    delete k2;
    k1->Attack();
    return 0;
}

조금은 성능면에서 raw pointer 를 사용하기보다는, 코드의 안정성을 위한 코드가 필요해서 smart pointer 가 생겼다. 스마트 포인터란 포인터를 알맞는 정책에 따라 관리하는 객체 (포인터를 래핑해서 사용) 되었다. smart pointer 안에 종류는 아래와 같다

  1. shared_ptr
  2. weak_ptr
  3. unique_ptr

smart pointer 안에서는, python 이나 c# 에서 Garbage Collector 에서 사용되는 reference count 를 해준다. 즉 아무도 사용하지 않을때, delete 를 해준다. 여기에서 중요한점은 RefCount = 1 로 세팅이 되어있고, 소멸할때는 0 으로 만들어준다음, 0 일때 지워주는게 보인다. (즉 refCount 를 확인하고 지워준다는게 특징이다.) 아래의 코드는 shared_ptr 이 어떻게 동작하는지를 확인할수있다.

class RefCountBlock
{
public:
    int _refCount = 1; // 기본 값은 1. 
};

template<typename T>
class SharedPtr
{
public:
    SharedPtr(){}
    SharedPtr(T* ptr) : _ptr(ptr)
    {
        if (_ptr != nullptr)
        {
            _block = new RefCountBlock();
            cout << "RefCount : " << _block->_refCount << endl;
        }
    }
    SharedPtr(const SharedPtr& shared_ptr) : _ptr(shared_ptr._ptr), _block(shared_ptr._block)
    {
        if(_ptr != nullptr)
        {
            _block->_refCount++;
        }
    }
    ~SharedPtr()
    {
        if(_ptr != nullptr)
        {
            _block->_refCount--;

            // delete _ptr

            if (_block->_refCount ==0)
            {
                delete _ptr;
                delete _block;
            }
        }
    }

    void operator=(const SharedPtr& shared_ptr)
    {
        _ptr = shared_ptr._ptr;
        _block = shared_ptr._block;
    }
public:
    T* _ptr = nullptr;
    RefCountBlock* _block = nullptr; 
};

int main()
{
    SharedPtr<Knight> k1(new Knight());
    SharedPtr<Knight> k2 = k1;

    SharedPtr<Knight> k3;
    {
        SharedPtr<Knight>k4(new Knight());
        k4 = k1;
    }
}

아래의 코드는 shared_ptr 를 직접 사용한 코드이다.

class Knight
{
public:
    Knight(){}
    ~Knight(){}
    void Attack()
    {
        if (_target)
        {
            _target->_hp = _damage;
            cout << "Hp:" << _target->_hp << endl;
        }
    }
public:
    int _hp = 100;
    int _damage = 10;
    shared_ptr<Knight> _target = nullptr;
}

int main()
{
    shared_ptr<Knight> k1 = make_shared<Knight>(); // 빨리 동작
    {
        shared_ptr<Knight> k2 = make_shared<Knight>();
        k1->_target = k2;
    }

    k1->Atttack();
    return 0;
}

하지만 shared_ptr 를 사용한다고 하더라도, 포인터의 똑같은 문제점인 순환구조에서는 refCount 가 0 이 되지 않아서, 큰문제가 있을거다. 아래의 예제 code segment 를 봐보자. 아래의 경우 k1 에서의 refCount 는 2 이고, k2 에서의 refCount 가 1 이기때문에 아무도 delete 를 안할것이다. 그래서, 순환구조로 있을때는 따로 nullptr 로 풀어줘야한다.

shared_ptr<Knight> k1 = make_sahred<Knight>();
{
    shared_ptr<Knight> k2 = make_shared<Knight>();
    k1 -> _target = k2;
    k2 -> _target = k1;
}

k1->_target = nullptr;
k2->_target = nullptr;

또다른 방법은 weak_ptr 를 사용한다. weak_ptr 를 사용함에따라서, ReferenceBlock 에는 또다른 _weakCount 라는게 생긴다. shared_pointer 와 달리, weak_ptr 같은 경우는 메모리가 날라갔는지 안날라갔는지 확인이 가능하다. 그래서 .expred() 를 사용해서 날라갔는지 안날라갔는지를 통해서, 그 ptr 를 lock 을 할수 있다. 즉 weak_ptr 는 생명주기를 확인할수 없다. 즉 shared_ptrweak_ptr 차이점은 메모리의 한정 범위에서 자유로워지냐, 생명주기를 확인할수 있냐 등이 있다.

Resource

Source Code


© 2021. All rights reserved.