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

Standard Template Library

STL 는 Standard Template Library 라고 한다. 즉 프로그래밍 할때 필요한 자료구조 및 알고리즘등을 템플릿으로 제공하는 라이브러리이다. 일단 STL 라이브러리에 뭐가 있는지 알아보자. 첫번째는 Container 이다. Container 같은 경우 데이터를 저장하는 객체, 즉 하나의 Data Structure 이다.

Vector

일단 Container 의 종류의 하나인 vector 을 알아보자. 일단 알아볼가지가 몇개가 있다.

  1. vector 의 동작 원리 (size / capacity)
  2. 중간 삽입 / 삭제
  3. 처음 / 끝 삽입 / 삭제
  4. 임의 접근

동적 배열이라고 함은, 뭔가 동적으로 배열으로 커지고, element 를 추가했을때 배열의 사이즈가 동적으로 커지는 현상을 말한다. 반대로 배열을 사용할때의 문제를 기억해보자. 문제점은 바로 배열의 사이즈다. 뭔가 동적으로 커지고 줄어드는게 힘들기때문에 배열의 단점이다. 하지만 동적배열은 고무줄 처럼 커지고 작아진다.

#include <vector>

int main()
{
    vector <int> v;
    v.push_back(1);
    v.push_back(2);
    v.push_back(4);
    v.push_back(5);

    const int size = v.size();
    for (int i = 0; i < size; i++)
    {
        cout << v[i] << endl;
    }

    return 0;
}

그렇다고 한다고 하면 vector 의 동작 원리는 뭐길래? 이렇게 고무줄 처럼 사이즈가 늘어나고 줄어들수 있을까? 일단은 두가지의 로직이 존재한다.

  1. (여유분을 두고) 메모리를 할당한다.
  2. 여유분까지 꽉 찼으면, 메모리를 증설 한다.

그렇다면 질문!?

  1. 여유분은 얼만큼이 적당할까?
  2. 증설을 얼만큼 해야할까?
  3. 기존의 데이터를 어떻게 처리할까?

첫번째 질문 같은 경우, 아까 봤던것 처럼 v.size() 를 봤을때 실제 용량이고, v.capacity() 는 여유분을 포함한 용량이다. 아래의 코드를 샐행했을때 vector 의 크기가 변화함에 따라서 capacity 가 1.5 또는 2 배 증가하는게 보인다. 그럼 왜 이게 이렇게 설정이 되어있을까? 만약에 배열이 꽉 차있다고 하면 두배로 증가 시킨다. 예를들어서 처음에 [1 2 3 4 5] 되어있다고 치자, 그러면 2 배 만큼을 증설을 시킬거고 그 다음에는 메모리는 malloc 을 통해서 덧붙여도 되지만, 애초에 2배된걸 memory 를 할당해서 메모리를 1.5 를 만든 다음, 복사를 하는 식이다. 즉 더 넒은 곳으로 이사를 하게 된다. 결국에는 지금 현재 메모리에 들고 있는 1.5 배 또는 2 배를 더 큰걸 옮겨주는 정책이 정해져있는것이다. 만약에 1만큼 증가하면 복사하는 비용이 더더욱 커져서 1.5 배나 2 배로 늘어난다.

그럼 예를들어서 capacity() 처음에 저장할수 있는 방법은 v.reserve(100) 이렇게 하면 처음에 100개로 capacity 가 설정이된다. 그런다면 100 개가 넘어가면 150 으로 변경이된다. 마찬가지로 v.resize() 같은경우는 사이즈를 세팅해주는거다.

#include <vector>

int main()
{
    vector<int> v;

    for (int i = 0; i < 100; i++)
    {
        v.push_back(100);
        cout << v.size() << " " << v.capacity() << endl;
    }
    return 0;
}

만약에 vector 를 clear 했다고 한다고 하면 size 나 capacity 의 변화는 어떻게 될까를 한번 알아보자. 아래의 코드를 실행해보면 capacity 는 그대로 1000 개 이고, size 는 0 으로 확인 할 수 있다. 완벽히 capacity 값을 0 으로 만드는 방법은 v 를 깡통인거에 해주면 같이 size 와 capacity 가 0 이될거다.

#include <vector>

int main()
{
    vector<int> v;
    v.reserve(1000);
    for (int i = 0; i < 100; i++)
    {
        v.push_back(100);
        cout << v.size() << " " << v.capacity() << endl;
    }

    v.clear()
    vector<int>() swap(v);
    cout << v.size() << " " << v.capacity() << endl;
    return 0;
}

그럼 데이터 꺼내기 같은경우는 v.front() 맨처음거를 꺼내오거나, v.back() 맨뒤에거를 꺼내오거나 v.push_back 이 있는것처럼 v.pop_back() 이 있다. 심지어 Initialize 도 가능하다. vector<int> v(1000, 0) 를 할수 있는데 1000 은 v.size() 고 0 은 초기값이다. 그리고 복사도 가능하다.(예: vector<int> v2 = v)


일단 위와 같이 vector 의 동작원리를 알아보았다. 그 다음에 알아봐야될거는 어떻게 vector 안에 있는 element 들을 indexing 할수 있는지를 알아야한다. 이거를 알려면 일단 Iterator(반복자) 의 내용에 대해서 알아야 한다. 일단 iterator 는 pointer 와 유사한 개념이고 Container 의 Element 를 가르키고 다음 또는 이전 원소로 넘어갈수 있다.

아래의 코드를 한번 봐보자. 일단 iterator 와 pointer 의 차이가 없다고 보인다. 하지만 iterator 의 메모리를 까보면 추가적인 정보를 들고 있다는걸 확인 할수 있다. 주사값은 물론이고 내가 어떤 Container 로 들고 있다라는 정보도 있다. iterator 의 찾아들어가면 *() operator 가 있는걸 볼수 있다. 이게 포인터의 값을 들고 오는걸 볼수 있다.

#include <vector>
int main()
{
    vector<int> v(10);
    for (vector<int>::size_type i = 0; i < v.size(); i++)
        v[i] = i;

    vector<int>::iterator it;
    int* ptr;

    it = v.begin();
    ptr = &v[0];

    cout << (*it) << endl;
    cout << (*ptr) << endl;
    return 0;
}

pointer 와 비슷하게 ++ -- operator 를 사용할수 있다. 포인터에서의 연산은 그다음 주소(데이터)로 넘어가거나 앞으로가거나였다. 아래의 코드에서 반복자의 처음과 끝을 볼수 있는데, 끝같은경우는 데이터의 마지막 값이 지나고, 쓰레기 값이 들어있다. 즉 유효하지 않은값까지 이다.

iterator 는 뭔가 복잡해 보인다. 그런데 사실 iterator 는 vector 뿐만아니라, 다른컨케이너도 공통적으로 있는 개념이다.

#include <vector>
int main()
{
    vector<int> v(10);
    for(vector<int>::size_type i = 0; i < v.size; i++)
        v[i] = i;
    vector<int>::iterator>> itBegin = v.begin();
    vector<int>::iterator itEnd = v.end();

    for (vector<int>::iterator it=v.begin(); i != v.end(); ++it)
    {
        cout << (*it) << endl;
    }

    int* ptrBegin = &v[0]; // v.begin()._Ptr
    int* ptrEnd = ptrBegin + 10; // v.end()._Ptr

    for (int*ptr=ptrBegin; ptr!=ptrEnd; ++ptr)
    {
        cout << (*it) << endl;
    }
    return 0;
}

그럼 iterator 에서 어떤 애들이 있을까? 일단 아래의 코드를 한번봐보자. 일단 const_iterator 가 존재한다. 그말은 값을 변경 하지 못한다는 뜻이다. 그리고 역방향도 있는데 reverse_iterator 라는걸로 vector 를 설정해주고, iterating 을 한다.

vector<int>::const_iterator it = v.begin();
*it = 100; // const 기 때문에 바꿀수 없다.

// 역방향
for (vector<int>reverse_iterator it = v.begin(); it != v.end() ++it)
{
    cout << (*it) << endl;
}

다시 돌아가서 이제 vector 의 접근 / 삽입 / 삭제등을 어떻게 활용하는지 보고, 해당되는 performance 를 체크 해보자. 일단 vector 는 container 이기 때문에 하나의 메모리 블록에 연속하게 저장된다. 만약에 예를들어서 중간에 삽입을 한다고 하면, 사이즈가 증가 할때마다 큰곳으로 복사를 해주어야 하는데, 그때의 복사 비용이 커진다. 그리고 삭제 같은 경우, 블록을 하나 사라 진다고 하면, 그래서 중간 삽입 / 삭제가 비효율적이다라는걸 알수 있다. 이 이야기 처럼 처음 삽입 / 삭제도 비효율적이라고 볼수 있다. 하지만 끝 삽입 / 삭제같은 경우는 뒤에것만 지우기때문에 효율적이다. Random Access(임의 접근) 같은 경우도 사실 하나의 메모리 블록에 연속적이다는 특성으로 인해서 임의 접근이 쉽게 된다.

// Init: [0][1][2][3][4]
v.insert(v.begin() + 2, 5);
// After: [0][1][5][2][3][4]

v.erase(v.begin()+2);
// After: [0][1][2][3][4]

v.erase(v.begin()+2, v.begin()+4);
// After: [0][1][4] 4 는 삭제 되지 않음

실수중에 하나가, 예를 들어서 3 이라는 데이터가 있으면 일괄 삭제하는 케이스가 있다고 하자. 아래의 코드는 그 예제의 케이스다고 볼수 있다, 그리고 이 코드를 돌렸을때, 실패가 났을것이다. 삭제를 했을때, 이때의 iterator 는 container 의 소속이 아니게된다. 그 다음에 it 에서 유효하지 않기 때문에 그다음 loop 에서 실패가 난다. 그래서 v.erase(it) 하면 null 인 상태가 아니라, iterator 다시 받을수있다. 근데 사실이것만 하면 되는게 아니라, iterator 가 그냥 넘어갔다고 하면 3 뒤에 나오는 element 는 스킵을 한다는게 포인트다. 즉 넘어가게끔 else 넘어가게 해주어야한다. 그리고 내부에서 절대 절대 clear() 를 call 하면 안된다.

#include <vector>
int main()
{
    vector<int> v(10);
    for (vector<int>::size_type i=0; i < v.size(); i++)
        v[i] = i;
    
    for (vector<int>::iterator it = v.begin(); it != v.end())
    {
        int data = *it;
        if (data == 3)
        {
            //v.erase(it);
            it = v.erase(it);
        }
        else
        {
            ++it;
        }
    }

    return 0;
}

vector 를 간략하게 구현해보자.

template<typename T>
class Iterator
{
public:
    Iterator() : _ptr(nullptr){}
    Iterator(T *ptr): _ptr(ptr){}

    Iterator& operator++()
    {
        _ptr++;
        return *this;
    }

    Iterator operator+(const int count)
    {
        Iterator temp = *this;
        temp._ptr += count;
        return temp;
    }

    Iterator operator++(int)
    {
        Iterator temp = *this;
        _ptr++;
        return temp;
    }

    Iterator& operator--()
    {
        _ptr++;
        return *this;
    }

    Iterator operator--(int)
    {
        Iterator temp = *this;
        _ptr++;
        return temp;
    }

    bool operator==(const Iterator& right)
    {
        return _ptr == right._ptr;
    }

    bool operator!=(const Iterator& right)
    {
        return _ptr != right._ptr;
    }

    T& operator*()
    {
        return *_ptr;
    }

public:
    T* _ptr;
};

template<typename T>
class Vector
{
public:
    Vecotr() : _data(nullptr), _size(0), _capacity(0){}

    ~Vecotr()
    {
        if(_data)
            delete[] _data;
    }

    void push_back(const T& val)
    {
        if(_size == _capacity)
        {
            int newCapacity = static_cast<int>(_capacity * 1.5);
            if (newCapacity == _capacity)
                newCapacity++;
            reserve(newCapacity);
        }

        _data[_size] = val;
        _size++;
    }

    void reserve(int capacity)
    {
        _capacity = capacity;
        T* newData = new T[_capacity];
        for (int i = 0; i < _size; i++)
            newData[i] = _data[i];
        
        // 기존에 있는 데이터를 날린다.
        if(_data)
            delete[] _data;
        
        _data = newData;
    }

    T& operator[](const int pos){ return _data[pos]; } // v[i] = i;
    int size() { return _size; }
    int capacity(){ return _capacity; }

private:
    T* _data;
    int _size;
    int _capacity;

    typedef Iterator<T> iterator;
    Iterator begin() { return iterator(&data[0]);}
    Iterator end() {return begin() + _size;}
};

int main()
{
    Vector<int> v;
    for (int i = 0; i < 100; i++)
    {
        v.push_back(100);
        cout << v.size() << " " << v.capacity() << endl;
    }

    for (int i = 0; i < v.size(); i++)
    {
        cout << v[i] << endl;
    }

    for (Vector<int>::iterator it = v.begin(); i != v.end() ++it)
    {
        cout << (*it) << endl;
    }
    return 0;
}

Lists

Vector 와 비슷한 container 의 형식인 List(LinkList) 가 있다. 하지만 List 는 Node 형식으로 되어있다. 즉, 트리 형식으로 만들수 있다는거다. 일단 아래의 코드를 보면, List 에서 대표적으로 유용하게 사용되는게 보인다. 일단 vector 를 비교하면, capacity 가 따로 없다 그 이유는 vector 와달리 Node 형식으로 동작을 한다. 그리고 다른걸 봐보면 push_frontpop_front 가 존재한다. 이것도 List 가 Vector 와 다른 형식으로 값을 Contain 하기 때문이다. 마지막으로 random access 가 지원되지 않고, 어떤 element 를 지우는것도 까다롭지 않게 구현이 되어있는걸 볼수 있다.

#include <list>
int main()
{
    list<int> l1;
    for (int i = 0; i < 100; i++)
        l1.push_back(i);
    
    li.push_front(10);      // vector 와 다르게 동작
    int size = l1.size();   // 
    // li.capacity() ?      // 동적배열인 형식이 아닌 Node 형식으로 동작

    int first = li.front();
    int last = li.back();

    // li[3] = 10;          // 임의 접근 안됨
    list<int>::iterator itBegin = li.begin();
    list<int>::iterator itEnd = li.end();

    for (list<int>::iterator it = li.begin(); it != li.end(); ++it)
    {
        cout << (*it) << endl;
    }

    li.insert(itBegin, 100);

    li.erase(li.begin());
    li.pop_front();
    li.remove(10);

    return 0;
}

위처럼 코드를 잠깐 살펴보았는데, 이제 List 가 어떤 동작 방식을 가지고 있는지 확인을 해보자. 만약 연결리스트의 개념을 알고 있으면, 메모리의 구조를 잘 이해하게 될거다. 일단 연결리스트에 종류가 있는데, 단일, 이중, 원형 LinkList 들로 이루어져있다. 즉 1 -> 2 -> 3 이런식으로 각 각 넘버는 Node 형태로 되어있고, 이 Node 들은 data 를 가지고 있고 그리고 Node 의 주소값을 가지고 있다. 여기서 포인트가 자기 자신의 Node 타입인 아이를 들고 있으면 무한정 Node 안에 Node 가 반복될것이다. 하지만 여기서 봐야될거는 Node 의 포인터 즉 주소값을 가지고 있는게 포인트이기때문에, 그다음의 주소값을 들고 있으면 리스트처럼 들어갈수 있다. 이중리스트 같은 경우는 아래의 Node2 를 보면 된다. Previous 의 주소값과 그다음 주소값을 나타내는게 보인다.

class Node
{
public:
    Node*   _next;
    int     _data;
}

class Node2
{
public:
    Node2* _next;
    Node2* _prev;
    int    _data;
}

일단 STL 에서는 이중 리스트로 되어있다. 이중리스트가 Node 형식으로 되어있으니까, 중간 삽입 또는 삭제 그리고 처음 / 끝 삽입 또는 삭제가 잘될거라는건 쉽게 믿을수 있다. 하지만 모든게 다 장점을 들고 있었더라면 List 를 많이 썼을거다. 하지만 List 의 단점이 있다. List 의 임의 접근이 쉽지 않다. 그니까 노드 들을 계속 타고 타고 가서 몇번째를 노드에 그 데이터를 가지고 갈수 있다. 그래서 List 에서 성능이 않좋기 때문에, 임의접근의 기능을 지원하지 않는다.

아래의 code segment 를 한번 봐보자. 일단 list 의 앞과 뒤의 주소를 ptrBegin 그리고 ptrEnd 로 저장을 해보자. 그런 다음 데이터의 저장된 Previous 와 Next 의 주소값을 확인하고 그 Node 자신의 데이터 값도 확인을 해보면 잘들어있는게 보인다. 그리고 Link List 에서 맨뒤의 값을 봐보면 Next 가 쓰레기 값으로 들어가있는걸 볼수있다. 이말은 Next 가 쓰레기 값이면 list 의 size 를 알수 있다. 그리고 Link List 이기때문에 궁금할수 있는건 맨마지막에서 빼면 앞으로 가는지, 그리고 뒤에서 맨앞으로 가면 어떻게 되는지 아래의 코드에서 확인 할수 있다. 그래서 LinkList 의 허용범위를 확인 할수 있다.

list<int> iterator itBegin = li.begin();
list<int> iterator itEnd = li.end();

// list<int>::iterator itTest1 = -- itBegin;    // 앞에서 맨뒤로 가는건 허용X
list<int>::iterator itTest2 = --itEnd;          // 앞으로 가는건 허용
// list<int>::iterator itTest3 = ++itEnd;       // 뒤에서 맨 앞으로 가는건 허용X

int* ptrBegin = &(li.front());
int* ptrEnd = &(li.end());

list<int>::iterator it2 = li.begin() + 10;

또 여기에서 의문점이 임의접근이 안되는데 중간 삽입 / 삭제가 빠르다는건 약간의 역설이 들어간다. 이미 삭제된 대상이 정해져 있으면 쉽지만, 그 index 를 가지고 이동해서 삭제하는 어렵다라는걸 알수 있다. 즉 erase 는 빠르게 되지만, 숫가락으로 그 index 까지 찾아줘야하는건 우리의 몫인거다. 그래서 그 다음 아래 코드를 보면, 저렇게 iterator 로 remember 로 받아들인다음에 나중에 삭제할 index 를 찾을수 있는 방법도 있다.

li.erase(li.begin() + 50) // 허용 되지 않음

list<int>::iterator it = li.begin();
for (int i =0; i < 50; i++)
    ++it;
li.erase(it);
list<int> li;
list<int>::iterator itRemember;
for  (int i = 0; i < 100; i++)
{
    if(i == 50)
    {
        itRemember = li.insert(li.end(), i);
    }
    else
    {
        li.push_back(i);
    }
}

li.erase(itRemember);

그렇다면 간락하게 구현을 한번 해보자.

#include <list>
#include <iostream>
using namespace std;
template<typename T>
class Node
{
public:
    Node() : _next(nullptr), _prev(nullptr), _data(T()){}
    ~Node(const T& value) : _next(nullptr), _prev(nullptr), _data(value){}
public:
    Node*   _next;
    Node*   _prev;
    T       _data;
};

template<typename> T
class Iterator
{
public:
    Iterator() : _node(nullptr)
    {

    }

    Iterator(Node<T>* node) : _ node(node)
    {

    }
    // ++it
    Iterator<T>& operator++()
    {
        _node = _node->_next;
        return *this;
    }

    //it++
    Iterator<T> operator++(int)
    {
        Iterator<T> temp = *this;
        _node = _node->_next;
        return temp;
    }
    // --it
    Iterator<T>& operator++()
    {
        _node = _node->_prev;
        return *this;
    }

    // it--
    Iterator<T> operator++(int)
    {
        Iterator<T> temp = *this;
        _node = _node->_prev;
        return temp;
    }

    T& operator*()
    {
        return _node->_data;
    }

    bool operator==(const Iterator& right)
    {
        return _node == right._node;
    }

    bool operator !=(const Iterator& right)
    {
        return _node != right.node;
    }
public:
    Node<T>* _node;
}

template<typename T>
class List
{
public:
    List(): _size(0)
    {
        _header = new Node<T>();
        _header->_next = _header;
        _header->_prev = _header;
    }
    ~List()
    {
        while(_size > 0)
            pop_back();
        delete _header;
    }

    void push_back(const T& value)
    {
        AddNode(_header, value);
    }

    void pop_back()
    {
        RemoveNode(_header->_prev);
    }

    Node<T>* AddNode(Node<T>* before, const T& value)
    {
        Node<T>* node = new Node<T>(value);
        Node<T>* prevNode = before->_prev;
        prevNode->_next = node;
        node->_prev = prevNode;
        node->_next = before;
        before->_prev = node;

        _size++;
        return node;
    }

    Node<T>* RemoveNode(Node<T>* node)
    {
        Node<T>* _prevNode = node->_prev;
        Node<T>* _nextNode = node->_next;
        _prevNode->_next = _nextNode;
        _nextNode->_prev = _prevNode;

        delete node;
        _size--;
        return nextNode;
    }

    int size(){return _size;}

public:
    typedef Iterator<T> iterator;
    iterator begin() { return iterator(_header->_next); } // Header in the Last element would be the frist elem
    iterator end() { return iterator(_header); }
    iterator insert(iterator it, const T& value)
    {
        Node<T>* node = AddNode(it._node, value);
        return iterator(node);
    }

    iterator erase(iterator it)
    {
        Node<T>* node = RemoveNode(it._node);
        return iterator(node);
    }
public:
    Node<T>* _header;
    int _size;
};

int main()
{
    list<int> li;
    list<int>::iterator eraseIt;
    for (int i = 0; i < 10; i++)
    {
        if (i == 5)
        {
            eraseIt = li.insert(li.end(), i);
        }
        else
        {
            li.push_back(i);
        }
    }

    li.pop_back();
    li.erase(eraseIt);

    for (list<int>::iterator it = li.begin(); it != li.end(), ++it)
    {
        cout << (*it) << end;s
    }
    return 0;
}

Deque

이제 vector 와 list 를 알아 보았다. 이 둘은 sequence container 라고 하는데, 데이터가 넣어지는대로 sequential 하게 넣어지기 때문이다. 우리가 이제 새로배울건 deque, double-ended queue 라고 한다. deque 같은 경우는 vector 와 list 의 사이로 생각하면 된다. 기존에 vector 에서는 배열의 크기를 늘리려면 새로운걸 크게 할당한다음에 복사하는 형태 였다. 하지만 deque 같은 경우는 그 배열 자체를 늘리는게 아닌 새로운 메모리 영역을 이어지게끔 즉 list 형식으로 만들어진다. 결론적으로 vector 와 마찬가지로 배열 기반으로 동작하지만, 메모리 할당하는 방식이 List 와 같다. 아래의 코드를 보면 vector 와 다르게 push_front 를 지원하는걸 볼수 있다.

#include <deque>
#include <vector>

int main()
{
    vector<int> v(3,1);
    deque<int> dq(3, 1);

    v.push_back(2);
    v.push_back(2);

    dq.push_back(2);
    dq.push_back(2);

    dq.push_front(3);
    dq.push_front(3); 
    return 0;
}

그렇다고 하면 vector 와 마찬가지로 처음 / 끝 에 대한 삽입 / 삭제가 효율성은 좋고 중간 삽입 삭제가 효율성이 않좋다는걸 확인 할수 있다. 임의 접근 같은 경우는 deque 는 아파트와 같다. deque 에서 F11 를 누르면 확인할 수 있는게, Offset 이라는 친구가 있어서 몇번째 층에 있는지를 확인할수 있고, 거기에 하나씩하나씩 element 를 더하는게 보인다. 즉 offset 과 얼만큼 떨어져있는지를 봐보면 임의 접근은 쉽게 된다는게 장점이다.

Map

Python 과 C# 코드를 보면 Dictionary 라는 타입이 존재 할거다. 바로 Key 와 Value 로 매칭되는식으로 연결되어있는 Hashtable 같은 자료구조이다. c++ 에서도 이런걸 지원하는데 바로 Map 이라는 친구이다. 이 친구는 연관 컨데이너라고도 부른다. 만약에 Python 을 사용해보았더라면, dict 의 indexing 하는 법과 data 를 꺼내오는 방법, 초기 생성등 알것이다. 일단 다시 돌아와서 vector 와 list 의 치명적인 단점으로 꼽자면, 뭔가 아이디에 매칭되는값을 찾으려고 할때 생각보다 코드가 많이 들어간다. 이걸 보완할수 있는게 바로 Map 이다. Map 에서는 균형 이진 트리 (AVL) 로 되어있으니까, 노드 기반을 되어있다. 아래의 첫번째 코드를 봐보면, 한노드에 대한 데이터 구조를 확인 할수 있다.

class Node
{
public:
    Node* _left;
    Node* _right;
    // data
    int _key;
    int* _value;
}

그렇다면 Map에 대한 예제를 한번 봐보자. 아래와 같이 살펴보자. 일단 Map 에는 key 와 value 의 타입을 설정해줘야되고, key 와 value pair 이기때문에 pair 라는걸 사용해서 m 에 넣어주었다는걸 확인 할수 있다. 그다음에 어떤 아이디를 찾았다고 한다면 erase 를 통해서 삭제가 가능하다. 뭔가 찾을때는 find 라는 걸 사용하면 되는데 이때의 return 타입을 확인해보면 map 에 있는 iterator 라고 확인 할 수 있다. 만약에 find 를 해서 return 값이 map 을 돌다가 끝에 도착하지 않는다고 한다면 그 key 에 매칭되면 찾은거고, end() 에 왔으면 못찾은거다.

여기서 궁금할수 있는거는 insert 와 erase 를 똑같은 키에다가 데이터를 넣었다고 한다면 어떻게 될까라는 질문을 할수 있다. erase 같은경우는 count 를 내뱉는데, 찾아서 지울께 있다면 1 로 내뱉고, 지워졌는데 또 erase 를 하면 0 으로 return 하는데, 이말은 두번호출은 괜찮다는거다. 하지만 insert 같은 경우, 처음 호출하는 insert 만 적용이되고 두번째 호출된 insert 는 되지않는다. 즉 덮어 쓰이지 않는다. 순회하는 부분도 확인 할 수 있는데 key 와 value 값으로 map 은 이루어져있기 때문에, Map 에 있는 iterator 에 first 값은 key 값이고, second 값은 value 로 이루어져있다는 걸 확인 할수 있다.

#include <map>

template<typename T1, typename T2>
struct Pair
{
    T1 t1;
    T2 t2;
}

int main()
{
    map<int, int> m;

    pair<map<int, int>::iterator, bool> ok; // 확인 기능
    ok = m.insert(make_pair(1, 100));
    ok = m.insert(make_pair(1, 200));
    for (int i = 0; i < 10000; i++)
    {
        m.insert(pair<int, int>(i, i * 100));
    }

    for (int i = 0; i < 5000; i++)
    {
        int randomValue = rand() % 5000;

        m.erase(randomValue);
    }

    // find the data
    map<int, int>::iterator findIt = m.find(1000);
    if (findIt != m.end())
    {
        cout << "Found" << endl;
    }
    else
    {
        cout << "Not Found" << endl;
    }

    unsigned int count = 0;
    count = m.erase(10);
    count = m.erase(10);

    // iteration on map

    for(map<int, int>::iterator it = m.begin(); it != m.end(); ++it)
    {
        pair<int, int>&p = (*it);
        int key = p.first; // it->first
        int value = p.second; // it->second
    }
    return 0;
}

이 이후에 확인 해야될거는 map 안에 key / value pair 값이 있느냐 없느냐의 따라서 insert 를 해주는 코드이다. [] operator 사용할시의 유의점이 있는데, 대임을 하지 않더라도 (key/value) 형태의 데이터가 추가 된다. 이때는 강제로 0 으로 initialize 시켜준다.

if (findIt != m.end())
{
    findIt->second = 200;
}
else
{
    m.insert(make_pair(10000, 300));
}
// 없으면 추가, 있으면 수정
m[5] = 500;

m.clear()
for (int i = 0; i < 10; i++)
{
    cout << m[i] << endl;
}

Set, Multimap, and Multiset

map 의 형제들을 초대하려고 한다. set, multimap, and multiset 이다. set 같은 경우 map 과 달리 단독적으로 key 만 사용하고 싶을때 사용하는 자료구조이다. 아래와 같이 코드를 보면서 set 을 확인 해볼수 있다.

#include <set>

int main()
{
    set<int> s;

    s.insert(10);
    s.insert(20);
    s.insert(30);
    s.insert(40);

    s.erase(40);
    s.erase(30);

    set<int>::iterator findIt = s.find(50);
    if (findIt != s.end())
    {
        cout << "found" << endl;
    }

    for (set<int>::iterator it = s.begin(); it != s.end(); ++it)
    {
        cout << (*it) << endl;
    }

    // s[2] =10 // not allowed
}

multimap 과 multiset 같은경우는 동일한 key 값에 대해서 다른 value 가 있을때 사용할수 있는 자료구조인데, 아래의 코드 에서 확인을 해보자. 일단 multimap 같은 경우 경우 map 과 다르게 동일한 key 에 다른 Value 가 들어간거니, 뭔가 혼동을 줄이기위해서 직접 안에 들어가 데이터를 수정하는건 막혀있다. 여기서 질문 할수 있는건, 지울때 key 값을 넘겨줬을때, 그때는 어떤 value 가 들어있든 상관없이, 그 key 에 해당하는 pair 들을 다 삭제한다. 만약에 그럼 특정 value 에 지우고 싶다면 어떻게 할까? 그럴땐 아래와 같이 iterator 를 돌아서 제일 먼저 찾아지는 친구를 지우게끔 할수도 있다.

#include <map>
#include <set>

int main()
{
    multimap<int, int> mm;
    mm.insert(make_pair(1, 100));
    mm.insett(make_pair(1, 200));
    mm.insett(make_pair(2, 200));
    mm.insett(make_pair(2, 500));

    // mm[1] = 500 // not allowed 
    unsgined int count = mm.erase(1);

    multimap<int, int>::iterator itFind == mm.find(1);
    if (itFind != mm.end())
    {
        mm.erase(itFind);
    }

    pair<multimap<int, int>::iterator, multimap<int, int>::iterator> itPair;
    itPair = mm.equal_range(1);

    multimap<int, int>::iterator itBegin = mm.lower_bound(1); // 1 이나오는 첫 순간
    multimap<int, int>::iterator itEnd = mm.upper_bound(1);   // 1 이끝나는 마지막
    for (multimap<int, int>::iterator it = itPair.first; it != itpair.second; ++it)
    {
        cout << it->first << " " << it->second << endl;
    }

    for (multimap<int, int>::iterator it = itBegin; it != itEnd; ++it)
    {
        cout << it->first << " " << it->second << endl;
    }

    multiset<int> ms;

    ms.insert(100);
    ms.insert(100);
    ms.insert(100);
    ms.insert(200);
    ms.insert(200);
    ms.insert(200);

    multiset<int>::iterator findIt = ms.find(100);
    
    pair<multiset<set>::iterator, multiset<int>::iterator> itPair2;
    itPair2 = ms.equal_range(100);

    for(multiset<int>::iterator it = itPair2.first; it != it != itPair2.second; ++it)
    {
        cout << (*it) << endl;
    }

    multiset<int>::iterator itBegin = ms.lower_bound(100);
    multiset<int>::iterator itEnd = ms.upper_bound(100);

    for(multiset<int>::iterator it = itBegin; it != itEnd; ++it)
    {
        cout << (*it) << endl;
    }
    return 0;
}

Algorithm

이제까지 data sturcutre 를 알아봤다. 사용에 따라서, data 를 어떻게 생성해주고, 선언하는걸 알아보았다. 하지만 여기서 끝나는건 아니다 데이터를 만들었으면 가공도 해야되기때문에 c++ 에서는 algorithm 이라는 라이브러리가 있다. 대표적으로 사용되는 걸 알아보자.

  1. find
  2. find_if
  3. count
  4. count_if
  5. all_of
  6. any_of
  7. none_of
  8. for_each
  9. remove
  10. remove_if

위에서 사용한 method 를 아래와 같이 question 과 구현을 해보았다. 다만 removeremove_if 를 조심하자! 이 둘은 결국 vector 안에서 중간 삭제나 처음 삭제가 일어나는데, 이때 먼저 필요한 데이터만 뽑아와서 복사를 한다음에, 복사 할필요 없는 element 도 복사 하니까, 실제 filtering 이 잘 안되어있을수 있다. 그래서 실제 return 값은 filtering 이 끝나는 위치(iterator)를 return 한다.

#include <algorithm>
#include <vector>
int main()
{
    int number = 50;
    // Q1 : Find the element if the number matches
    std::vector<int>::iterator itFind = std::find(v.begin(), v.end(), number);
    if (itFind == v.end())
    {
        cout << "not found" << endl;   
    }
    else
    {
        cout << "Found" << endl;
    }

    // Q2 : check if the element in vector is divisible by 11.
    struct CanDivideBy11
    {
        bool operator()(int n)
        {
            return (n % 11 == 0);
        }
    };
    std::vector<int>::iterator itFind = std::find_if(v.begin(), v.end(), CanDvideBy11());
    if (itFind == v.end())
    {
        cout << "not found" << endl;   
    }
    else
    {
        cout << "Found" << endl;
    }

    // Q3 :find how many odd number is in vector
    struct isOdd
    {
        bool operator()(int n)
        {
            return (n % 2) != 0;
        }
    };
    std::vector<int>::iterator ifFind = std::count_if(v.begin(), v.end(), isOdd())
    if (itFind == v.end())
    {
        cout << "not found" << endl;   
    }
    else
    {
        cout << "Found" << endl;
    }

    // 모든 데이터 홀수?
    bool b1 = std::all_of(v.begin(), v.end(), isOdd());
    // 홀수인 데이터가 하나라도 있어?
    bool b2 = std::any_of(v.begin(), v.end(), isOdd());
    // 모든 데이터가 홀수가 아니야?
    bool b3 = std::none_of(v.begin(), v.end(), isOdd());
    
    // Q4 multiply three on every elements in a vector
    struct MultiplyByThree
    {
        bool operator()(int &n)
        {
            n = n * 3;
        }
    };
    std::for_each(v.begin(), v.end(), MultiplyByThree);

    // Q5 remove all data which is an odd number
    v.clear();
    v.push_back(1);
    v.push_back(4);
    v.push_back(5);
    v.push_back(2);
    v.push_back(3);

    vector<int>::iterator it = std::remove_if(v.begin(), v.end(), IsOdd());
    v.erase(it, v.end());
    // v.erase(std::remove_if(v.begin(), v.end(), IsOdd()), v.end());
    return 0;
}

Resource

Source Code

Function Pointer

함수 선언이나 변수 선언을 할때, 항상 앞에는 타입이 존재했다. return 받을 타입이나, 아니면 이 타입으로 저장한다는 식으로. 그렇다면 함수를 변수로 사용할수 있을까? 의 질문의 시작이 함수 포인터의 시작이다.

일단 함수를 뭔가 변수로 설정하려고 한다면, 함수의 signature 이 중요하다. 일단은 사실상 함수의 이름은 신경 써주지 않는다고하고 아래의 코드를 보면 int(int a, int b) 이런식으로 된다. 근데 어떻게 이름을 줄까? 라는 생각을 해보는데. 이건 typedef 로 생각하면 된다.

그래서 signature 은 typedef int(FUNC_TYPE)(int a, int b) 이런식으로 주면 된다. 하지만 Modern C++ 에서는 더 편한걸로 using 을 사용해서, using FUNC_TYPE = int(int a, int b) 이런식으로 표현이 가능하기도 하다. 그런다음에 FUNC_TYPE* fn 이라고 생성하면 함수의 포인터라고 할수 있다.

int Add(int a, int b)
{
    return a + b;
}

int main()
{
    int Add(int a, int b);
    return 0;
}

아래의 코드를 봐보기전에, 뭔가 함수의 return 값을 봐보면, 함수의 시작 주소가 있다는걸 힌트를 알수 있다. 그 말은 즉슨 포인터를 이용해, 그 함수를 point 하게 둘수 있다는 말이다. 아래와 같이 fn 즉 FUNC_TYPE 의 signature 만 맞으면, 함수를 호출이 가능하다는 소리이다. 그리고 아래의 (*fn) 이 signature 같은 경우는 fn 을 타고 들어가서 (1, 2) 를 넣어준다라고 생각하면 될것 같다.

int Add(int a, int b)
{
    return a + b;
}

int main()
{
    typedef int(FUNC_TYPE)(int, int);
    FUNC_TYPE* fn;
    fn = Add;

    int result = fn(1, 2);
    int result = (*fn)(1, 2);
}

근데 구지 생각을 해보면, 왜 이렇게 까지 써야되냐? 라는 질문을 할 수 있는데, 그 이유는 함수의 signature 가 동일하게 되면 쉽게 스위칭이 가능하다 라는 말이다. 아래의 코드를 한번 봐보자. Add 를 다 쓴 이후 Sub 으로 바로 스위칭이 가능하다. 어떻게 보면 나머지를 고칠 필요 없이, 강력한 Tool 로 쓸수 있다.

int Add(int a, int b)
{
    return a + b;
}

int Sum(int a, int b)
{
    return a - b;
}

int main()
{
    typedef int(FUNC_TYPE)(int, int);
    FUNC_TYPE* fn;
    fn = Add;
    // add.. 를 다 쓴이후
    fn = Sub;
    // sub 처리
}

또 다른 예제를 알아보자. 아래의 Item 클래스를 만들어줬고 Item pointer 를 return 해주는 코드가 있다고 하자. 뭔가 같은 기능을 계속 인자만 바꿔서 주는게 굉장히 코드가 길어지고, 유지 보수가 좋지 않다. 그래서 인자값으로 함수의 안전체크나 조건문들을 넘겨주게 된다면 이 코드의 유지 보수성은 더 올라갈것이다.

class Item
{
public:
    Item() : _itemId(0), _rarity(0), _ownerId(0){}
public:
    int _itemId;
    int _rarity;
    int _ownedId;
};

Item* FindItemByItemId(Item items[], int itemCount, int itemId)
{
    for(int i = 0; i < itemCount; i++)
    {
        Item* item = &items[i];
        if (item->_itemId == item)
            return item;
    }

    return nullptr;
}

Item* FindItemByRarity(Item items[], int itemCount, int rarity)
{
    for(int i = 0; i < itemCount; i++)
    {
        Item* item = &items[i];
        if (item->_rarity == rarity)
            return item;
    }

    return nullptr;
}

그래서 더 낳은 코드를 한번 봐보겠다. 그리고 이번엔 rarity 를 체크 해보겠다.

class Item
{
public:
    Item() : _itemId(0), _rarity(0), _ownerId(0){}
public:
    int _itemId;
    int _rarity;
    int _ownedId;
};

void IsRareItem(Item* item)
{
    return item->_rarity >= 2;
}

Item* FindItem(Item items[], int itemCount, bool(*selector)(Item*item))
{
    for(int i = 0; i < itemCount; i++)
    {
        Item* item = &items[i];
        if (selector(item))
            return item;
    }

    return nullptr;
}

int main()
{
    Item item[10] = {};
    item[3]._rarity = 2;
    Item* rareItem = FindItem(items, 10, IsRareItem);
}

그런데, 물론 장점도 있지만 단점도 있다. 아까 계속 언급한 함수의 signature 가 달라진다면 물론 다르게 표현을 해야된다. 아래의 코드를 보면 실패하게 된다 왜냐하면 isOwnerItem 의 signautre 가 아까 IsRareItem 과 다르기 때문이다. 그래서 아래의 코드를 보면 같이 맞춰지게 int 값도 따로 추가해주면 된다. 즉 꼬리의 꼬리물기가 되서, 뭔가 함수의 인자수가 많아져서 좋지 않은 구조가 된다. 즉 결론은 함수도 주소가 있어서 함수포인터를 사용해서 함수의 call 바로 할수 있다.

class Item
{
public:
    Item() : _itemId(0), _rarity(0), _ownerId(0){}
public:
    int _itemId;
    int _rarity;
    int _ownedId;
};

void IsRareItem(Item* item, int)
{
    return item->_rarity >= 2;
}

bool isOwnerItem(Item* item, int ownderId)
{
    return item->_ownderId == ownderId;
}

Item* FindItem(Item items[], int itemCount, bool(*selector)(Item*item, int), int value)
{
    for(int i = 0; i < itemCount; i++)
    {
        Item* item = &items[i];
        if (selector(item, value))
            return item;
    }

    return nullptr;
}

int main()
{
    Item item[10] = {};
    item[3]._rarity = 2;
    Item* rareItem = FindItem(items, 10, isOwnerItem);
}

사실은 블로그를 커버하다가, 한번도 typedef 에 대해 설명을 하지 않았다. typedef 그냥 형태 만 봤을때 typedef [] [] 이런식으로 생겼다. 하지만 봤을때 오른쪽이 커스텀 타입을 정의를 했었다. 근데 이걸 더 정확하게 보면, 선언 문법에서 typedef 를 앞에다 붙이는쪽으로 왔었다. 아래와 같이 선언을 하면, 바로 앞에 typedef 를 붙여지는거다. 그래서 Code Segment 를 봤을때 아래를 보면 함수의 Signature 은 (int, int)의 인자를 받고 int 로 받고, 그다음에 함수의 포인터이기때문에 (*PFUNC) 라는걸 선언을 한거다. 그다음에 typedef 를 넣으면 된다.

int INTEGER;
int *POINTER;
int FUNC();

int (*PFUNC)(int, int);

근데 또 단점이 있다. 위와같이 사용할때는 전역함수 / 정적함수만 담을 수 있다. 즉 호출 규약이 정해져있다는 소리이다. 아래처럼 Member Function 에서는 에러가 나온다는걸 확인할수있다.

typedef int(*PFUNC)(int, int);

class Knight
{
public:
    // Static Function
    static void HelloWorld(){}
    
    // Member Function
    int GetHP(){ return _hp; }

    int _hp = 100;
}

int main()
{
    PFUNC fn;
    // fn = GetHP; // 에러
    // fn = &Knight::GetHp;
    return 0;
}

그래서 이걸 멤버함수에 속한다라는걸 보여주어야기 때문에 아래의 코드 처럼 하면된다. 아래에 보면, 주소값을 달라는 표시도 해주어야한다. 이건 C 언어의 호환성 때문에 한다.


class Knight
{
public:
    // Static Function
    static void HelloWorld(){}
    
    // Member Function
    int GetHP(){ return _hp; }

    int _hp = 100;
}

typedef int(Knight::*MEMBERPFUNC)(int, int);

int main()
{
    PMEMFUNC mfn;
    mfn = &Knight::GetHp;

    Knight k1;
    (k1.*mfn)(1, 1);

    Knight* k2 = new Knight();
    (k2->*mfn)(1,1);
    delete k2;

    return 0;
}

일반적으로 자신과 다른 클래스가 있고 멤버함수가 동일하다고 하더라도, 이미 지정해주었기 때문에, 객체를 바꾸더라도 실행이 안된다는거에 주의하자.

Functor

함수 객체는 함수처럼 동작하는 객체를 뜻하는데, 위와 같이 함수 포인터의 단점이 너무 잘보였었다. 일단 함수의 signature 가 동일한 친구들 만 사용됬었고 다르다고 하다면, 인자를 늘려가야하는 큰단점, 즉 generic 하게 사용하지 못한다는 점이 큰 단점이 였다. 또 다른 큰 단점은 자세하게 debug 하지 않으면 객체의 상태의 유지성을 모른다는거다. 예를 들어서 Knight 의 객체 안에 _hp 라는 field 가 있는데, 함수 포인터 같은 경우는 인자만 넘기지, 그 field 가 뭘하는지, 유지 됬는지 잘모른다는 뜻이다.

함수처럼 동작하는 객체라는게 뭘까라는 걸 알아보자. 일단 함수 처럼 작동하려면 힌트는 () 이런 연산자가 필요하다. () 연산자 오버로딩이 필요하다는거다.

class Functor
{
public:
    void operator() ()
    {
        cout << "Functor Test1" << endl;
    }
    bool operator() (int num)
    {
        cout << "Functor Test2" << endl;
        _value += num;
        cout << _value << endl;
    }
private:
    int _ value = 0;
}

int main()
{
    Functor functor;
    functor();

    bool ret = functor(3);
    return 0;
}

예시로 보여준건 MMO 에서 함수 객체를 사용하는 예시가 있다. 게임은 클라와 서버가 있는데, 서버같은 경우는 클라가 보내준 네트워크 패킷을 받아서 처리하는데 만약에, 클라가 (5,0) 으로 좌표로 이동 시켜줘! 라고 서버한테 요청을 했다고 하자. 실시간 MMO 라고 하면 클라가 정말 많을텐데, 이때 처리할때 사용할수 있다. 이때, Functor 만들어준 시점과 그리고 실제 실행할 시점을 분리 시키는걸 볼수 있다. 아래가 바로 Command Pattern 을 비슷하게 사용해서 Tracking 이 가능할거다.

class MoveTask
{
public:
    void operator()()
    {
        // TODO
        cout << "Move" << endl;
    }
public:
    int _playerId;
    int _posX;
    int _posY;
}

int main()
{
    MoveTask task;
    task._playerId = 100;
    task._posX = 5;
    task._posY = 3;

    // 나중에 여유될때 일감을 실행
    task();
    return 0;
}

Template Basics

Template 이란 함수나 클래스를 찍어내는 툴이라고 생각하면 된다. 템플릿의 종류는 Function Template 과 Class Template 이 존재한다. 일단 예시같은 경우는 이런거다 여러 다른 타입들을 받는 똑같은 함수가 존재한다고 하자. 다 똑같은 기능을 가지고 있지만 인자값으로 다르게 존재 하는걸 볼수 있다. 이거를 한번에 묶을수 있는 존재가 있을까? 라는 생각이든다. 그게 바로 형식을 틀로 잡을수 있는 template 이 있다. 즉 조커 카드이다.

#include <iostream>
using namespace std;
void Print(int a){ cout << a << endl;}

void Print(float a){ cout << a << endl; }

void Print(double a){ cout << a << endl; }

Template 에 대해서 한번 봐보자. 일단 아래의 코드처럼 typename T 라고 지정해준걸 볼수 있다. 그리고 그 인자의 type 을 Print() 함수에 넣어진걸 확인 할 수 있다. 즉 어떤 타입이 들어오든지 T 로 들어와서 컴파일러가 각각 type 을 정해주는걸로 볼수 있다. 아래 처럼 코드를 실행 해보았을때, 잘 빌드가 성공된걸 볼수 있다. 그리고 맨 아래에 보면 Print 뒤에 <double> 이란게 나와있다. 이거 같은 경우는 컴파일러에 맡기는것보다 내가 이런 형식을 원한다라는걸 강제적으로 줄수도 있다. 이말은 즉슨 컴파일러에게 힌트를 주는거다. 그리고 아래의 Add 함수를 봐보자. 여기에서는 인자가 같을때 즉 (int, int), (double, double), 이런식으로 타입이 같은 인자가 들어왔을때도 줄일수도 있고, return 값으로도 T 를 줄수도 있다. 이 말은 또 각기 다른 타입의 인자를 넣어줄수도 있다. PrintMultiple() 을 참고하자. 그리고 객체의 있는걸 Type 으로 넘겨준다고 하면 실행 되지 않는다. 그 이유는 Knight 라는 클래스는 프로그래머가 만든 커스텀 한 타입이기때문에 지원되지 않아서 ofstream 에 있는걸 가져다가 오버로딩을 해줘야한다.

template<typename T>
void Print(T a)
{
    cout << a << endl;
}

template<typename T>
T Add(T a, T b)
{
    return a + b;
}

template<typename T1, typename T2>
void PrintMultiple(T1 a, T2 b)
{
    cout << a << " " << b << endl;
}

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

// 연산자 오버로딩 (전역함수)
ofstream& operator<<(ofstream& os, const Knight& k)
{
    os << k._hp << endl;
    return os;
}

int main()
{
    Print(50);
    Print(50.0f);
    Print<double>(50.0);

    ret = Add(2, 3);

    PrintMultiple("Hello", 3);

    Knight k1;
    Print(k1); // 연산자 오버로딩이 없으면 안됨
    return 0;
}

위와 같이 template 은 조커카드였다. 하지만 어떤객체에 대해서 특수 조커 카드를 만들려고 했을때, 어떻게 해야될까? 라는 질문을 할수 있다. 즉 어떤 규칙을 따르는 template 을 template 특수화라고 한다.아래의 코드를 봐보자. 아래와 같이 어떤 특정한걸 주고 싶을때는 객체를 인자로 넘겨주고 temmplate 안에 있는거는 비어있게 해줘야 template 의 특수화가 된다.

tempalte<>
void Print(Knight a)
{
    cout << "Knight" << endl; 
    cout << a._hp << endl;
}

int main()
{
    Knight k1;
    Print(k1);
    return 0;
}

이제 class template 을 보기위해 아래 코드를 봐보자. 일단 RandomBox 라는 클래스가 있고, 만약에 GetRandomData() 가 만약에 Float 로 내뱉는 return 이 필요하다고 하면 float 데이터를 담을수 있는 바구니가 필요하면서, float 을 return 하는 GetRandomData() version 을 만들어야할것이다. 그래서 이 다음 코드를 봐보면 template 이 적용된걸 볼 수 있다.

class RandomBox
{
public:
    int GetRandomData()
    {
        int idx = rand() % 10;
        return _data[idx];
    }
public:
    int _data[10];
}

int main()
{
    srand(static_cast<unsigned int>(time(nullptr)));
    RandomBox rb1;
    for (int i = 0; i < 10; i++)
    {
        rb1._data[i] = i;
    }
    int value1 = rb1.GetRandomData();
    cout << value1 << endl;
    
    RandomBox rb2;
    for (int i =0; i< 10; i++)
    {
        rb2._data[i] = i;
    }
    int value2 = rb2.GetRandomData();
    cout << value2 << endl;
    return 0;
}
template<typename T>
class RandomBox
{
public:
    T GetRandomData()
    {
        int idx = rand() % 10;
        return _data[idx];
    }
public:
    T _data[10];
}

int main()
{
    srand(static_cast<unsigned int>(time(nullptr)));
    RandomBox<int>rb1;
    for (int i = 0; i < 10; i++)
    {
        rb1._data[i] = i;
    }
    int value1 = rb1.GetRandomData();
    cout << value1 << endl;
    
    RandomBox<float> rb2;
    for (int i =0; i< 10; i++)
    {
        rb2._data[i] = i + 0.5f;
    }
    float value2 = rb2.GetRandomData();
    cout << value2 << endl;
    return 0;
}

그런데 typename 을 무조건 붙여야되는건 아니다. 즉 다시 말해서 template 안에 들어가는건 골라줘야하는 목록 이라고 볼수 있다. 예를들어서 아래의 코드를 봐보자. SIZE 에다가 인자를 아무거나 주되 int type 인자를 받아야하는 어떤 설정을 따로 해줄수 있다. 하지만, rb1, rb2 가 같이 instantiate 했지만, 서로 다른 객체라는걸 알수 있어서 함수의 인자의 signature 이 같다고 하더라도, 객체는 다르게 이루어졌다고 볼수 있다. 위와 같이 함수에서 템플릿 특수화를 썻던것처럼, 클래스에서도 template 특수화를 사용할수 있다.

template<typename T, int SIZE>
class RandomBox
{
public:
    T GetRandomData()
    {
        int idx = rand() % SIZE;
        return _data[idx];
    }
public:
    T _data[SIZE];
}

int main()
{
    srand(static_cast<unsigned int>(time(nullptr)));
    RandomBox<int, 10>rb1;
    for (int i = 0; i < 10; i++)
    {
        rb1._data[i] = i;
    }
    int value1 = rb1.GetRandomData();
    cout << value1 << endl;
    
    RandomBox<int, 20> rb2;
    for (int i =0; i< 20; i++)
    {
        rb2._data[i] = i;
    }
    int value2 = rb2.GetRandomData();
    cout << value2 << endl;

    //rb1 = rb2; // 서로 다른 객체
    return 0;
}

템플릿 특수화를 한번 보자. 위의 코드에서 무조건 double 로 return 하는 값들로만 모아보자. 아래와 같이 보면 double 로 뭔가 규약을주고, 사이즈와 관련된것들은 int 로 아무인자나 받게 보여진다. 만약 double 로 호출을 한다고 하면 RandomBox<double, int SIZE> 가 호출이 되고, 그 외의 것들은 RandomBox 가 호출이 될거다.

template<int SIZE>
template<typename T, int SIZE>
class RandomBox
{
public:
    T GetRandomData()
    {
        int idx = rand() % SIZE;
        return _data[idx];
    }
public:
    T _data[SIZE];
}

class RandomBox<double, int SIZE>
{
public:
    double GetRandomData()
    {
        int idx = rand() % SIZE;
        return _data[idx];
    }
public:
    double _data[SIZE];
}

int main()
{
    srand(static_cast<unsigned int>(time(nullptr)));
    RandomBox<int, 10>rb1;
    for (int i = 0; i < 10; i++)
    {
        rb1._data[i] = i;
    }
    int value1 = rb1.GetRandomData();
    cout << value1 << endl;
    
    RandomBox<double, 20> rb2;
    for (int i =0; i< 20; i++)
    {
        rb2._data[i] = i + 0.5;
    }
    double value2 = rb2.GetRandomData();
    cout << value2 << endl;

    //rb1 = rb2; // 서로 다른 객체
    return 0;
}

Callback Function

Callback 함수 같은 경우는 결국 다시 호출하다. 약간 Call me back 과 같다. 또는 다시 역으로 호출하다라는 느낌이다. Functor 와 마찬가지로 어떤 상황이 일어나면 이 기능을 호출해줘 라는 느낌으로 사용하면 된다.

class Item
{
public:
    int _itemId = 0;
    int _rarity = 0;
    int _ownderId = 0;
}

class FindByOwnderId
{
public:
    bool operator()(const Item* item) // 수정이 필요없음
    {
        return (item->_ownerId == _ownderId);
    }
public:
    int _ownerId;
}

class FindByRarity
{
public:
    bool operator()(const Item* item) // 수정이 필요없음
    {
        return (item->_rarity == _rarity);
    }
public:
    int _rairty;
}

template<typename T>
Item* FindItem(item items[], int itemCount, T selector)
{
    for (int i = 0; i < itemCount; i++)
    {
        Item* item = &items[i];
        // TODO Condition
        if (selector(item))
            return item;
    }
    return nullptr;
}

int main()
{
    Item Items[10];
    items[3]._ownerId = 100;
    items[5]._rarity = 1;
    FindByOwnderId functor1;
    functor1._ownerId = 100;

    FindByRarity functor2;
    functor2._rarity = 1;

    Item* item1 = FindItem(items, 10, functor1);
    Item* item2 = FindItem(items, 10, functor2);
    
    return 0;
}

Resource

Source Code

TextRPG

일단 파일 구조는 아래와 같다.

├── Creature
|   ├── Creature.cpp
│   ├── Creature.h
│   ├── Monster.cpp
|   ├── Monster.h
|   ├── Player.cpp
│   └── Player.h
├── Game
|   ├── Field.cpp
│   ├── Field.h
│   ├── Game.cpp
│   └── Game.h
└── TextRPG.cpp

Resource

Source Code

Obejcted Oriented Programming

Objected Oriented Programming 이란 객체지향 프로그래밍을 의미하며, 기존에 있던 절차지향 프로그래밍 같은경우에는 코드가 분산되서, readability 도 떨어지면서, 사용자에게 배려하는 느낌이 전혀 없어진다. 근데 객체지향에서의 귀찮은 점도 존재한다. 예를들어서 “참, 두세줄이면 끝날 코드인데, 꼭 이렇게 까지 내가 해야할까?” 라는 생각도 들수도 있다. 하지만 객체 지향을 통해서 사용자의 편의성을 극대화 하며, 사용자의 실수를 최대한 제작자가 차단을 해야한다라는 가정하에 객체지향적 프로그래밍을 해야한다.

일단 바로 코드로 넘어가자. 일 단 아래와 같이 Knight 에 대한 간단한 class 를 생성했다. class 생성할떄 Modify 할수 있는 조건이있는데 그게 바로 publicprivate 이있다. 그 이외에건 지금은 생략하고, Inheritance 에서 더 자세하게 설명을 할건데. 지금은 public 은 공공으로 사용할수 있는 변수나 함수(method) 라고 하자. public 으로 기본으로 지정된 Member Variable 과 Methods 가 존재한다.

생각을해보자 클래스라는건 빵틀이라고 생각하면 된다. 예를들어서 Knight 이라는 틀은 기본적으로 Health bar, Attack Attributes, and its position 을 들고 있을거다. 그리고 움직일수도있고, 공격할수도 있고, 죽을수도 있다. 그렇게해서 여러개의 Knight 을 만들수 있을것이다. 이제 구현부에 대해서 이야기 하자. Member Function 또는 Method 같은 경우는 구현부를 Knight class 안에다가 구현할수 있으며, 또는 Knight::Attack 이렇게 구현할수 있는데, 이말은 Knight 클래스 안에 속해 있는 함수다라는 말이다. 똑같은 함수인 move 가 있다고 보여지는데, 하나는 Knight::Move 이고 다른 하나는 parameter 로 Knight 의 주소값을 가져온다고 보여진다. void Move(Knight*) 이 signature 같은 경우, 다른 Knight 를 instantiate 했을때 사용할수 있고, Knight::Move 그 객체가 들고 있는 built-in 함수라고 생각하면 된다.

class Knight
{
public:
// Member Variable
    int _hp;
    int _attack;
    int _posX;
    int _poxY;

// Member Function
    void Move(int y, int x);
    void Attack();
    void Die(int hp)
    {
        _hp = 0;
        cout << "Die " << endl;
    }
};

void Move(Knight* knight, int y, int x)
{
    knight->_posX = x;
    knight->_posY = y;
}

void Knight::Move(int y, int x)
{
    _posY = y;
    _posX = x;
    cout << "Move" << endl;
}

void Knight::Attack()
{
    cout << "Attack" << endl;
}

int main()
{
    // Instantiation
    Knight knight;

    // setting the `knight` member variable
    knight._hp = 100;
    knight._attack = 10;
    knight._posX = 0;
    Knight._posY = 0;
    return 0;
}

Constructor & Destructor

앞에서 말했듯이, 클래스에 ‘소속’된 함수들을 Member Functions 또는 Methods 라고 한다. 이중에 특별한 친구들이 있다. 바로 [시작][끝] 을 알리는 함수들이 있다. 즉 탄생과 소멸을 칭하는 생성자 가 있고 소멸자 가있다. 생성자 같은 경우 여러가지로 존재할수 있고, 소멸자는 단 1개만 가능하다. 생성자같은 경우 그냥 틀이기 때문에 return 값이 없다.

바로 코드를 봐보자. 기본적으로 생성자를 만들때에는 Knight() 라는 생성자를 만들면서, 주로 Member Variable 을 초기화 시켜주는 역활을 한다. 방금 전 생성자는 여러개의 생성자가 존재할수 있다고 했다. 초기에 parameter 를 받을수 있는 생성자가 있을수도 있고(기타 생성자), 그리고 다른 객체를 Copy 할수 있는 생성자가 될수도 있고, 그리고 타입 변환 생성자인 경우도 있다.

아래와 같이 봐보면, Knight() 기본 생성자 같은 경우 member variable 을 세팅 하는걸 볼수 있고 그 기타 생성자 같은 경우는, 객체를 생성할때 parameter; hp, attack, posX and posY 값을 받아오는 생성자가있다.

Copy Constructor(복사 생성자)는 생성자는 생성자인데, 자기 자신의 참조 타입을 인자로 받는다. 클래스를 생성할때, input 값을 받을때, 자기 자신을 받는다고 생각하면 된다. 즉 이 복사 생성자를 만들때는, ‘똑같은’ 데이터를 지닌 객체가 생성되길 원할때, 복사 생성자를 만든다고 생각하면된다.

아래의 코드에 K2 를 생성할때 보자. K1 을 인자로 Copy Constructor 로 인해서 k2 생성해준다. 결과적으로 클론을 하는거나 마찬가지이다. 그렇다면 복사생성자가 없다는 가정하에 k2(k1) 를 빌드 했었을때, 빌드가 된다. default 로 암시적으로 복사생성자가 만들어진다고 생각하면 된다. 그렇다면 왜 명시적으로 만들까? 만약에 인자로 참조값이나 포인터로 요구하는 인자값이 들어갔으면 어떻게할까? 라는 질문에 해답이 있다.

잠시 코드 아래에 k4 를 생성하는것과 k3 를 생성하는것을 보자. 엄연히 같게 보이지만, 서로 다른 역활을 하고 있다. k4 는 기본생성자로 만들졌고, k1 에다가 k4 를 복사 한다고 생성하고, k3 같은 경우 생성을 하는 동시에 복사를 한다. 즉 k3 는 복사생성자로 호출 되는거고, k4 는 기본생성자로 만들어준다음에, 복사를 하는거다.

생성자를 지금 까지 봐보았는데, 이게 생성자를 명시적으로 만들지 않으면, 암시적(implicit) 생성자가 된다. 즉 아무 인바도 받지 않는 [기본 생성자]가 컴파일러에 의해 자동으로 만들어진다. 그러나, 명시적(explicit) 으로 아무생성자 하나 만들면, 자동으로 만들어지던 [기본 생성자] 는 더이상 만들어지지 않는다. 아래의 코드를 실행했을때 main 에 있는 knight instantiate 했을때 기본생성자를 만들지 못하게 된다. k5 같은 경우 명시적으로 만든 생성자가 call 하게 된다.

마지막으로 잠깐 언급했던 기타 생성자를 봐보자. 기타 생성자 같은 경우 앞에 말했다싶이 여러가지 인자를 받으면서 객체를 생성하는것으로 볼수 있었다. 그중에 인자를 1개만 받는 기타 생성자는 타입 변환 생성자 라고 한다. Type Conversion Constructor 같은 경우에는, hp 를 넣어주는거에 더불어, k5 = 1 이라고 만들면 객체를 생성할수 있다. 하지만 여기서 암시적으로 생성된 생성자가 문제가 생긴다. 왜냐하면 k5 = 1 은 우리가 원치 않을수도 있기 때문이다. 그래서 타입변환 생성자에 explicit 즉 명시적인걸로 막아줘야한다. 즉 명시적으로 call 할때도 k5 = (knight)1 이런식으로 해야 더 코드가 명료해진다고 볼수 있다.

class Knight
{
public:
    // Constructor.
    Knight()
    {
        cout << "Knight() 기본생성자 called" << endl;
        _hp = 100;
        _attack = 10;
        _posX = 0;
        _posY = 0;
    }

    // Type Conversion Constructor
    // explicit!
    explicit Knight(int hp)
    {
        cout << "Knight(int ) called" << endl;
        _hp = hp;
        _attack = 10;
        _posX = 0;
        _posY = 0;
    }

    // Etc Constructor
    Knight(int hp, int attack, int posX, int posY)
    {
        _hp = hp;
        _attack = attack;
        _posX = posX;
        _posY = posY;
    }

    // Copy Constructor
    Knight(const Knight& knight)
    {
        _hp = knight._hp;
        _attack = knight._attack;
        _posX = knight._posX;
        _posY = knight._posY;
    }

    // Destructor
    ~Knight(){cout << "Knight() 소멸자 called" << endl;}

    void Move(int y, int x);
    void Attack();
    void Die(int hp)
    {
        this->_hp = 0;
        cout << "Die" << endl;
    }

public:
    int _hp;
    int _attack;
    int _posX;
    int _posY;
};

int main()
{
    Knight k1;
    k1._hp = 100;
    k1._attack = 20;

    // 1) Copy Constructor
    Knight k2(k1);

    // 2) Copy Constructor
    Knight k3 = k1;

    // 3) Copy Constructor
    Knight k4;
    k4 = k1;

    // 4) explicit
    Knight k5(10);

    // 5) Type Conversion Constructor
    // implicit version -> compliler will automatically switch
    int num = 1;
    float f = num; // explicit version float f = (float)num;

    Knight k6;
    k6 = 1; // ?

    return 0;
}

Inheritance

객체 지향 프로그래밍에서 중요한 속성들이 있는데 아래와 같다.

  1. 상속성
  2. 은닉성
  3. 다형성

만약 지금까지 배워온거라고 한다면, 클래스는 하나의 설계도 인데, 그러면 여러가지 instance 를 만들어야한다고 가정했을때, 똑같은 데이터 같은경우는 struct 로 관리하면 된다고 치지만, 똑같은 기능이 있는 클래스도 다시 만들어줘야하느냐? 라는 질문도 할수 있다. 그렇다고 한다면, 뭔가 설계도를 더 계층 구조로 짜면 어떨까? 라는 질문에서 비롯된게 상속성(Inheritance)을 볼수있다. 여기에서는 상속성(Inheritance)를 살펴보자.

상속이라는 단어는 결국 부모가 있고, 그 자식에게 유산을 물려주는것으로 볼수 있다. 즉 기능등을 물려줄수 있다고 한다. 아래의 코드를 봐보자. 클래스에서 상속을 받고 있다 라는 문법을 쓰자면 class Knight : public Player 이렇게 사용할수 있는것으로 볼수 있다. 즉 Player 의 member variable 과 functions 를 상속받을수 있게된다. 그렇다면 궁금할수 있는게, 메모리에 어떻게 잡혀질까? 라고 궁금해 할수 있다. 메모리 관점에서 보면 [ Kinght ] 안에 [ Player ] 가 있다고 생각하면 된다.

코드를 잠깐 봐보면, 일단 부모님에서 정의 한 method 를 Knight 에서 재정의 해서 사용할수 있다는것을 볼수 있고, 부모님의 본래의 함수를 call 을 하려면, k::Player::Move() 라고 사용할수 있을거다.

class Player
{
public:
    Player(){ _hp=0; _attack=0, _defence=0; cout << "player constructor" << endl; }
    Player(int hp){ _hp = hp; }
    ~Player(){ cout << "player destructor" << endl; }
    void Move() { cout << "Player " << endl; }
    void Attack() { cout << "Player Attack" << endl; }
    void Die() { cout << "Player Die " << endl; }

public:
    int _hp;
    int _attack;
    int _defence;
};

class Knight : public Player
{
public:
    Knight() {
        _stamina=0;
        cout << "Knight Constructor" << endl;
    }
    Knight(int stamina) : Player(100)
    {
        _stamina=stamina;
        cout << "Knight Constructor" << endl;
    }
    ~Knight() {cout << "Knight Desturctor" << endl; }

    // redefined : 부모님의 유산을 거부하고 새로운 이름으로 만듬
    void Move() { cout << "Knight Move" << endl; }
public:
    int _stamina;
};

class Mage : public Player
{
public:
    int _mp;
};

int main()
{
    Knight k;
    k._hp = 100;
    k._attack = 20;
    k._defence = 10;
    k.Attack();
    k.Player::Move(); // player move (parent)
    k.move(); // knight move (child)
    return 0;
}

그렇다고 한다면, constructor / destructor 측면에서 한번 다시 생각해보자. constructor 같은 경우 여러개의 constructor 을 생성할수 있고, destructor 같은 경우 하나만 존재한다는 언급을 했었다.

그렇다면 위의 코드를 한번 봐보자, 일단 child 나 parent 의 기본 생성자와 소멸자를 만들었다. 그렇다면 궁금즘은 이거다 생성자는 class 가 instantiate 했을때, 또는 탄생했을때 호출되는 함수라고 했었다. 그렇다고 한다면 Knight 를 생성했을때, Player 의 생성자가 호출이 될지 knight 의 생성자가가 호출 될지 궁금증이 생긴다. 결론적인 답은 둘다 호출하자 이렇게 생성이된다. 그리고 생성이되는 순서는 부모님 먼저 호출이 되고 그다음에 child 가 호출이 된다음에 소멸될때에는 자식이 먼저 호출이 되고, 그다음 부모님이 호출이 된다고 볼수 있다.

좀더 자세하게 child class 의 생성자가 언제 call 되는 영역이 어딘지 확인해보자. 아래와 같이 볼수 있다. 일단 Child 가 Instantiate 했을때, Knight 의 생성자 Knight() 이거나 Knight(int stamina) 가 call 이되면서, 선처리 영역에서 부모의 생성자가 호출이 된다. 그래서 부모인 Player() 가 호출이 되고 cout 으로 생성자가 호출 됬다는걸 확인 할 수 있다. 생성자와 달리 소멸자같은 경우는, ~Knight() 가 호출이 되고, 즉 child 가 호출이 된다음, 후처리 영역에서 Parent 인 ~Player() 소멸자가 호출 되는걸 볼수 있다. 그리고 추가해야할 문법은, 부모님의 생성자를 다른걸 선택? 하고 싶으면 Knight(int stamina) : Player(100) 이런식으로 해서 선처리 영역에서 Player(int hp) 를 호출 하게끔 하면 된다.

class Player
{
public:
    Player(){ _hp=0; _attack=0, _defence=0; cout << "player constructor" << endl; }
    Player(int hp){ _hp = hp; }
    ~Player(){ cout << "player destructor" << endl; }
    void Move() { cout << "Player " << endl; }
    void Attack() { cout << "Player Attack" << endl; }
    void Die() { cout << "Player Die " << endl; }

public:
    int _hp;
    int _attack;
    int _defence;
};

class Knight : public Player
{
public:
    Knight() {
        /*
         * 선(처리) 영역
         * - 여기서 Player() 생성자 호출
         */
        _stamina=0;
        cout << "Knight Constructor" << endl;
    }
    Knight(int stamina) : Player(100)
    {
        //

        /*
         * 선(처리) 영역
         * - 여기서 Player() 생성자 호출
         */
        _stamina=stamina;
        cout << "Knight Constructor" << endl;
    }
    ~Knight() {cout << "Knight Desturctor" << endl; }
    /*
     * 후처리영역
     * - 여기서 Player() 소멸자 호출
     */
    void Move() { cout << "Knight Move" << endl; }
public:
    int _stamina;
};

int main()
{
    Knight k;
    return 0;
}

결국에는 상속을 쓰면, 코드가 간결해지고 가독성이 높아진다는걸 알수 있다.

Hiding

Hiding 은 한마디로 은닉성(Data Hiding) 또는 Encapsulation 이라고 한다. 여기서 이야기하는건 데이터의 권한 문제라고 생각하면 된다. 그렇다면 왜 숨기고 이걸 보호 해야하냐 라는 질문을 할 수 있다. 대표적인 이유는 중 하나는 정말 위험하고 유저가 함부러 건드리면 안되는 경우가 있고, 나머지 하나는 다른 경로로 접근하길 원하는 경우가 있다. 예를 들어서, 자동차가 있다 유저가 실제로 보고 작동할수 있는건, Handle, Excel Pedal, and Break 가 있다. 물론 자동차를 관리하는 사람들을 제외 하고, 일반인들은 엔진이나 엔진에 묶여있는 와이어를 손을 덴다고 하면, 차가 쉽게 망가지기 마련이다. 어떤부분은 유저들에게 안보여지게 하고, 다른 부분들은 보여지는거다. 그러면 이런것을 어떻게 문법으로 적용을 할것인가? 라는 질문을 할수있다. 이걸 접근 지정자 라고 한다. 접근지정자 같은 경우 아래 세가지가 있고, 그에 관한 설명이 있다. 코드를 한번봐보자.

  • public : 누구한테나 실컷 사용하세요
  • protected : 나의 자손들한테만 허락
  • private : 나만 사용할께 (즉 자신 내부에서만)
class Car
{
public: // 접근 지정자
    void MoveHandle() {}
    void PushPedal() {}
    void OpenDoor() {}

    void TurnKey(){ RunEngine(); }

protected:
    void DisassembleCar() {}
    void RunEngine() {}
    void ConnectCircuit() {}
};

위의 코드 같이, MoveHandle,PushPedal,OpenDoor,TurnKey 같은 경우는 유저가 자동차의 겉표면? 쉽게 사용할수 있는 기능들이다. 하지만 DisassembleCar, RunEngine, ConnectCircuit 같은 경우는 private 으로 class 내부에서만 사용할수 있지만, 상속을 받을수 있기 때문에 protected 로 보호할수있다. 또 TurnKey() 내부 함수에서 protected 로 지정된 함수를 call 할수 있게 해놓았다.

class Car
{
public: // 접근 지정자
    void MoveHandle() {}
    void PushPedal() {}
    void OpenDoor() {}

    void TurnKey(){ RunEngine(); }

protected:
    void DisassembleCar() {}
    void RunEngine() {}
    void ConnectCircuit() {}
};

class SuperCar : public Car // 상속 접근 지정자
{
public:
    void PushRemoteController(){ RunEngine(); }
};

int main()
{
    Car car;
    return 0;
}

그렇다면 위에서 이야기 했던 상속 부분을 좀 더 생각해보자. 접근 지정자가 있다고 한더라면, 상속 접근 지정자를 빼먹을수 있다. 상속 접근 지정자 같은 경우, 다음 세대에 어떻게 부모님의 유산을 어떻게 물려줄지? 가 테마라고 생각하면된다. 즉 부모님한테 물려받은 유산을 꼭 나의 자손한테도 똑같이 물려줘야하지 않는다는 뜻이다. 멤버 접근 지정자처럼 상속 접근 지정자에 대한 설명을 해볼까 한다.

  • pubilc : 공개적으로 상속? 부모님의 유산 설계 그대로! (public -> public / protected -> protected)
  • protected : 보호받는 상속? 내 자손들한테만 물려줄꺼야! (public -> protected / protected -> protected )
  • private : 개인적인 상속? 나까지만 잘쓰고 -> 자손들에게 아예 안물려 줄꺼야! (public -> private / protected -> private)

아래의 코드를 한번 봐보자. 일단 SuperCar 라는 클래스는 Car 로 부터 private 으로 상속받았기 때문에 만약 SuperCar 라는 상속받은 아이는 Car 에 대한것을 접근할수 있다. 즉 SuperCar는 자기까지만 욕심많게 상속을 받고 물려주지 않은것으로 보여진다. 이 코드에서 만약 상속 접근 지정자를 안했을 경우 private 으로 인식하게 된다.

class SuperCar : private Car // 상속 접근 지정자
{
public:
    void PushRemoteController(){ RunEngine(); }
};

class TestSuperCar : SuperCar
{
    void Test()
    {
        DisassembleCar(); // Cannot access
    }
};

class SuperSuperCar : private Car
{
public:
    void PushRemoteController(){ RunEngine(); }
};


class TestSuperSuperCar : public SuperSuperCar
{
public:
    void Test() { /* .. Can't call DisassembleCar(); */}
};

이유중에 2번째: 다른 경로로 접근 이라는게 있다. 이거에 대한 예를 들어보자. 아래의 코드를 보면, main 함수에서 버서커를 instantiate 한다음에, hp를 바꾸는데, 버서커모드의 출력창이 안나온다. 그 이유는 일단 _hp 를 접근해서 바꾸는건 위험하고 또 다른건 클래스는 그냥 틀에 불과 하기 때문에 설계를 잘못했다고 말을 할수 있다. 그래서 이 부분에서 encapsulation 에 대한 이야기를 할까 싶다. 캡슐화는 한마디로 연관된 데이터와 함수를 논리적으로 묶어 놓은것이라고 볼수 있다.

class Berserker
{
public:
    void SetBerserkerMode(){ cout << "Getting Stronger" << endl; }
public:
    int _hp = 100;
};

int main()
{
    Berserker berserker;
    berserker._hp = 10;
}

Encapsulate 된 클래스 구조를 코드로 확인 해보자. 일단 _hp 를 쉽게 건들수 없게 private 으로 막아놓으면, 일단 외부에서는 접근을 못하게 막아 놓았다. 주로 member variable 을 가지고 나올때는 getter 와 setter 을 쓰기 때문에, GetHp()SetHp() 를 만들어준다. 그래서 일단 우리가 만들고 싶은거는 뭔가 hp 가 세팅이 됬을때, 버서커모드로 될지 안될지를 체크를 해주면 된다. 또 외부에서 버서커 모드를 키게 하면 안되니까 private 으로 막아줬다.

class Berserker
{
public:
    int GetHp() { return _hp; }
    void SetHp(int hp)
    {
        _hp = hp;
        if (_hp <= 50) SetBerserkerMode();
    }

private:
    int _hp = 100;
    void SetBerserkerMode(){ cout << "Getting Stronger" << endl; }
};

int main()
{
    Berserker berserker;
    berserker.SetHp(20);
    return 0;
}

Polymorphism

Polymorphism 이라는건 결국 다양한 형태로 존재 한다 라고 생각하면 된다. 즉 쉽게 말해서 겉은 똑같은데, 기능이 다르게 동작한다고 말할수 있다. 두가지로 대표적으로 2 가지를 말을 할수 있다.

  1. 오버로딩(Overloading) = 함수 중복 정의 = 함수 이름의 재사용
  2. 오버라이딩(Overriding) = 재정의 = 부모 class method 를 사용해서 자식클래스에서 재정의

잠깐 오버로딩에 대해서 이야기를 해보자. 바로 코드를 보겠다. 아래는 Move() 라는 함수를 이용해서 같은 이름이지만 signature 이 다른 함수인 Move(int) 로 함수를 중복 정의 한걸 볼수 있다. 이게 바로 대표적인 오버로딩에 대한 예이다. 이와같이 오버로딩은 되게 간단하다고 볼수있다.

class Player
{
public:
    Player() {_hp = 100;}
    void Move(){cout << "Move()" << endl;}
    void Move(int step){cout << "Move(int)" << endl;}
};

int main()
{
    Player player;
    player.Move();
    player.Move(20);
    return 0;
}

오버라이딩 같은 경우가 굉장히 polymorphism 에서 중요한 부분인데 한번 알아보자. 아래와 같이 오버라이딩에 대한 간단한 예라고 볼수 있다 부모 클래스인 Player() 에서 상속받은 KnightMage 같은 경우 Move() 함수를 재정의 해서 사용한걸 볼수있다.


class Player()
{
public:
    void Move(){ cout << "Move() " << endl; }
public:
    int _hp;
};

class Knight : public Player
{
public:
    Knight() {_stamina = 100; }
    void Move(){ cout << "Knight Move()" << endl; }

public:
    int _stamina;
};

class Mage : public Player
{
public;
    void Move() {cout << "Mage Move()" << endl; }
public:
    int _mp;
};

int main()
{
    Knight k;
    k.Move();

    Mage m;
    m.Move();
    return 0;
}

상속받아서 하는 클래스를 설계하는건 아주 중요한 스킬중에 하나인데, 그중에 또하나의 장점이 있다. 아래의 코드를 봐보자. Move 라는 기능의 함수를 클래스 별로 만들었다. 여기에서 만약에 MoveKnight() 안에 player 의 주소값을 넣어주면 어떻게 될까? 호환이 되지 않는다. 일단 기존에 PlayerMove() 안에 넣었던걸 번역?을 하자면, 플레이어는 플레이어다라고 말을 할수 있는데, 플레이어가 기사냐라고 물어봤을때, 지금 계층 구조에서는 의미가 맞지 않는다. 즉 플레이어는 Mage 일수도 있고, Knight 도 될수도 있다. 그러면 그 반대의 케이스로 MovePlayer() 안에 Knight 의 주소값을 넣어줬다고 하면 어떨까? 앞의 해석에 의해서 빌드가 된다. 그렇다면 해석을 구지 하자면, Knight 는 Player 가 맞다. 바로 이 점을 사용해서, MoveKnight() 나 MoveMage() 함수를 따로 안만들어주고, MovePlayer() 로 관리 할수 있게된다. 상속관계를 잘알게 된다면, 확실히 코드가 간결해진다.

그렇다면, 여기서 더나아가서 MovePlayer() function 만 사용한다고 했을때, 과연 어떤 클래스에서 Move() method 를 사용할까? 라는 의문점이든다. 실행을 해보면, 부모안에 있는 Move() 가 실행되는걸 확인 할수 있다. 그러면 이게 문제가 된다. overriding 을 사용해서 Knight Move() 를 만들었는데.. 라고 물을수 있다. 그 이유는 바로 Binding(바인딩) 이라는 개념과 연관된다.

Binding(바인딩) 은 결국 어떤걸 묶는다라는 걸 볼수 있는데, 정적 바인딩과 동적 바인딩이 있다. 아래의 정의를 잠깐 봐보자

  • Static Binding(정적 바인딩) : compiler 시점에 결정
  • Dynamic Binding(동적 바인딩) : run time 에 결정

주로 일반함수는 정적바인딩에 속한다. 즉 MovePlayer() 가 compiler 에서 봤을때는 Player 의 주소값을 받는 타입이 있으니까, 원본데이터가 Knight 였을지여도, Player 의 Move() 함수가 실행되는거다. 그렇다면 이걸 어떻게 해결할까? 는 예상대로 동적바인딩을 사용하면 된다. 그렇다면 동적 바인딩을 사용하려면 조건이 필요하다. virtual(가상) 함수가 필요하다.

class Player()
{
public:
    void Move(){ cout << "Move() " << endl; }
public:
    int _hp;
};

class Knight : public Player
{
public:
    Knight() {_stamina = 100; }
    void Move(){ cout << "Knight Move()" << endl; }

public:
    int _stamina;
};

class Mage : public Player
{
public;
    void Move() {cout << "Mage Move()" << endl; }
public:
    int _mp;
};

void MovePlayer(Player* player)
{
    player->Move();
}

void MoveKnight(Knight* knight)
{
    knight->Move();
}

void MoveMage(Mage* mage)
{
    mage->Move();
}

int main()
{
    Player p;
    p.Move(&p);
    Kngiht k;
    k.Move(&k);
    return 0;
}

가상함수를 사용하려면 어떻게 사용해야할까는 method 앞에 keyword 를 사용하면 된다. 그렇다면 위의 코드를 조금 정리해서 봐보자. 일단 동적바인딩을 사용해서, VMove() 그리고 VAttack() 을 만들었다. 상속관계에서 virtual function 을 사용하면, virtual 함수인거다.

class Player
{
public:
    Player() {_hp =100 ;}
    virtual void VMove() { cout << "VMove" << endl;}
    virtual void VAttack();
public:
    int _hp;
};

class Knight : public Player
{
public:
    Knight() {_stamina = 100; }
    virtual void VMove() { cout << "VMove Knight" << endl;}
    virtual void VAttack(){cout << "VAttack Knight" << endl;}
public:
    int _stamina;
};

void MovePlayer(Player* player)
{
    player->VMove();
}

int main()
{
    Knight k;
    MovePlayer(&k);
    return 0;
}

앞에서 본건 구현방법이였다. 하지만 더 자세하게 보려면 어셈블리를 까봐서 어떻게 구현되어있는지 정확하게 필요가 있다. 일단 break point 를 걸어서 실행을 해보면 Knight 앞 메모리에 뭔가가 추가 되었다는게 보인다. 즉 이 추가된게 가상함수의 어떤 플래그라는걸 알수있다. 즉 실제 객체가 어떤 타입읹지 어떻게 알고 있어서 가상함수를 호출하는지를 찾아보면, 바로 가상함수 테이블(vftable) 이라는게 존재해서 그렇다. 그러면 가상함수 테이블에서 잠깐 보면 가상함수 테이블(.vftable) 는 32 bit 에서는 4 바이트를 차지하고, 64 bit 에서는 8 바이트를 차지한다. 메모리 구조에서는 [VMove][] 즉 테이블 주소로 되어있다는 거다. 즉 가상함수 테이블을 통해서 가상함수들을 관리 한다는걸 확인할수 있다. 그렇다면 가상함수를 쓰는 주체는 누구인가? 라는 질문도 할수 있다. 정답은 생성자에서 한다. 생성자의 선처리 영역에서 vftable 을 채워넣는다.

signature 만 가지고 있는 가상함수를 순수 가상함수라고 하는데, 이 가상함수는 구현은 없고 interface 만 전달하는 용도로 사용하고 싶을때 사용된다 순수 가상함수를 만들었을 경우, 빌드를 시켰을때, 그 method 가 있는 클래스 abstract class 가 된다. 여기서 abstract class 가 뭐냐고 묻는다면, 순수 가상함수가 1 개이상 존재하거나 포함되면 바로 추상클래스로 간주되고, 직접적으로 객체를 instantiate 하지 못하게 된다.

순수 가상함수일경우에는 그 가상함수를 표현할때 virtual void Attack() = 0 이런식으로 표현한다. 모던 c++ 에서는 virtual void Attack() abstract 라고 표현된다. 이렇게 순수가상함수가 표현되면, 거의 무조건 상속받는 친구들은 무조건 재정의가 필요해 이렇게 말을하는거다.

Initializing the List

초기화를 하는 이유는 여러가지가 있다. 일단 초기화를 통해서 디버깅도 쉬어지고, 또한 초기화를 함에 따라서 어떤 값이 들어갔는지 확인이 가능하다. 즉 버그를 예방을 할수 있고, 포인트나 주소값이 연루 되어있다고 한다면 더더욱 중요시 생각해야한다. 아래의 코드를 한번 봐보자. 일단 k._hp 를 출력 한다고 가정하면, 엉뚱한 메모리값을 가지고 있다는걸 확인 할수 있다. 이렇게 초기화를 안한 상태에서, if statement 로 넘어간다면, 이제 Knight 가 죽었다는 사실을 들고 있다. 이런 실수를 방지 하고자 Initializing 을 할 필요가 있다.

#include <iostream>
using namespace std;
class Knight
{
public:
    int _hp;
};

int main()
{
    Knight k;
    cout << k._hp << endl;
    if (k._hp < 0 )
    {
        cout << "Knight is Dead" << endl;
    }
    return 0;
}

초기화 방법은 여러가지가 있지만, Object Oriented Programming 관점에서의 초기화는 일단 생성자 안에서 초기화를 하는 방법이 있고, 그리고 초기화 리스트가 있으며, c++11 에서 추가된 문법이 있다. 이거에 대해서 더 상세 하게 이야기를 할려고 한다. 일단 아래의 코드를 보자. Knight 클래스는 Player 클래스로 부터 상속을 받았고, 생성자에서 부모 클래스의 초기화를 했고, 또한 Knight 클래스의 member 변수인 _hp 를 100 으로 초기화 한걸 볼수있다. 또한 Knight 생성자 내에서 멤버 변수인 _hp_hp = 100 이런식으로 초기화가 가능하다.

C++11 에서는 바로 class 내부에서 int _hp = 100 으로 설정이 가능하다.

class Player
{
public:
    Player(){}
    Player(int id){}
};

class Knight : public Player
{
public:
    Knight() : Player(1), _hp(100)
    // 선처리 영역 // 
    {

    }

public:
    int _hp;
};

일단 초기화 리스트 같은경우, 상속 관계에서 원하는 부모를 생성자 호출할때 필요하다. 더나아가서 생성자내에서 초기화를 하는게 있고, 초기화 리스트의 비교를 따로 해보자. 일단 일반 변수 같은경우는 별 차이가 없으며, 만약 멤버 타입이 클래스인 경우 차이가 난다.

일단 이걸 더 판별하기 위해서, Is-A(Knight Is-A Player?)Has-A(Knight Has-A Inventory) 스스로에게 질문을 해보면 된다. 아래의 코드를 한번 봐보면 Is-A 같은 경우는 기사는 플레이어다 라고 생각해서 맞다고 하면, 상속관계 이다. 또 다른 예를 찾아보면 Is-A 를 사용해서 기사는 인벤토리냐 라고 묻는다면, 상속관계가 아닌 포함관계라는걸 볼수 있다. 큰그림을 그려보자면, 이렇게 생각하는 이유는 처음에 코드를 설계할때의 유용하기 때문이다.

또 여기서 문제가 될게 있다. 만약 생성자 내부안에서 Inventory 를 만들어서 초기화를 할 경우, 각 다른 생성자를 한번씩 한번씩 호출이되고, 소멸자는 두번이 호출된거를 볼수 있는데, 멘붕이 올수 있다. 이부분은 선처리 영역에서 Inventory 를 만들었는데 생성자에 들어와서, 기존에 있던 기본 생성자를 날려버리고 Inventory(int) 의 생성자로 덮어씌우는 동시에 소멸자가 호출되면서, 코드가 끝나게 되면, 다시 소멸자가 호출이된다. 이럴 경우에는 애초에 선처리영역에서 아래와 같이 초기화를 하는걸 볼수 있다.

class Inventory
{
public:
    Inventory(){ cout << "inventory()" << endl; }
    Inventory(int size){ _size = size; }
    ~Inventory(){ cout << "~Inventory()" << endl; }
public:
    int _size = 10;
};

class Player
{
public:
    Player(){}
    Player(int id){}
};

class Knight : public Player
{
public:
    Knight() : Player(1), _hp(100), _inventory(20)
    // 선처리 영역 // 
    // inventory() // 
    {

    }

public:
    int _hp;
    Inventory _inventory;
};

또 정의함과 동시에 초기화가 필요한 경우가 있다. 주로 참조 타입이나 const 타입일 경우가 있다. 아래의 코드를 봐보자. 일단 테스트를 위해서, _hpRef,_hpConst 가 있다해보자. Knight 클래스 내부에서 생성자에서 저 두 멤버변수를 바꾼다고 해도 의미가 없어진다. Const 같은 경우는 바꿀수 없는거고, Reference 는 누군가 하나를 가르키고 있어야 하는건데, 이미 Knight 클래스가 생성이 될시 즉 선처리영역에서 이미 했기때문에 다른값으로 수정이 불가능하다. 그래서 하고 싶을 경우에는 마찬가지로 선처리 영역에서 하면된다.

class Inventory
{
public:
    Inventory(){ cout << "inventory()" << endl; }
    Inventory(int size){ _size = size; }
    ~Inventory(){ cout << "~Inventory()" << endl; }
public:
    int _size = 10;
};

class Player
{
public:
    Player(){}
    Player(int id){}
};

class Knight : public Player
{
public:
    Knight() : Player(1), _hp(100), _inventory(20), _hpRef(_hp), _hpConst(100)
    // 선처리 영역 // 
    // inventory() // 
    {
        _hpRef = _hp; // 안됨
        _hpConst = 100; // 안됨
    }

public:
    int _hp;
    Inventory _inventory;
    int& _hpRef;
    const _hpConst;
};

Operation Overloading

Operation Overloading 이란 큰틀로 보면 객체와 객체끼리의 연산을 할수 있게 만들어주는 역활을 말한다. 아래의 코드로 예를 들어보자. 우리는 두 가지의 객체를 만들어서 더했을때, pos2 로 저장하려고 했을때 각각의 객체들의 멤버 variable 이 더해졌으면 좋겠다는 바램에 실행을 해보면, 빌드에 실패하게 되어있다. 이럴때 필요한게 Operation Overloading 이라고 한다.

class Position
{
public:
    int _x;
    int _y;
};

int main()
{
    Position pos;
    pos._x = 3;
    pos._y = 4;

    Position pos1;
    pos1._x = 7;
    pos1._y = 3;

    Position pos2 = pos + pos1;
    return 0;
}

그렇다면 어떻게 만들까? 그리고 어떤 문법이 있을까를 알아보자. 어떻게 보면 일단 오버로딩 같은경우 한개의 같은 Naming 에 각 각 다른 signature 을 들고 있었다. 연산자도 똑깥이 사용하면 돼고, 그리고 그렇게 생각하다보면 그냥 보통 함수와 연산자 와는 무슨 차이가 있을까? 일단 연산자 같은경우는 피연산자의 개수/타입이 고정되어있다.

그렇다면 바로 넘어가서 연산자 오버로딩에 대해서 더 설명 하려고 한다. 일단 연산자 함수를 정의를 해야하는데, 그 함수를 정의하는것도 멤버함수와 전역함수로 일반함수와 비슷하게 할수 있다. 일반 함수의 signature 을 생각을 해보자. 처음에는 Return type 이 존재 하고 그 다음 함수 이름에 argument 가 따라왔었다. 연산자 오버로딩도 똑같다. RET operator+(ARG_LIST) 이런식으로 하면 된다. 그래서 위의 코드를 서포트 하긴 아래의 코드를 한번봐보자. 아래와 같이 reference 의 값을 받아서 Position 을 + 연산을 한 이후에 Position 을 return 하는거로 보인다. 즉 a op b 이런 형태일때, 왼쪽으로 기준으로 실행이 된걸 확인 할수 있다. 즉 a 를 피연산자라고 생각 하면된다. 근데 만약 a 가 클래스가 아니면, 연산이 안된다. 즉 1 + pos 가 된다면 안된다. 이걸 해결 할수 있는 방법은 전역 연산자 함수로 만들면 된다. 즉 a op b 라고 한다면, a, b 모두를 연산자 함수의 피연산자로 만들면 된다는 뜻이다.

class Position
{
public:
    Position operator+(const Position& arg)
    {
        Position pos;
        pos._x = _x + arg._x;
        pos._y = _y + arg._y;
        return pos;
    }

public:
    int _x;
    int _y;
}

Position operator+(int a, const Position& b)
{
    Position ret;
    ret._x = b._x + a;
    ret._y = b._y + a;
}

int main()
{
    Position pos;
    pos._x = 3;
    pos._y = 4;

    Position pos1;
    pos1._x = 7;
    pos1._y = 3;

    Position pos2 = pos + pos1;
    return 0;
}

그렇다면 어떤 연산자가 있을까? 그리고 전역 연산자 함수와 멤버 연산자의 함수 둘중에 어떤게 좋은걸까? 분명 전역으로 만들면 좋다는걸 말을 했었다. 하지만 둘다 사실 알아야 할 필요가 있다. 주로 대입 연산자 같은 경우(a=b)는 전역 연산자 함수로 만들어주는게 맞다. 조금은 Tricky 하지만 대입연산자같은 경우는 자기 자신의 참조값을 return 하는 경우가 많기 때문에 Position& operator=(int arg) 이런식으로 된다. 그래서 자기 자신의 포인터를 표현하기 위해서 *this return 하면 된다.

class Position
{
public:
    Position operator+(const Position& arg)
    {
        Position pos;
        pos._x = _x + arg._x;
        pos._y = _y + arg._y;
        return pos;
    }
    Position operator+(int arg)
    {
        Position pos;
        pos._x = _x + arg;
        pos._y = _y + arg;
    }

    bool operator==(const Position &arg)
    {
        return _x == arg._x && _y == arg._y;
    }

    Position& operator=(int arg)
    {
        _x = arg;
        _y = arg;
        return *this;
    }

    Position& operator=(const Position& arg)
    {
        _x = arg._x;
        _y = arg._y;
        return *this;
    }

    Position& operator++()
    {
        _x++;
        _y++;
        return *this;
    }

    Position operator++(int)
    {
        Position ret = *this;
        _x++;
        _y++;
        return ret;
    }

public:
    int _x;
    int _y;
};

Position operator+(int a, Position &b)
{
    Position ret;
    ret._x = b._x + a;
    ret._y = b._y + a;
    return ret;
}

위의 코드를 보면 복사 대입연산자의 signature 들이 보인다. 일단 말부터 어렵다 복사하긴 하는데 대입? 이라고 하는데, 이걸 잘풀어 보면 대입 연산자는 연산자인데, 자기 자신의 참조 타입을 인자로 받는것을 복사 대입 연산자라고 한다. 위의 코드를 봤을때, Position& operator=(int arg) 이 member function 인데, arguments 를 Position& arg 로 받는것이다. 그래서 표현을 하면 Position& operator=(const Position& arg) 이런 signature 로 된다는 것이다. 앞에서 봤듯이 복사 생성자와 복사 대입연산자 같은 경우는 Deep Copy 와 Shallow Copy 에서 다룰거지만 Memory 를 그대로 복사한다는거에대해서 되게 큰 의미가 있는 작업이다.

위에서 봤듯이 모든 연산자를 오버로딩을 할수 있는건 아니다. (::, . .*) 등 안된다. 그리고 단항 연산자도 가능하다. 그렇다면 증감 연산자도 되기 때문에 위의 코드를 참고 하면 된다. 전위형 같은경우는 operator++() 이런 형태와, 후위형은 operator++(int) 이런식으로 되어있다는걸 확인할수 있다.

Etc

조금 디테일한거에 대해서 포커스를 해보자 한다. 일단 C 에서 사용하는 struct 와 class 에 대한 차이를 알아보자 하는데, 결론은 종이 한 장 차이이다. 주로 struct 를 사용할때는 Data 의 묶음? 이라고 했던 점이 있었는데, 클래스에도 똑같이 사용할 수 있다. 그리고 Operator 나 function 을 넣을수 있다. 근데 다른점 이라고 한다면, 접근을 할 수 있느냐 없느냐의 차이이다. 아래의 코드를 실행시켜보면 알 수 있다. 즉 클래스에서 접근 지정자를 선언하지 않으면, private 으로 default 로 생성이 되기 때문에 미묘한 차이로 접근 할 수 없게 된다. 그리고 Struct 같은 경우는 기본 접근 지정자가 public 이다.

이렇게 설계한 느낌은 결국은 C 의 호환성이라고 한다.

struct TestStruct
{
    int _a;
    int _b;
};
class TestClass
{
    int _a;
    int _b;
};
int main()
{
    TestStruct ts;
    ts._a = 1;
    TestStruct tc;
    tc._a = 3;
    return 0;
}

결국은 Struct 는 그냥 구조체 (데이터 묶음)을 표현하는 용도로 사용하면 되고, class 같은 경우는 객체 지향 프로그래밍을 나타내는 정도의 용도로 사용하면 될 것 같다.

그 다음에 static 변수와 static 함수에 대해서 설명하고자 한다. static 은 정적이라는 의미를 가지고 있다. 아래의 코드 상태에서 스타크래프트의 아카데미 역활을 함수로 표현 하고자 할때 생각을 해보면, 모든 마린은 attack 이라는 변수를 가지고 있는데, 설계할때 클래스의 멤버 변수로 가지고 있기 때문에, 모든 마린의 공격력을 증가 시킬때, Instantiate 한 모든 마린의 공격력을 무식하게 다 바꿔줘야 해야 해서 불편할 수 있다. 그래서 static 을 사용해서 하면 해결 할 수 있다.

즉 이렇게 하는 이유는 Marine class 의 설계도에 종속적으로 가져갈 수 있기 때문이다. (그 의미는 Marine 을 instatiate 할 때 마다 각 각 다른 객체이지만, 공통적으로 가지고 있는 것을 만들고 싶을 때 이렇게 사용된다.)

class Marine
{
  public:
    int _hp;
    static int _attack;
};
int Marine::s_attack = 0;
int main()
{
    Marine m1;
    m1._hp = 40;
    m1._attack = 6;
    
    Marine m2;
    m2._hp = 40;
    m2._attack = 6;
    
    // Academy
    Marine::s_attack = 7; // 모든 마린의 공격력을 7 로 바꾼다.
}

일반 static 이 변수에 쓰는 경우를 알아봤다. 이렇다면 함수에 쓰이는 경우는 어떤 경우가 있을까? 생각이 든다. 아래의 코드를 한번 봐보자. 아까의 위의 클래스를 생각을 해보자. 아래와 같이 보면, 모든 Marine 에 대해서 SetAttack() 함수가 정의 되어있는걸 보인다. static 변수는 어떤 메모리에 올라갈까? 일단 초기화를 한다면 .data 에 올라갈거고, 안하면 .bss 영역에 올라갈것이다.

class Marine
{
public:
    void TakeDamage(int damage)
    {
        _hp -= damage;
    }

    static void SetAttack()
    {
        _attack = 100; 
    }
public:
    int _hp;
    static int _attack;
};
int Marine::s_attack = 0;
int main()
{
    Marine m1;
    m1._hp = 40;
    m1._attack = 6;
    
    Marine m2;
    m2._hp = 40;
    m2._attack = 6;
    
    // Academy
    Marine::_attack = 7; // 모든 마린의 공격력을 7 로 바꾼다.
    Marine::SetAttack();
}

static 의 생명주기는 프로그램의 시작과 종료, 즉 메모리에 항상 올라가 있다. 즉 id 같은 경우는 계속 저기 어딘가에 메모리주소에 있기때문에 내부에서 선언이 되었더라도, 함수 스택메모리에 벗어나더라도, id 는 계속적으로 바뀌어있는게 계속 있을거다.

class Player
{
public:
    int _id;
};

int GenerateId()
{
    // 1, 2, 3, 4
    // 정적 지역 객체
    static int id = 1;
    return id++;
}

Resource

Source Code

Dynamic Allocation

동적 할당은 진짜 C++ 에서 정말 중요도가 너무 높다. 동적 할당을 알려면 메모리의 구조도 알아야 할 필요가 있다. 메모리 구조에대해서 잠깐 복습을 해보자.

전체적인 그림에서 주로 어떤 OS 든지, 유저영역과 커널 영역(ex: Windows 핵심 코드) 나누어져있다. 유저영역에서는 어플리케이션들이 유저영역에서 실행된다. 만약에 유저 애플리케이션이 서로가 서로의 메모리를 공유해서 쓰거나, 서로의 영역을 침범한다면 정말 망할것이다. 유저 애플리케이션은 서로 서로 독립적으로 메모리를 커널영역에 요청을 한다. 즉 유저영역에서, 운영체제에서 제공하는 API 를 호출한다. 그런 다음 커널영역에서 메모리를 넘겨준다.

일단 샐행할 코드가 저장되는 영역은 코드 영역 이라고 한다. 전역(global) / 정적 (static) 변수는 데이터 영역에 저장이되고 마지막으로 지역 변수 / 매개 변수는 스택영역에 사용이된다. 하지만 위에 이미지를 보면, 다른 영역들도 보인다. 그러면 왜 구지 다른 영역들을 사용할까? 라는 질문이 떠오르긴 한다.

만약에 실제 상황에서 MMORPG 에서 동시 접속이 1 만명 더많게는 10 만명이 있고, 몬스터도 500만 마리가 될수 있다. 아래의 코드를 실행해보면, stack overflow 라고 하면서 에러를 내뱉는걸 볼수 있다. 근데 이게 항상 최대 상한선에서의 이야기이였던거 였다. 실제 게임에서는 한번에 만드는게 아니라, 가끔씩 시간에 지남에 따라서 생성되고 죽음을 맞이 해야할것이다. 그렇다면 메모리 구조에서 조금만 더 생각해보자.

스택 영역에서는 함수가 끝나면 같이 정리되서, 조금 불안정한 메모리라고 볼수 있ㄷ다. 그래서 잠시 함수에 매개변수를 넘긴다거나, 하는 용도로는 좋다. 그리고 그에 따른 메모리영역은 무조건 사용이된다고 볼수 있다. 그렇다면 우리가 위에서 하고 있었던 목적으로 메모리를 필요할때만 사용하고, 필요없을때 반납할수 있는 그런 메모리와 스택과는 다르게 우리가 생성 / 소멸 시점을 관리할 수 있는 그런 아름다운 메모리를 바로 힙영역이다.

그렇다면, C++ 에서 이 힙영역을 건들려면 어떤 Keyword 를 살펴보아야 하나면 malloc, calloc, realoc, free, new, delete, new[], delete[] 이 있다. 그렇다면 아까 잠깐 언급한 유저영역과 커널영역에서의 관점을 보자면 C++ 에서는 어떻게 힙영역을 사용할까? C++ 에서는 기본적으로 CRT(C RunTime Library)의 힙관리자를 통해서 힙영역을 사용한다. 단, 정말 원한다면 우리가 직접 API 를 통해 힙을 생성하고 관리할 수도 있다. 예를 들어서 MMORPG 서버 메모리 풀링을 사용할수 있다.

class Monster
{
public:
    int _hp;
    int _x;
    int _y;
    int _z;
};

int main()
{
    Monster monster[500 * 10000000];
    return 0;
}

일단 malloc 의 signature 을 봐보면 size_t 를 볼수 있는데 F11 을 타서 봐보면 typedef 로 unsigned int 를 size_t 로 되어있는걸 볼수 있다. 그리고 return 값을 보면 pointer 로 보인다. 그래서 void* pointer = malloc(1000) 볼수 있다. 이때 1000 byte 만큼을 메모리 관리자로부터 요청해서, 메모리를 할당 후 시작 주소를 가르키는 포인터를 반환해준다. 잠깐 확인 해야 할 필요가 있는게, void* pointer 라는거라는게 뭘까? 라는걸 확인해야 할 필요가 있다. 앞서 포인터를 타고 가면 아무것도 없다라는게 아니라, 타고 가면 패ㅑㅇ 뭐가 있는지 모르겠으니까 너가 적당히 반환해서 사용해라라는 느낌으로 void 라는게 붙어져있다. 그래서 위의 Monster 를 사용하자면, Monster* m1 = (Monster*)pointer; 이렇게 변환해서 사용할 수 있다. 그래서 m1 에다가 _hp 나 세팅이 가능하게 되는것이다. 근데 1000 byte 는 너무 과하고 딱 Monster 객체만큼 받고 싶다면 sizeof(Monster) 사용하면 될것이다.

그렇다고 한다면 우리가 빌려쓰고 이제 반환하는 과정을 보려고 한다. 일단 결론적으로 malloc 이라는 키워드를 사용한다면 free 라는 keyword 같이 따라오는데, 이게 반환하는 과정이라고 생각하면 된다. freemalloc, calloc, realoc 메모리로 요청한것들을 반환하거나 풀어주는거라고 생각하면된다. 조금 궁금한점이 있다면, 메모리를 요청 할떄는 얼마만큼 요청했는지를 알수 있는데, 왜 풀어줄때는 메모리를 얼마나 풀어주는지를 따로 요청 하지 않는다. 이는 메모리를 요청할때 Header 라는게 있는데 이게 얼마만큼 요청했는지를 Tracking 할수 있어서 free 를 할때 이걸 보고 메모리를 풀어주는것이다.

class Monster
{
public:
    int _hp;
    int _x;
    int _y;
    int _z;
};

int main()
{
    void* pointer = mallocs(sizeof(Monster));
    Monster* m1 = (Monster*)pointer;
    m1._hp = 100;
    m1._x = 0;
    m1._y = 0;
    m1._z = 0;

    free(pointer);
    return 0;
}

메모리를 건들다 보면, 이제 stackoverflow 처럼, Heap 영역에서 요구한만큼을 더 사용했을 경우에 heapoverflow 가 생긴다. 즉 유효환 힙 범위를 초과했을때 corruption 이 생긴다. 만약에 free() 를 사용하지 않고, malloc 을하면 live(RunTime) 일때 Memory Lick(메모리 누수)가 날수 있다. 계속 요청만하고 풀어주지 않아서 문제가 생기는것이다. 즉 반납을해야 지속적으로 사용하거나 재사용을 할수 있다. 그렇다면 free 를 두번 하면 될까? 라는 질문이 생길수 있다. 첫번쨰 free 를 했을때, 그 Header 에 얼마나 할당받았는지를 알수 있는데 free 를 할때 그정보도 날려주기 때문에 두번째에 free 를 했을때 엉뚱한값을 free 하려고 해서 문제가 생긴다.

그렇다면 어떤게 정말 큰 잘못일까? 라고 생각을 하면, 바로 use-after-free 라는 것이다. 아래의 코드를 보면 이 케이스를 볼수 있다. free 를 이미 했으면 그 메모리를 건들면 안되는데, m1 이라는 메모리를 건드는걸 볼 수 있다. pointer 는 살아있기때문에, 건드릴수 있는데 crash 가 날때가 있고 없을때도 있다. 근데 없을때가 정말 큰일이나는 거다. 즉 엉뚱한 메모리를 건들게되면 위험하기때문에, 동적할당을 사용할때는 사용자가 잘사용해야된다는것이다. 그래서 일단 방지 할수 있는거는 pointer=null 을 할수 있다.

class Monster
{
public:
    int _hp;
    int _x;
    int _y;
    int _z;
};

int main()
{
    void* pointer = mallocs(sizeof(Monster));
    Monster* m1 = (Monster*)pointer;
    m1._hp = 100;
    m1._x = 0;
    m1._y = 0;
    m1._z = 0;

    free(pointer);
    // m1._hp = 100;
    // m1._x = 0;
    // m1._y = 0;
    // m1._z = 0;
    pointer = null;     
    return 0;
}

사실 malloc, calloc, realoc 은 C 에서 사용되는 부분이였다. 그래서, C++ 에서 자주 사용되는 newdelete 를 알아보자. 일단 넘어가기전에 malloc, calloc, realoc 이들은 함수였다. 하지만 C++ 에서 동적할당을하는 newdelete 연산자(operator)이다.

아래의 코드를 봐보자. new 를 했을때 타입을 넣어준다. 이때 타입의 크기만큼을 동적할당을 해준다. 앞에서 mallocfree 가 같이 있듯이, newdelete 같이 묶여 다니는걸 볼수 있다. 버그의 케이스도 위의 malloc 과 같다. 그러면 우리가 Monster 를 여러마리를 만들고 싶을때는 아래의 코드를 보면 new Monster[5] 를 사용해서 5 마리의 몬스터를 만든것을 볼수 있다.

class Monster
{
public:
    int _hp;
    int _x;
    int _y;
    int _z;
};

int main()
{
    Monster* m1 = new Monster;
    m1._hp = 100;
    m1._x = 0;
    m1._y = 0;
    m1._z = 0;
    delete m1;

    Monster* m2 = new Monster[5];
    delete[] m2;
    return 0;
}

그렇다면 malloc / free 그리고 new / delete 의 Use-Case 같은경우는 사용 편의성에서는 확실이 new / delete 이게 좋지만, 타입에 상관없이 특정한 크기의 메모리 영역을 할당받으려면 malloc / free 이 좋다. 정말 가장 중요한 근본적인 차이는 또 new / delete 는 생성타입이 클래스일 경우, 생성자 / 소멸자를 호출해준다. 즉 위의 코드에서 Monster 를 여러개 만들때 생성자와 소멸자가 5번 호출을 해주고, 만약에 delete 를 한번 했었을때, 에러를 뱉어내는걸 확인할수 있다.

class Monster
{
public:
    Monster(){ cout << "Monster()" << endl;}
    ~Monster(){ cout << "~Monster()" << endl;}
    int _hp;
    int _x;
    int _y;
    int _z;
};

int main()
{
    Monster m1 = new Monster;
    delete m1;
    return 0;
}

마지막 예를 보자. 아래의 코드를 봐보면, 실제 stack 에서 Item 을 instantiate 했을때는, 생성자가 호출이 되는걸 확인 할수 있는데 pointer 로 Item 이라는걸 생성할때는 생성자가 호출이 될수도 있고 안될수도 있다. 아래의 같이 pointer 로 배열을 만들경우 타입만 선언을 했기때문에 그리고 초기에는 아무것도 없기 때문에 생성자가 없을 수 도 있다. 그래서 아래와 같이 객체생성을 하기위해서 loop 을 돌면서 생성을 하고 그 다음에 소멸자도 마찬가지로 소멸을 시키면 소멸자가 호출이된다.

class Item
{
public:
    Item(){cout << "Item()" << endl;}
    Item(const Item& item){cout << "Item(const: item&" << endl;}
    ~Item(){ cout << "~Item()" << endl;}
public:
    int _itemType = 0;
    int _itemObid = 0;

    char _dummy[4096] = {};
};

void TestItem(Item item){ /* 복사생성자 호출 */ }

void TestItemPtr(Item* item){ /*원본을 건드리기때문에 원격*/}

int main()
{
    Item item;
    Item* item2 = new Item();

    TestItem(item);
    TestItem(*item2);

    TestItemPtr(&item);
    TestItemPtr(item2);

    Item item3[100] = {};

    // 실질적으로 아무것도 없을수 있음
    Item* item4[100] = {};

    for(int i=0; i<100; i++)
        Item4[i] = new Item();

    for(int i=0; i<100; i++)
        delete Item4[i];
    delete item2;
    return 0;
}

Type Conversion

위에서 보았듯이 malloc / free 를 사용했을때 void 값으로 설정을 해준 이후 타입을 변환한걸 확인 할수 있었다.

일단 타입 변환에도 유형(비트열 재구성 여부)이 있다.

  1. 값 타입 변환
    1. 의미를 유지하기 위해서, 원본 객체의 다른 비트열 재구성.
  2. 참조 타입 변환
    1. 비트열을 재구성하지 않고 관점만 바꾸는 것
int main()
{
    {
        // 값 타입 변환
        int a = 123456789;
        float b = (float)a;
    }
    {
        // 참조 타입 변환
        int a = 123456789;
        float b = (float&)a;
    }
    return 0;
}

타입 변환에서는 변환이 안전하게 변하게 되는 경향과 불안전하게 변환되는게 있다. 예를 들어서 Upcasting 즉 작은 메모리를 타입을 가지고 있는걸, 큰거에다가 변환시켰을때에 안전하게 변환되는걸 확인할수 있다. 그렇다면 불완전하게 되는 경향은 이거에 반대되는 상황일다. 이럴 경우 데이터의 손실을 불러 일으킨다.

또 프로그래머의 암시적변환과 명시적 변환이존재한다. 암시적 변환 같은 경우는 컴파일러에게 알아서 변환 인거고, 명시적인거는 프로그래머가 따로 괜찮으니까 타입캐스팅을 해줘라는 느낌이다.

그렇다면 객체 클래스가 나온다고 했을때는 어떻게 타입변환을 어떻게 해야할까? 라는 질문을 할 수 있다. 일반적으로는당연히 타입변환이 되지 않는다. 하지만 예외적인 케이스는 있다. 타입 변환생성자를 만들어주게 되면, 타입변환이 가능하다. 그리고 타입 변환 연산자로 타입변환이 가능하다. 아래의 코드를 참고하자.

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

class Dog
{
public:
    Dog(){}
    // 타입 변환 생성자
    Dog(const Knight& knight){ _age = knight._hp;}

    // 타입 변환 연산자 (return type 이 없음)
    operator Knight()
    {
        return (Knight)(*this);
    }
public:
    int _age = 1;
    int _cuteness = 2;
};

int main()
{
    Knight knight;
    Dog = dog (Dog)knight; 
}

그리고 또 연관없는 클래스 사이의 참조 타입변환을 알아보자. 아래의 코드를 봐보면 (Dog&) 이게 knight 앞에 없으면 에러를 내뱉는다. 이건 일단 기본적으로 서로 타입이 안맞기 때문이다. 그리고 문법적으로 봤을때는 일단 참조기 때문에 주소값을 타고 가면 Dog 가 있을꺼야라고하는게 사실을 문법적으로 맞다. 하지만 사실적으로는 Knight 이 있는거다. 즉 문법적으로 통과할지라도, 사실의 값이 다르다는건 명시적으로는 괜찮다.

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

class Dog
{
public:
    Dog(){}
    // 타입 변환 생성자
    Dog(const Knight& knight){ _age = knight._hp;}

    // 타입 변환 연산자 (return type 이 없음)
    operator Knight()
    {
        return (Knight)(*this);
    }
public:
    int _age = 1;
    int _cuteness = 2;
};

int main()
{
    Knight knight;
    Dog& dog = (Dog&)knight; 
}

그렇다면 마지막으로 볼수 있는게 클래스에서 중요했던 상속관계에 있는 클래스 사이의 변환은 어떻게 될까? 첫번째는 상속 관계 클래스의 값타입변환이 있다. 아래의 코드를 보면 bulldog 은 dog 를 상속 받고 있기때문에, 말에 일리가 있다. 즉 자식의 타입변환을 해서 부모님에게 저장하는건 가능하다 라는 말이다.

class Dog
{
public:
    Dog(){}
    // 타입 변환 생성자
    Dog(const Knight& knight){ _age = knight._hp;}

    // 타입 변환 연산자 (return type 이 없음)
    operator Knight()
    {
        return (Knight)(*this);
    }
public:
    int _age = 1;
    int _cuteness = 2;
};

class BullDog : public Dog
{
public:
    bool IsFrench;
};

int main()
{
    BullDog bulldog;
    Dog dog = bulldog;
}

마지막으로는 상속관계 클래스의 참조 타입 변환이다. 아래의 코드를 확인했을때 자식에서 부모의 타입변환은 Ok 지만, 부모에서 자식으로 할때 암시적으로는 안돼지만, 명시적으로는 Ok 한다.

class Dog
{
public:
    Dog(){}
    // 타입 변환 생성자
    Dog(const Knight& knight){ _age = knight._hp;}

    // 타입 변환 연산자 (return type 이 없음)
    operator Knight()
    {
        return (Knight)(*this);
    }
public:
    int _age = 1;
    int _cuteness = 2;
};

class BullDog : public Dog
{
public:
    bool IsFrench;
};

int main()
{
    Dog dog;
    BullDog& bulldog = (BullDog&)dog; 
}

Pointer Type Conversion

위와같이 Type Conversion 을 연이어 해보자. 일단 연관성이 없는 클래스 사이의 포인터 변환을 해보자. 아래의 코드에서 보면 명시적으로는 Ok 지만 암시적인것은 컴파일러에서 에러를 내뱉는걸 확인할수있다. 이걸 해석해보자면 item 의 주소를 타고 가면 Item 이 있다라는걸 명시해주는데, 사실상 틀린거라고 확인할수있다. 그런데 여기서 문제점은 실제 Knight() 안에 _hp 가 4 byte 일텐데 item->_ItemType 을 했을때까지는 괜찮다. 왜냐하면 같은 4 byte 일테니까. 하지만 두번째 _ItemDbId 를 넣을경우 엉뚱한곳에다가 메모리의 값을 수정하다보니 메모리오염이 있을수가 있다. 또 이건 에러로 내뱉지도 않으니, 그냥 지나칠수 있는 치명적인 메모리의 오염의 주범이 될거다.

class Knight
{
public:
    int _hp;
};

int main()
{
    Knight* knight = new Knight();
    // Item* item = knight; 암시적 NO
    // 명시적 OK
    Item* item = (Item*)knight;
    item->_ItemType = 2;
    item->_ItemDbId = 1;
    delete knight;
    return 0;
}

그렇다면 상속관계에서의 포인터 타입 변환관계를 알아보자. 여기에서도 명시적으로 하면 Ok 지만, 사실 엉뚱한 메모리를 바꿀수 있는 위험이 있다. 하지만, 논리적으로 생각했을때 자식에서 부모 변환테스트는 암시적으로는 된다. 당연히 Weapon 은 Item 이 맞기 때문이다. 즉 명시적으로 타입변환을 할때는 항상 조심해야한다.

그렇다면 항상모든게 명시적으로 하는게 좋지 않느냐라는 질문을 할수 있지만 아래의 코드를 보면, 자식에서 부모로 가는건 설계적인 면에서 많은 이득을 볼수 있기때문에, Inventory 라는 pointer array 를 사용해서 추가할수 있다. 이 코드에서 사실 제일 중요한 부분은 Okay. 분명 포인터 타입변환을해서 새로운 객체 생성도 했어. 하지만 제일 중요한건 메모리를 빌렸으면 깔끔하게 반납하는게 사실 제일 중요하다. 생성할때는 Item 으로 관리를해서, loop 을 돌면서 item 을 지운다고 하면 어떻게 될까? 일단 Item 만 삭제 하려고 하면 안되고, weapon 이나 armor 의 소멸자를 호출해야 제일 깔끔하게 지워준다.

class Item
{
public:
    Item(){cout << "Item()" << endl;}
    Item(int itemType) : _itemType(_itemType) {};
    Item(const Item& item){cout << "Item(const: item&)" << endl;}
    ~Item(){ cout << "~Item()" << endl;}
public:
    int _itemType = 0;
    int _itemdbid = 0;

    char _dummy[4096] = {};
};

class Weapon : public Item
{
public:
    Weapon() : Item(IT_WEAPON){ cout << " Weapon() " << endl; _damage = rand() % 100 + 1;} 
    ~Weapon(){ cout << "~Weapon()" << endl; }
public:
    int _damage = 0;
};

class Armor : public Item
{
public:
    Armor() : Item(IT_ARMOR){ cout << " Armor() " << endl;}
    ~Armor(){ cout << " ~Armor() " << endl;}
public:
    int _defence = 0;
};

int main()
{
    // Parent -> child
    Item* item = new Item();
    // item 은 무기냐? --> 아니다. 다른거일수도 있잖아!
    Weapon* weapon = item;

    Weapon* weapon1 = new Weapon();
    Item* item = weapon;

    delete item;
    delete weapon;
    

    Item* inventory[20] = {};
    srand((unsigned int) time(nullptr));
    for (int i=0; i < 20; i++)
    {
        int randValue = rand() % 2; 

        switch(randValue)
        {
            case 0:
                inventory[i] = new Weapon(); 
                break;

            case 1:
                inventory[i] = new Armor();
                break;
        }
    }


    for (int i =0; i < 20; i++)
    {
        Item* item = inventory[i];
        if (item == nullptr)
            continue;

        if (item->_itemType == IT_WEAPON)
        {
            Weapon* weapon = (Weapon*)item;
            cout << "Weapon Damage: " << weapon->_damage << endl;
        }

        if (item->_itemType == IT_ARMOR)
        {
            Armor* armor = (Armor*)item;
            cout << "Armor " << armor->_defence << endl; 
        }
    }

    for (int i =0; i < 20; i++)
    {
        Item* item = inventory[i];
        if (item == nullptr)
            continue;

        if (item->_itemType == IT_WEAPON)
        {
            Weapon* weapon = (Weapon*)item;
            delete weapon;
        }

        if (item->_itemType == IT_ARMOR)
        {
            Armor* armor = (Armor*)item;
            delete armor;
        }
    }

    return 0;
}

오케이 여기까지 해봤는데, 뭔가 쉬운 방법이 없을까? 라는 생각이든다. 즉 위의 코드 처럼 타입별로 지우는 방법도 있지만, virtual 이라는 keyword 를 사용해서 자식이 어떤 타입이든 상관하지 않고 지울수 있는 방법이 있다. 아래의 코드를 보면 확실히 코드가 깔끔해지는걸 볼수 있다. 가상함수의 개념을 사용해서, virtual keyword 소멸자

class Item
{
public:
    Item(){cout << "Item()" << endl;}
    Item(int itemType) : _itemType(_itemType) {};
    Item(const Item& item){cout << "Item(const: item&)" << endl;}
    virtual ~Item(){ cout << "~Item()" << endl;}
public:
    int _itemType = 0;
    int _itemdbid = 0;

    char _dummy[4096] = {};
};

class Weapon : public Item
{
public:
    Weapon() : Item(IT_WEAPON){ cout << " Weapon() " << endl; _damage = rand() % 100 + 1;} 
    virtual ~Weapon(){ cout << "~Weapon()" << endl; }
public:
    int _damage = 0;
};

class Armor : public Item
{
public:
    Armor() : Item(IT_ARMOR){ cout << " Armor() " << endl;}
    virtual ~Armor(){ cout << " ~Armor() " << endl;}
public:
    int _defence = 0;
};

int main()
{
    for (int i =0; i < 20; i++)
    {
        Item* item = inventory[i];
        if (item == nullptr)
            continue;

        delete item;
    }
    return 0;
}

결론적으로 포인터나 일반적인 타입의 생성자 호출이 중요했으며, 포인터 사이의 타입변환(캐스팅)을 할 떄는 매우 매우 조심해야한다. 부모-자식 관계에서 부모 클래스의 소멸자에는 까먹지 말고 virtual 을 붙이는게 굉장히 중요하다라는걸 알아보았다.

Shallow Copy vs Deep Copy

가끔식은 객체를 우리가 복사해서 사용을 할수 있다. 원본 데이터는 그대로 내두고, 복사를 통해서 복사한 값으로 이리 저리 사용한다음에 테스팅만 할수 도 있다. 그렇면 복사에 대해서 잠깐 알아보자.

아래의 코드를 봐보자. 아래의 main() 쪽을 봐보자. 일단 복사 생성자와 복사 대입연산자가 없어도 컴파일러가 암시적으로 만들어주는걸 확인 할수 있다. 컴파일러가 기본적으로 만들어주는건, 물론 편하지만, 가끔씩은 커스텀을 해야할 필요가 있다. 즉 그런 케이스는 참조와 포인트를 사용할 경우가 있다.

class Knight
{
public:
    Knight(){};
    ~Knight(){};

public:
    int _hp = 100; // c++11
};

int main()
{
    Knight knight;
    knight._hp = 200;

    Knight knight2 = knight; // 복사 생성자
    Knight knight3(knight);

    Knight knight4; // 기본생성자
    knight4 = knight; // 복사 대입 연산자
}

위의 코드에서는 Knight 의 기본 class 가 있었다. 하지만 기사들이 만약에 pet 이라는 것을 들고 다닌다고 생각해보자. 근데 아래 처럼 Knight 에 Pet 이 속해 있게끔 Knight 클래스 안에 Pet 을 넣었다고 생각을 해보자. 근데 여기 설계에서 안좋은 점은 Pet 의 객체의 생명주기를 tracking 하기 힘들다는 것이다. 그리고 Pet class 안에 정말 큰 이상한 데이터가 들어간다고 생각해보면, Knight 를 instantiate 할때마다 엄청난 큰 데이터를 들고 있는건 메모리 측면에서도 비효율 적이다. 그리고 만약 지금은 괜찮겠지만 Pet 을 상속을 받는 클래스가 있다고 하면 Knight 에서는 상속받는 클래스를 지정해주기가 어렵다. 그래서 Pet* _pet; 이런식 으로 만들면 된다.

다시 복사에대해서 생각을 해보자. 아래와 같이 기본 복사 대입 연산자나 복사 생성자를 통해서 만든다고 했을때, knight 가 들고 있는 Pet 을 그대로 들고 있다는 걸 확인 할 수 있다. 즉 한 펫을 공유하고 있다. 이런 공유의 개념은 사실 얕은 복사(Shallow Copy)라고 생각을 하면 된다. 어떤 하나의 객체를 복사를 하려고 했을떄, 그 복사되는 객체가 다른 객체의 주소값을 그대로 가지고 있어서, 공유가 되는 현상이다.

class Pet
{
public:
    Pet(){ cout << "Pet()" << endl; }
    Pet(const Pet& pet){ cout << "Pet(const&)" << endl; } // 복사 생성자
    ~Pet(){ cout << "~Pet()" << endl; }
};

class Knight
{
public:
    Knight(){};
    ~Knight(){};

public:
    int _hp = 100; // c++11
    //Pet _pet;
    Pet* _pet;    
};

int main()
{
    Pet* pet = new Pet();
    Knight knight;
    knight._hp = 200;
    knight._pet = pet;

    Knight k2 = knight;
    Knight k3;
    k3 = knight;
    return 0;
}

이거만 봤을때 얕은 복사의 역활은 알겠지만 문제가 되는점이 뭘까?라고 생각을 해보자. 얕은 복사가 문제가 될경우는 이거다. 만약 Pet 의 생명주기가 knight 의 생명주기가 같다고 아래의 코드와 같이 생각 해보자. 세개의 Knight 의 객체가 하나의 Pet 을 바라보기 때문에, 소멸자를 날려줄때, 한번삭제는 가능하지만, 나머지는 아예 삭제된 객체를 보기 떄문에 문제가 생긴다. 즉 double free 문제가 생긴다.

class Pet
{
public:
    Pet(){ cout << "Pet()" << endl; }
    Pet(const Pet& pet){ cout << "Pet(const&)" << endl; } // 복사 생성자
    ~Pet(){ cout << "~Pet()" << endl; }
};

class Knight
{
public:
    Knight()
    {
        _pet = new Pet;
    };
    ~Knight()
    {
        delete _pet;
    };

public:
    int _hp = 100; // c++11
    Pet* _pet;    
};

int main()
{
    Knight knight;
    knight._hp = 200;
 
    Knight k2 = knight;
    Knight k3;
    k3 = knight;
    return 0;
}

그래서 이 Shallow Copy 를 안하기 위해서, Deep Copy(깊은 복사)를 하면 된다. 즉 위의 예제와 같이 Knight 들은 각자 자기들만의 Pet 의 객체를 들고 싶어한다. 포인터는 주소값이 있다면, 주소를 그대로 복사하는게 아니라 새로운 객체를 생성하고 상이한 객체를 가르키는 상태가 되게 할 수 있다.

다시 말해서 깊은 복사를 하려면, Compiler 에서 제공되는 기본 복사 생성자나 복사 대입 연산자를 사용하면 안되고, 명시적인 표현이 필요하다. 아래의 코드를 봐보자. 아래의 코드를 보면 복사 대입연산자와 복사 생성자를 명시적으로 표현한게 보인다. 일단 Pet 을 새롭게 만들어야하는것도 맞지만 Pet 의 복사 생성자를 이용해서 knight._pet 을 인자로 준게 보인다. 이건 Knight 에 해당되는 _pet 에 속한다라고도 생각하면 된다. 이것이 깊은 복사라고 한다.

class Pet
{
public:
    Pet(){ cout << "Pet()" << endl; }
    Pet(const Pet& pet){ cout << "Pet(const&)" << endl; } // 복사 생성자
    ~Pet(){ cout << "~Pet()" << endl; }
};

class Knight
{
public:
    Knight()
    {
        _pet = new Pet;
    };
    Knight(const Knight& knight) // 복사 생성자
    {
        _hp = knight._hp;
        _pet = new Pet(*(knight._pet));
    }
    Knight& operator=(const Knight& knight) // 복사 대입 연산자
    {
        _hp = knight._hp;
        _pet = new Pet(*(knight._pet));
        return *this;
    }
    ~Knight()
    {
        delete _pet;
    };

public:
    int _hp = 100; // c++11
    Pet* _pet;    
};

int main()
{
    Knight knight;
    knight._hp = 200;
 
    Knight k2 = knight;
    Knight k3;
    k3 = knight;
    return 0;
}

암시적으로 생성되는 복사 생성자와 복사 대입 연산자를 알아보자, 그리고 그 스텝과 명시적과의 차이를 알아보자. 일단 결론적으로 말하자면, 암시적 복사 생성자의 step 은 이렇다.

  1. 암시적 복사 생성자 Steps
    1. 부모 클래스의 복사 생성자 호출
    2. 멤버 클래스(pointer x)의 복사 생성자 호출
    3. 멤버가 기본 타입일 경우 메모리 복사 (shallow copy)
  2. 명시적 복사 생성자 Steps
    1. 부모클래스의 기본 생성자 호출
    2. 멤버 클래스의 기본 생성자 호출

아래의 코드를 한번 봐보자. 아래의 코드는 암시적으로 복사 생성자와 복사 대입생성자의 흐름을 알수 있다.

class Pet
{
public:
    Pet(){ cout << "Pet()" << endl; }
    Pet(const Pet& pet){ cout << "Pet(const&)" << endl; } // 복사 생성자
    ~Pet(){ cout << "~Pet()" << endl; }
};

class Player
{
public:
    Player(){ cout << "Player()" << endl;}
    Player(const Player& player) 
    { 
        cout << "Player(const Player&)" << endl; 
        _lvl = player._lvl; 
    }
    Player& operator=(const Player& player)
    { 
        cout << "Player operator=()" << endl; 
        _lvl = player._lvl;
        return *this; 
    }
    ~Player(){cout << "~Player()" << endl; }
public:
    int _lvl = 0;
};

class Knight : public Player
{
public:
    Knight()
    {
    };
    ~Knight()
    {
    };

public:
    int _hp = 100; // c++11
    Pet _pet;    
};

int main()
{
    Knight knight;
    knight._hp = 200;
 
    Knight k2 = knight; // 복사 생성자
    Knight k3;
    k3 = knight; // 복사 대입 연산자
    return 0;
}

명시적인걸 한번 봐보자. Inherit 에서 명시적으로 복사 생성자를 했을 경우 주의점은 부모의 복사생성자가 call 이 됬는지, 기본 생성자가 생성이 되어있는지를 확인해보아야 한다.

class Pet
{
public:
    Pet(){ cout << "Pet()" << endl; }
    Pet(const Pet& pet){ cout << "Pet(const&)" << endl; } // 복사 생성자
    ~Pet(){ cout << "~Pet()" << endl; }
};

class Player
{
public:
    Player(){ cout << "Player()" << endl;}
    Player(const Player& player) 
    { 
        cout << "Player(const Player&)" << endl; 
        _lvl = player._lvl; 
    }
    Player& operator=(const Player& player)
    { 
        cout << "Player operator=()" << endl; 
        _lvl = player._lvl;
        return *this; 
    }
    ~Player(){cout << "~Player()" << endl; }
public:
    int _lvl = 0;
};

class Knight : public Player
{
public:
    Knight()
    {
    };
    Knight(const Knight& knight) : Player(knight), _pet(knight._pet)
    {
        _hp = knight._hp;
    }
    ~Knight()
    {
    };

public:
    int _hp = 100; // c++11
    Pet _pet;    
};

int main()
{
    Knight knight;
    knight._hp = 200;
 
    Knight k2 = knight;
    Knight k3;
    k3 = knight;
    return 0;
}

그 다음에는 암시적 복사 대입연산자의 step 을 알아보자.

  1. 암시적 복사 대입 연산자 step
    1. 부모 클래스의 복사 대입 연산자 호출
    2. 멤버 클래스의 복사 대입 연산자 호출
    3. 멤버가 기본 타입일 경우 메모리 복사 (얕은 복사 shallow copy)
  2. 명시적 복사 대입 연산자 step
    1. 알아서 잘해라!

암시적인 복사 대입연산자도 암시적 복사 생성자와 같다.

class Pet
{
public:
    Pet(){ cout << "Pet()" << endl; }
    Pet(const Pet& pet){ cout << "Pet(const&)" << endl; } // 복사 생성자
    ~Pet(){ cout << "~Pet()" << endl; }
    Pet& operator=(const Pet& pet){ cout << "Pet& operator()="<< endl; return *this; }
};

class Player
{
public:
    Player(){ cout << "Player()" << endl;}
    Player(const Player& player) 
    { 
        cout << "Player(const Player&)" << endl; 
        _lvl = player._lvl; 
    }
    Player& operator=(const Player& player)
    { 
        cout << "Player operator=()" << endl; 
        _lvl = player._lvl;
        return *this; 
    }
    ~Player(){cout << "~Player()" << endl; }
public:
    int _lvl = 0;
};

class Knight : public Player
{
public:
    Knight()
    {
    };
    Knight(const Knight& knight) : Player(knight), _pet(knight._pet)
    {
        _hp = knight._hp;
    }
    Knight& operator=(const Knight& knight)
    {
        cout << "Knight operator=()" << endl;
        _hp = knight._hp;
        return *this;
    }
    ~Knight()
    {
    };

public:
    int _hp = 100; // c++11
    Pet _pet;    
};

int main()
{
    Knight knight;
    knight._hp = 200;
 
    Knight k2 = knight;
    Knight k3;
    k3 = knight;
    return 0;
}

하지만, 명시적인것은 얕은복사를 피하기 위해서 Knight 의 복사 대입연산자를 호출 했을경우 Pet 과 Player 에 대한 정보가 없으므로 초기 세팅이 필요하다.

class Pet
{
public:
    Pet(){ cout << "Pet()" << endl; }
    Pet(const Pet& pet){ cout << "Pet(const&)" << endl; } // 복사 생성자
    ~Pet(){ cout << "~Pet()" << endl; }
    Pet& operator=(const Pet& pet){ cout << "Pet& operator()="<< endl; return *this; }
};

class Player
{
public:
    Player(){ cout << "Player()" << endl;}
    Player(const Player& player) 
    { 
        cout << "Player(const Player&)" << endl; 
        _lvl = player._lvl; 
    }
    Player& operator=(const Player& player)
    { 
        cout << "Player operator=()" << endl; 
        _lvl = player._lvl;
        return *this; 
    }
    ~Player(){cout << "~Player()" << endl; }
public:
    int _lvl = 0;
};

class Knight : public Player
{
public:
    Knight()
    {
    };
    Knight(const Knight& knight) : Player(knight), _pet(knight._pet)
    {
        _hp = knight._hp;
    }
    Knight& operator=(const Knight& knight)
    {
        cout << "Knight operator=()" << endl;
        Player::operator=(knight);
        _hp = knight._hp;
        _pet = knight._pet;
        return *this;
    }
    ~Knight()
    {
    };

public:
    int _hp = 100; // c++11
    Pet _pet;    
};

int main()
{
    Knight knight;
    knight._hp = 200;
 
    Knight k2 = knight;
    Knight k3;
    k3 = knight;
    return 0;
}

Casting

또 C++ 에서 casting 에 관련된 함수들이 존재한다. 한번 알아보자.

  1. static_cast
  2. dynamic_cast
  3. const_cast
  4. reinterpret_cast

static_cast 같은경우, 타입 원칙에 비춰서 볼때, 상식적인 캐스팅만 허용해준다. (예 int <-> float). 그리고 다운 캐스팅도 허락이된다 (예 Player* -> Knight*). 이것도 마찬가지로 뭔가 타입 캐스팅을 할때 프로그래머가 객체의 구조를 확인하고, 명시적으로 할수 있는지 없는지에 따라서 결정을 해야한다. 아래의 코드를 확인해보자.

int hp = 100;
int maxHP = 200;
float ratio = static_cast<float>(hp) / maxHP;
class Player
{

};

class Knight : public Player
{

};

class Archer : public Player
{

};

int main()
{
    Player* player = new Player(); // 예외의 케이스
    Knight* k1 = static_cast<Knight*> player;

    Knight* k2 = new Knight();
    Player* p2 = static_cast<Player*>(k2);
    return 0;
}

그 다음에는 dynamic_cast 를 확인해보자. 결국 dynamic_cast 같은 경우 static_cast 의 단점을 살짝 보완해주는 느낌이다. 이게 어떻게 작동하는지는 RTTI(RunTime Time Information) 라는 거에 결정이 되는데, 이 개념은 사실 virtual 과 비슷하다. .vftable 에서 run time 에서 타입을 확인 할수 있다. 즉 virtual keyword 가 있어야 dynamic_cast 를 사용할수 있다라는 것이다. 그리고 만약에 잘못된 타입으로 캐스팅을 했으면, nullptr 로 반환을 한다. 근데 이렇게 .vftable 를 확인해서, 그 타입이 한번 맞는지 더 체크하는게 performance 에서는 않좋을수 있으니 static 과 같이 사용하는게 좋다. 아래의 코드를 보면 정의하는 방법이있다.

Knight* k4 = dynamic_cast<Knight*>(player)

const_cast 의 경우는 const 를 떄고 붙이고 하는 역활을 한다. 아래와 같이 "Nick" 같은 경우는 const char pointer 이기 때문에 안될수 밖에 없다. 그래서 const_cast 를 사용해서 const 를 빼고 넘겨주기 떄문에 PrintName 의 signature 에 맞아서 작동 하게 된다.

PrintName(char *str)
{

}

int main()
{
    PrintName(const_cast<char*>("Nick"));
}

그리고 마지막 reinterpret_cast 를 알아보자. 이 친구는 강력한 형태의 캐스팅이다. 예를 들어서 포인터와 전혀 관계업는 다른타입 변환이 있다.

Knight* k2;
__int64 address = reinterpret_cast<__int64> k2;

Resource

Source Code

What is Pointer ?

포인터는 한마디로 해서, 주소를 저장하고 있는 바구니라고 생각 하면 된다, 주로 포인터 사이즈는 64 비트 기준으로 8 바이트이다. 일단 사용하는 방법은 TYPE* VAR_NAME 이런 식으로 사용하면 된다. 그러면 딱 코드를 봤을때 어떻게 생각하면 되냐? 뭔가 *(Asterisk) 이게 등장했다하면 포인터 = 주소라고 생각하면된다. 아래와 같이 number 라는 variable 은 Stack Memory 에 저장이 되어있는데, 이 Stack Memory 에 주소값을 ptr 로 받아주는 느낌이라고 생각하면 된다. 그렇다면 주소의 값은 어떻게 받냐? 라고 하냐면 & (ampersand) 이렇게 받으면된다. 다시말하자면 *ptr 이라는건 주소를 저장하는 형식의 바구니라는거라고 생각하면 된다.

int number = 1;
int *ptr = &number;

그렇다면 이 주소를 가지고 있는 바구니가지고 뭘할수 있을까 라고 생각이든다. 값을 가지고 오는 방법은 뭐가있을까? 일단 그러기전에 변수선언(variable declartion)을 한상태에서, 사용할때는 마치 포틀을 타고 순간이동을 한거나 마찬가지라고 생각을 하면된다. 왜 갑자기 포탈이라고 생각할수 있는데. 만약 메모리를 까보면 이런식으로 되어있다. 위의 코드를 봤을때 ptr 의 값을 &ptr 이런식으로 가게되면 주소값이 저장된걸 확인할수 있고, 그 값을 통해서 가면 number 의 주소로 향하고 있다는걸 확인 할수 있다. 또한 아래의 코드를 보자면, ptr 을 타고 들어가서 그 값은 주소값이니까, 그주소로 가면 2로 변경된걸 확인 할수 있다. 즉 포탈을 타고 가서 값을 변경하는것이다. 이걸 타입면에서 생각을 해보았을때 * 가 있다면 ptr 을 가면 int 가 있다고 생각해도 된다. 그런데, 타입 캐스팅이 가능하기 때문에, 메모리 오염을 시킬수 있다.

int value1 = *ptr;
*ptr = 2

// but let's think about this way
// what if type is differnet
__int64* ptr2 = (__int64*)&number;
*ptr = 0x0000AABBCCDDEEFF; // this wil contaminate the memory since it will allocate extra space.

아래와 같은 예제는 함수에 사용될때의 예시인데, main() 안에 stack memory 에 올라간 hp 를 SetHp로 인자를 넘겨줬을때, 값은 변화하지 않는다. 값을 변화시킬려면 stack memory 안에 있는 hp 의 주소를 던져줘서 hp value 를 바꿀수 있는 예제이다.

void SetHp(int *hp)
{
    *hp = 100;
}

int main()
{
    int hp = 1;
    SetHp(&hp) // give the address.
    return 0;
}

Pointer Operation

포인터 연산에는 아래와 같이 나누어진다.

  1. 주소 연산자 : ampersand(&)
  2. 산술 연산자
  3. 간접 연산자
  4. 간접 멤버 연산자

주소 연산자 같은 경우는 & 사용해서 주소값을 가지고 올때 사용한다. 즉 해당 변수 타입에 따라서 TYPE* 을 return 한다. 산술 연산자 같은 경우 +- 를 사용한다. 다만 나눗셈과 곱셈 연산은 사용되지 않는다. +- 를 했을때 그 타입의 사이즈를 따라서 그 주소 뒤에 가거나 앞으로 간다고 생각하면 된다. 즉 타입만큼의 크기만큼 이동하는거라고 생각하면 된다. 간접 연산자 같은경우는, 우리가 포인터를 생성할때 * 사용했었다. 포인터란 다시말해서, 포탈을 타고가라 라고 생각하면 됬었다 했었다. 그렇다면 간접 멤버 연산자는 뭘까? 정담은 이 친구 -> 이다. 아래의 예제 코드를 보면, player 의 객체의 주소를 playerPtr 향하게 했었다. 그말은 playerPtr 에는 player 의 주소가 담겨져 있다 라고 생각하면 된다. 그래서 player 의 멤버 변수인 _hp_damage 를 바꾼다고 가정했을때 아래와 같이 할수 있다. 그럼 간접연산자를 어떻게 사용할수 있을까는 그 다음 코드 segment 로 사용해도 똑같다. 즉 * 와 . 을 합친게 -> 라고 생각하면 쉽다.

class Player()
{
public:
    int _hp;
    int _damage;
}

int main()
{
    int number = 1;
    int* ptr = &number;

    Player player;
    player._hp = 100;
    player._damage = 30;

    Player* playerPtr = &player;

    //pointer
    (*playerPtr)._hp = 200;
    (*playerPtr)._damage = 40;

    // indirect member op
    playerPtr->_hp = 300;
    playerPtr->_damage = 50;
    return 0;
}

예제 코드는 아래, resource 에 있다.

Reference

이걸 말하기전에 바로 코드로 넘어가보자. StatInfo 라는 struct 를 만들어서 pointer 값을 CreateMonster 함수 에서 parameter 로 받았을때, hp, attack, and defence 는 각각 4 바이트씩 차지하고 있을거다. 그래서 CreateMonster 함수에서 인자로 넘겨줄때는 주소값(address)를 넘겨주는걸 확인할수 있다. 하지만 만약 CreateMonster 함수의 paramater 를 StatInfo info 이런 식으로 signature 가 저장이 되어있다면 어떻게 될까? 라고 생각을 하자면, 원본 데이터 즉 main 에 있는 info 가 인자값으로 돌아와 그 매개의 복사값을 생성하면서 값을 채워넣는다. 즉 CreateMonster 안에 있는 함수의 info 와 main 에 있는 함수는 별개의 것이며, info 라는 매개변수를 새로 생성해서 복사를 하는 형태가 된다. 즉 원본데이터를 수정하기 위해선 주소값을 넘겨주고, 인자선언은 pointer 로 받으면 된다는 뜻이다. 근데 결과물은 똑같다. 하지만 performance 에서 봤을땐 복사값을 만들어서 붙여넣기 하는 형태이고, 주소값을 보내는 친구는 그냥 딱 야 이거해 하는 느낌인거다.

struct StatInfo()
{
    int hp;
    int attack;
    int defence;
};

void CreateMonster(StatInfo* info)
{
    info->hp = 100;
    info->attack = 9;
    info->defence = 5;
}

int main()
{
    StatInfo info;
    CreateMonster(&info);
    return 0;
}

reference 놈을 알아보자. reference 는 c 에 없고 c++ 에 있는 친구인데. 일단은 low level (assembly) 단에서보면 pointer 와 동일하게 작동한다. 일단 정의 부터 알아보자. reference 를 하고 싶다면, 아래와 같이 하면되는데. 이렇게 생까하면 된다 number 라는 바구니에 reference 라는 다른 이름을 지어줄께. 라고 생각하면 된다. 뭐야? reference 가 더쉽잖아. 왜 구지 포인터를 쓰면서 까지 저렇게 해 라고 되물을 수 있다. 근데 여기서 중요한건 pass_by_referencepass_by_pointer 라고 생각하면 된다. 즉 함수에서 문법이 달라진다.

int number = 3;
int& reference = number;

아래의 코드와 같이 문법이 살짝 달라진다고 알수 있다. pass_by_reference 같은경우 pass_by_pointer 와 달리 info 값을 넣어준걸 확인 할수 있고, pass_by_pointerinfo 의 주소값을 던져주는걸로 알수 있다. 즉 결론을 말하자면 pass_by_referencepass_by_pointer 를 assembly 언어로 까보면 동작은 똑같다. 즉 performance 측면에서는 똑같다. 하지만 문법이 다른것으로 알수있으며 pass_by_reference 는 약간 야매로 pass_by_valuepass_by_pointer 의 중간지점이라고 생각하면 편할것같다.

struct StatInfo()
{
    int hp;
    int attack;
    int defence;
}

void PrintInfoByValue(StatInfo info)
{
  cout << "_________________" << endl;
  cout << info.hp << endl;
  cout << info.attack << endl;
  cout << info.defence << endl;
}

void PrintInfoByPtr(StatInfo* info)
{
  cout << "_________________" << endl;
  cout << info->hp << endl;
  cout << info->attack << endl;
  cout << info->defence << endl;
}

void PrintInfoByRef(StatInfo& info)
{
  cout << "_________________" << endl;
  cout << info.hp << endl;
  cout << info.attack << endl;
  cout << info.defence << endl;
}

int main()
{
    // ...
    StatInfo info;
    PrintInfoByValue(info);
    PrintInfoByPtr(&info);
    PrintInfoByRef(info);
    return 0;
}

Pointer vs Reference

위에서 봤듯이 low level 에서는 pointerreference 가 동일하다는 걸 체크를 했었다. 동일하다는 의미는 Performance 적으로는 확실히 동일하고, 편리성은 reference 가 더 편할수 있다는 말이다. 근데 편하다는건 항상 단점을 가지고 있다. 포인터는 주소를 넘기니 확실하게 원본을 넘긴다는 힌트를 주는데 예를 들어서, & 사용해서 넘겨준다. 하지만 참존는 자연스럼게 모르고 지나칠수 있다는 말이다. 예시를 들자면, 위의 코드에서 만약 함수의 이름이 다 동일한 케이스일 경우 들수 있다.

// 어떤 여러개 선언


int main()
{
    PrintInfo(&info)
    PrintInfo(info) // --> 이럴 경우에는 실제 원본을 건들이는건지, 복사를 하는건지 명확하지 않다.
    return 0;
}

즉 실제 원본을 건들이는건지, 아니면 복사를 하는건지 모를수도 있고, 원본을 훼손할 가능성이 티가 안날수 있다는것도 맞다. 그러면 어떻게 이걸 막을수 있는 방법이 뭘까? 라고 고민할수 있다. (즉 절대 수정하지마라!) 라는걸 어떻게 표현할까. 아래의 코드 처럼 const 를 쓰게 되면 info 가 read-only 로 만들수 있다.

void PrintInfo(SztatInfo *){};
void PrintInfo(const StatInfo& info){};

그렇다면 여기서 또 질문할수 있는게, pointer 앞에 const keyword 를 사용하면 어떤의미를 가지고 있을까도 생각할수 있다. pointer 에서 const 를 쓸때 앞에다가 붙이느냐, 뒤에다가 붙이느냐의 의미는 서로 다르다.

void PrintInfo(const StatInfo* info){}
void PrintInfo(StatInfo)

만약 앞에 const 라는 keyword 가 붙었더라면, info 가 가르키고 있는 메모리에 저장되어있는[바구니] 값을 바꿀수 없는 형태이며, 반대로 뒤에 const 가 붙인다면, info 라는 주소(바구니의 내용물)을 못바꾸는 형식으로 된다. 아래의 예제 코드를 봐보자.

즉 다시말해서, 원격 바구닌를 못바꾸냐, 직접적인 바구니를 못바꾸냐의 차이이다. 아래의 코드를 보면 info 안에 들고 있는 struct 의 member variable? 을 access 할수 없게 되는거며, 뒤에 const 가 바뀔시에는 info 라는 값을 못바꾸는 것이라고 생각하면된다. 즉 안정성을 보안할수 있다는게 중요하다. 그럼 reference 와 pointer 를 비교했을때, nullptr 을 체크해서 사용을 한다면 가끔씩 유용할수 있다는것과 쉽게 바구니의 다른 네이밍을 넘겨주는것도 편리성에서는 좋다는 것이다.

struct StatInfo()
{
    int hp;
    int attack;
    int defence;
};

StatInfo globalInfo;
void ConstantBehind(StatInfo* const info)
{
    info = &globalInfo; // redline (error)
}

void ConstantFront(const StatInfo* info)
{
    info -> hp = 1000; // redline
}

StatInfo* FindMonster()
{
    // TODO: HEAP 영역에서 뭔가를 찾아봄
    // 찾았다
    // return monster
    // if not 
    return nullptr
}

int main()
{
    StatInfo info;
    return 0;
}


또 넘어가보자. 오케이. 알겠어 reference 와 pointer 의 차이점을 이해가간다고 생각을한다면, 사실 맨처음에 다뤄야할게 이제 나온다. 바로 initialization 을 어떻게 할것인가가 문제이다.

참조 타입은 바구니의 2번째의 이름이라고 생각하면 된다. 포인터같은경우는 가르키는 바구니를 말을 하면 되는거다(어떤 ~ 주소 라는 의미). 즉 참조 타입 같은 경우 참조하는 대상이 없으면 안된다. 하지만 포인터 같은경우 가르키는 대상이 실존하지 않을수도 있다. 즉 Null 로 정의 될수 있다는 것이다. Intuitively 생각하자면, 참조 타입 같은 경우, NULL 이나 nullptr 로 initialization 이 없다라고 생각하면된다.

The Lecturer told that it’s case-by-case to use reference or pointer. For example, google uses the pointer, and Unreal Engine prefer the reference.

결국엔 선호에 따라서 달라져있는데, nullptr 처럼 유용하게 쓸수 있다고 고려한다면, pointer 를 사용하고. 바뀌지 않고 읽는 용도로만 사용한다면 const ref& 이런식으로 하고. 그 외 일반적으로 ref (명시적으로) 호출할때 OUT 을 붙여준다. 아래의 코드를 봐보면 될것이다.

// Unreal Engine
#define OUT
void ChangeInfo(OUT Statinfo &info)
{
    info.hp = 1000;
}

int main()
{
    ChangeInfo(OUT info);
    return 0;
}

참고) 포인터로 사용하던것을 reference 로 사용하려면 어떻게 해야되냐. 아래의 코드를 보면된다. 여기에서 ptr 을 그냥 주게 되면 에러가뜨게 된다 그말은 가르키는 주소를 던져준다라고 생각하면 되는데, 우리는 info 의 값을 바꾸고 싶으니까, info 로 가라 라는건 * 연산자를 통해서 가면 된다. 그리고 만약 이걸 반대로 생각해서 우리가 reference 값을 pointer 로 넘기는 방법은, & 를 사용해서 주소값을 넘겨줘라 라고 생각하면 된다.

struct StatInfo()
{
    int hp;
    int attack;
    int defence;
}

void PrintInfoByPtr(StatInfo* info)
{
  cout << "_________________" << endl;
  cout << info->hp << endl;
  cout << info->attack << endl;
  cout << info->defence << endl;
}

void PrintInfoByRef(StatInfo& info)
{
  cout << "_________________" << endl;
  cout << info.hp << endl;
  cout << info.attack << endl;
  cout << info.defence << endl;
}

int main()
{
    StatInfo = info
    StatInfo* ptr = nullptr;
    ptr = &info;
    PrintInfoByRef(*ptr);

    StatInfo &ref = info;
    PrintInfoByPtr(&ref)
    return 0;
}

Basic Array

배열이란 결국 우리가 1 층 아파트를 생각하면될것이다. 1 층 건물에 누구를 할당시킬것인가가 라고 생각하면 편하다 아래의 코드를 보자. 아래의 코드를 보자면 사실 comment 로 다 작성을 했었다. Pointer 로 작성 했었을때, 일단 포인터는 주소값이고 array 라고 생각하면, 제일 앞에를 가르키고 있을것이다. 그래서 pointer operation 을 통해서 + 를 사용해서 그다음 데이터를 고칠수 있을것이다. 그리고 initialization 같은 경우는 코드를 보면 될것이다.

struct StatInfo
{
    int hp;
    int attack;
    int defence;
};

int main()
{
    const int mCount = 10; // mosnter count
    StatInfo monsters[monsterCount] // how many monster will be in the array

    // Access Array with pointer
    StatInfo* ptr = monsters; // [monster 0] [monster 1] [monster 2] ... [monster 9]
    // ptr is initially in monster 0 location
    ptr->hp = 100;
    ptr->attack = 10;
    ptr->defence = 10;

    // what if we do the operation + 1 to pointer, that is going to be the next one
    StatInfo* ptr1 = ptr + 1;
    ptr1->hp = 200;
    ptr1->attack = 20;
    ptr1->defence = 10;

    // then what if I want to get the reference data using pointer, then this is going to be the second one
    StatInfo& ref = *(ptr + 2);
    ref.hp = 300;
    ref.attack = 30;
    ref.defence = 30;

    // what if I want to go full round
    for (int i=0; i < mCount; i++)
    {
        monsters[i].hp = 100 * (i+1);
        monsters[i].attack = 10 * (i+1);
        monsters[i].defence = (i+1);
    }

    // Array Initialization
    int arr[5] = {}; // the size is going to be 5(0-4), and all variable set to 0
    int arr1[10] = {1, 2, 3, 4}; // [1][2][3][4][0][0].. rest are going to be 0
    int arr2[] = {1, 2, 3, 4, 5, 6}; // depending on the elements in array, it will set to the number of elements

    return 0;
}

MultiPointer

이를 다중 포인터라고 하는데 예를 들어서 이런 예제 코드를 봐보자. 아래의 코드를 실행 시켰을때, 이상하게도 type 은 정확하게 넣었는데, 값이 “Hi” 로 안바뀐걸로 보이는다. 왜그럴까? 라고 생각해보자. 일단 stack 안에 있는 msg 가 parameter 로 SetMessage 로 들어가는데, 이때 a 값이 변하지 않는 이유는 SetMessage 안에 있는 a 의 값은 변경이되고 사라진다는 것이다. 그래서 a 를 고치려면 *a 식으로 가야한다. 근데 막상 돌려보면 이건 const char 이다. 그래서 그 아래의 코드로 변경을 해야 된다. 어 ** 이렇게 한다고 어이가 없을수 밖에 없다.

그러면 이거에 대해서 설명을 하겠다. msg 도 pointer 이다. msgHello 라는 값을 pointing 하고 있다. 그렇다면 이값을 변경하려면 msg 의 주소값을 들고 와야한다. 그러면 다시 pointer 의 개념으로 돌아가는것이다. 그 의미는 pointer 를 다시사용해서, 즉 두번사용해서 msg 를 가르키는 놈이 필요하다는것이다. 여기서 dpointer 가 하는 역활이 msg 의 주소값을 가지고 있어서 두번 와프를 타라는 것이다. 왜냐하면 거기에 hello 라는 놈이 있을테니. 그래서 **dpointermsg 의 주소값을 가져오게 되면, msghello 를 가르키고 있기때문에 가지고 올수 있는것이다. 그 이후에 parameter 로 SetMessageDiff 에 넘겨주게 된다면, a 라는 놈을 한번 타고 들어가서 값을 변경시키면 될것이다.


void SetMessage(const char *a)
{
    a = "Hi";
}

void SetMessageDiff(const char **a)
{
    *a = "Bye";
}

int main()
{
    const char* msg = "Hello";
    SetMessage(msg);

    const char **dpointer = &msg;

    SetMessageDiff(&msg);
    cout << msg << endl;
    return 0;
}

MultiDimension Array

Array section 에서 본것 같이 multi-Dimension Array 란 이제 층수가 달라진다는것이다. 예를 들어 코드를 봐보자

int main()
{
    for (int floor = 0; floor < 2; floor++) {
        for (int room = 0; room < 5; room++) {
            int num = apartment[floor][room];
        }
    }
    return 0;
}

이런식으로 배열이 되어있다면, unit number 은 5 개라는거고 층의 개수는 2개라고 생각하면 될것이다. 그래서 이렇게 배열을 indexing 할수 있는거다. 그렇다고 하면 pointer 로 어떻게 access 를 할수 있을까가 질문인것이다.

2 차원 배열과 다중포인터로 어떻게 사용할수 있을까? 라는 생각이든다. 이렇게 생긴 코드를 한번 보자. pp [ 주소1 ] –> 주소1[주소2] –> 주소2[] 이렇게 하면 될까? 라는 생각이든다. 하지만, 실제로 보면, *pp 만 해도 덩그러니 1 이라는 값이 있는걸 생각할수 있다. 즉 pp[ 주소1 ] 을 타고 들어갔더니 주소1[ value ] 가 있는것이다. 그래서 프로그램이 뻗어버린다. 즉 다중 포인터와 다중 배열은 완전 다른 타입이다.

int **pp = arr2;

그래서 이 코드를 보면 된다, 그러기전에 꼭 타입을 한번 확인해보자. 그러면 int(*)[2] 라고 확인할수 있다. 그러면 이걸 어떻게 해석하면 되냐. p2 로 타고 들어갔더니 int()[2] 2차원 배열이 있다고 생각하면 된다. 근데 이렇게 구지 할필요는 없을거다. 그냥 arr2[0][0] indexing 을 해도 충분하다.

int (*p2)[2] = arr2;
cout << (*p2)[0] << end;
cout << (*p2)[1] << endl;
cout << (*(p2 + 1))[0] << endl;
cout << (*(p2 + 1))[1] << endl;

Resoure

Source Code

Black Panther - Wakanda Forever

Black Panther : Wakanda Forever

사실 그렇게 Marble 영화를 좋아한다고 하기에는 굉장히 논란거리가 많다고 생각한다. 나는 아이언맨 1, 2, 헐크, 토르1, 2 만 보았고 전체적인 스토리에대해서 전혀 모른다. 마블의 광팬이라면, 뭔가 내가 해리포터를 굉장한 팬처럼 생각하는것 처럼 그렇겠구나라는 생각이든다. 항상 마블하면 생각하는건 미국에 있었을때 였다. 뭔가 엔드게임을 보자는 친구들이 많아서, 나를 대신해서 티케팅을 해줘서 그냥 따라 갔던 기억이 난다. 그때는 뭔가 영어를 알아들어도, 무슨 스토리인지 감이 안와서 생각없이 보았던 영화였는데 하면서 봤던건데 기억나는 장면은 갑자기 모든 주인공이 나타나 싸우질 않나, 갑자기 캡틴 아메리카가 토르의 망치를 가지고 오면서 싸우는 장면이 띄어졌을때, 온 사람이 “WoW! YEAH! OMG!” 이런 반응에 뭐지? 한것도 기억이 난다. 그리고 마지막은 정말 슬펐던 장면, 아이언맨이 죽고 나서, 아이언맨의 딸에게 최애의 음식이 치즈버거 였다는 말까지 밖에 기억이 안났었다. 그게 나의 마지막 마블영화였던것 같다.

블랙팬서 1도 비행기 타면서 봤었는데 너무 지루하고, 무슨 이야기가 돌아가는지 몰라서 잤었던것 밖에 기억이 안났는데, 이걸 어찌보냐 이런생각 밖에 안들었었다. 그런데, 주인공이 죽었다라는 소식을 듣기는 들었었어서 마냥 완전 새로운 스토리겠지 하면서 봤었다. 스포를 싫어하는 사람들을 위해서 말을 하자면, 결론은 딱이거다. 새로운 블랙팬서가 나타났다. 그게 여동생이다라는 말을 할수 밖에 없는것 같다. 뭔가 영화를 보면서, 조금 음 뚱딴지 같다라는 생각이드는데, 나중에 나의 최애 캐릭터가 있었던거 같다. 의리와 재치가 넘치는 음바쿠! 음바쿠라는 캐릭터 설정이 진짜 재밌어 보였는데, 사실 내가 영어를 많이 않쓰다보니 영어 듣기 실력이 줄었겠지 하지만, 이 친구가 말하는건 웬지 너무 잘들리고, 뭔가 내 이미지와 맞는것 같아서 계속 그냥 들렸던것 같다.

하.. 딱히 영화리뷰라고 할것도 없다. 아무 생각없이 보기엔 정말 좋은 영화였다. 스토리 라인은 그냥 조금 그랬었던것 같았다. 극적인 변환

Sid Meier

Sid Meyer

사실 이 책을 읽었을때, 한국에 온지 한 1년이 되지 않은차에 천천히 읽게 되었다. 가끔씩은 내가 어떤 문장을 번역을 한다고 했을때, 정확한 의미 전달이 안될수도 있다는 생각이 자주들지만, 가끔씩 이런 미국에서 책을 쓴게 원작이 되어서 한국어로 번역되어서 나온 책들은 늘 부자연스럽게 느껴진다. 그리고 뭔가 게임 개발자로서 통하는 용어들은 확실히 와닿지 않았다.

나는 항상 어떤 product 를 만드는것도 좋아하며, 그 만든것을 내가 소유하는것을 굉장히 좋아한다. 그래서 게임을 만들기전 과거의 사람들이 게임을 만들때, 임했던 자세나 어떤 고생과 땀을 가지고 게임을 만들었는지가 궁금했어서 이책을 리디북스로 바로 구매 했다.

회사에 다니면서, 뭔가 Unreal Engine 에 빠져있었던 나, 그게 영향을 끼쳤던 Carla Simulator, ray tracing 을 통해서 보여지는 차량들, 그림자(Shader) 효과 등 realistic 한 느낌을 들었기 때문에 게임 개발을 하고 싶다는 생각이 들어서, 사실 이 책을 읽게되었다. 이 책을 읽기전, 책 이름이 어떤 게임 이름의 이름인 줄 알았다. 하지만, 읽고 나서 보니, 시드 마이어라는 게임 개발자가 게임을 만들면서 일어났던 회사의 이슈, 그리고 시드마이어의 family background(like where he grew up in sweden affects his displine for developing his personality) 또는 그가 좋아했던 바흐의 음악이 어떻게 Sims Golf 라는 게임에 영향을 끼쳤는지, 알수 있었다.

시드마이어 이 책은 내가 기억하기론 한챕터마다 마지막에 미션이 달려있다. 그리고 이 책은 약간 시드마이어 게임 개발자로서의 삶이 시간대별로 녹아져내려 있으며, 시드마이어 라는 사람이 누구인지를 알게된계기가 되었다.

읽으면서 좋았던 구문들을 밑줄치면서 읽었는데, 공유하면 좋을것 같다.

  1. “효율이 목표이고 이를 위해서는 개발주기의 횟수를 최대한 늘리는 것이 좋으나 각 개발 주기에 최대한 많은 정보를 얻는 것도 중요하다. 내 원칙은 “2배로 늘리거나 절반으로 잘라라 이다.”
  2. “변화가 과한 것 같다면 올바른 방향으로 진행하고 있는 것을 확인했으니 변화의 정도를 적정 수준으로 조절하면 된다.”
  3. “여러분의 재능을 누군가 발견해준다는 보장은 없다. 하지만 아무것도 만들지 않는 사람에게는 확실히 그런 일이 일어나지 않는다. 자신의 아이디어가 좋다는 것을 증명하는 최고의 방법은 말이 아닌 행동이다. 처음에는 프로그래머가 되어서 플레이할 수 있는 무언가를 만들라. 다음에는 그래픽 디자이너가 되어서 이 무언가를 대충 알아볼 만한 상태로 변신 시켜라. 그리고 그 뒤에는 테스터 역활을 맡아서 재밌는 부분과 그렇지 않은 부분이 무엇인지 솔직히 평가하라. 어떤 역활에든 완벽할 필요는 없다. 자신의 아이디어를 증명하고 다른사람도 그 아이디어 구현에 동참하고 싶다고 생각하게 만들 정도의 실력만 갖추면 된다”
  4. “수학이 인기와 정면대결을 해서 이기긴 어렵다. 디자인 관점에서는 사각형보다 육각형이 나은데도 <문명>이처음 출시될 당시 평범한 컴퓨터 사용자가 보기에는 육각형은 너무 너드스럽다는 이유로 익숙한 사각형을 선택할 수 밖에 없다. ... 말했듯이 모든 일에는 대가가 따른다"
  5. “각자가 자신이 가장 좋아하는 게임의 추억 안에 나를 보존해두기 때문이다. 어떤 이는 십 대 시절 자신의 인도한 현명한 노선생으로 기억할 것이고 어떤 이는 모두가 너무 늙어서 그런 일을 할 수 없다고 말할 때 함께 해적인 척해준 엉뚱한 비밀 친구로 기억할 것이다. 사람들이 나에 대해 품고 있는 환상은 사실 내가 아니라 그들이 경험한 즐거움에 관한 환상이고 나는 그들을 위해 그 행복한 기억을 지켜주고 싶다.”

등등 글이 있는데, 정말 뜻깊고 주옥같은 이야기이다. 만약 내가 개발자 또는 게임쪽으로 개발을 전향한다면, 내 미래의 나에게 이렇게 개발을 했으면 좋겟다 라는 말을 전해주고 싶다. 제일 생각이 많이들었던 부분이라고 한다면, 어떻게 더 낳은 개발자로 성장하려면, 기본적으로 그러한 성격을 가지고 있어야하나라는 생각과 어떤게 안된다고 했을때, 그 호기심을 가지고 문제를 해결하려는 지속성이 필요한건가 라는 질문을 많이 던졌었다. 그러면서 결국 자신에게 속이려고 하지말자라는 생각을 하게 되었다.

또 이제 문명에 관한 시드 마이어의 인터뷰를 찾아보았는데, 아래와 같은 내용을 설명을 해주었다. 조금 클래식 하지만 뭔가 기본적인 지식이라고 하지만 근본있는 이야기 같아 보인다.

  • zone-based game
  • interactive story
  • turn-based game vs real-time game

그리고 2 번째의 이야기 처럼, The valley of dispair rule 를 설명하였다. The valley of dispair Rule, which is the double it or cut it in half rule. If you’re gonna make a change make it dramatic.

Look at the Youtube Below

뭔가 감상문이라고 하기에는 조금 내가 마음에 들었던 부분만 이야기한것 같지만, 지하철에서 뛰엄 뛰엄 읽느라 많이 생각들을 잃어버렸던것 같다.

The Greenhouse At The End of The Earth

The Greenhouse at the end of the earth (지구 끝의 온실)

이 책을 읽었던 동기는 사실 잘 기억이 나지 않는다. 뭔가, 전에 소설 책을 읽으면 재밌겠지. 소설이라는 책에도 감정이 충분이 녹아들을수 있겠다? 라는 기대감에 리디북스에서 전자책을 사서 읽게 시작됬다.

이 책같은 경우 SF 판타지 종류의 책이다. 사실 이 책의 Genre 를 정확하게 알게된게, 아마 마지막에 다 읽고 나서, 사람들의 리뷰를 봤을때 쯤이였다. 이 책의 내용은 지구가 ‘Dust’ 로 인해, 멸망 한 후의 이야기를 다루고 있다. 이 시점에 두가지 종류인 사람이 있었던것 같다. 하나는 Dust 에 대해서 immune 한 사람과 그렇지 못한 사람, 그리고 dust 에 immune 한 사람들은 dust 밖에 살아도 immune 한 정도에 따라서, 살수 있느냐 없느냐를 결정할수 있었다. 그리고 그들은 주로 실험체로 사용되었기 때문에 dom 에서 그들을 잡기 위해서, 사냥꾼이 존재 했었던 배경이다.

이 책이 재미있었던 부분은 나는 주인공 시점이 계속 바뀌었던 부분이다. 또 그 주인공 시점이 바뀔때 마다 장면들도 바뀌고, 주인공들의 이야기도 섹션마다 이상하게? 바뀌어서 재밌었던 부분이긴 했다. 예를 들어서 식물학자인 아영은 자기의 어렸을때, 그 나중에 지수라는 분의 창고에서 보았던 그 모스바나(식물)의 장면과 나오미라는 분이 어떻게 해서 모스바나를 통해서 더스트에 살아남았던 장면 스위칭이 되게 해깔리게 했지만, 항상 그런 스위칭은 내가 그 전 내용을 봤을때를 다시 remind 시켜주는게 좋았다.

사실 내용은 별로 다루고 싶지 않다. 그리고, 별로 다루지도 못하고. 어떤 이야기가 지나갔는지는 책을 읽어봐야 느끼는 그런게 필요하다. 특히나 이 책은 뭔가 생각보다 SF 치고, 감정이 깊다. 특히나 점점 사이보그로 변해가는 레이첼이 지수의 이기심으로 생겨난 감정들이, 레이첼한테 없었던게 아니라, 애초에 이기심이 아닌 사람의 생명을 구해주고 싶었던 지수로 인해서 생겨난거라는게 인상 깊긴 했었다.

그리고, 나오미와 아마나? 의 떠돌이 같은 삶은 뭔가 내 미국의 삶을 보여주기도 하는것 같아 보였다. 버티기 위해서, 뭔가 이뤄내고 싶다는 마음보다도, 그냥 일단 존버로 버텨보자 그런 희망을 위해서 내가 미국에서 있었던 그런 짧은 순간들이 주로 생각이 났었다.

내가 책을 읽었던 순간 순간 중에, 아영이 나오미와 아마나의 기억이 가짜가 아니라는걸 증명을 하려고 했을때? 분명 우여곡절이 있었지만 진짜 증명해나가는 모습들이 인상깊었고. 또한, 그 우여곡절, 아영이 자기가 살았던 도시로 돌아가 지수라는 사람에대해서 힌트를 찾으러 갔을때, 어렸을때 그 감성을 다시 찾아보려고 노력했을때, 아무것도 못찾아서 허탈한 기분들이 느껴졌었을때 쯤 연구소에서 모스바나 식물의 Origin 힌트를 찾았을때의 그 기쁨 및 뭔가 그리운것을 되찾으려고 했던것들이 마음에 와닿았던것 같다.

역시나 어떤 사람에 대해서 공감을 어느 정도 하느냐는 참 쉽지 않다. 내가 많이 노력해봐도 그건 natural 하게 되는것이지 관심이 있고 없고의 문제를 이미 넘어선 시점에서 일어나는것 같다. 요즘 감정이 사실 굳혀져 간다는게 느껴진다. 행복한게 행복한거지만 계속되며, 딱히 슬픈것을 못느끼는것 같다. 다만, 답답한 마음은 존재하지만, 내가 할수 없는 일이기때문에 포기하는 속도가 더 빨라져. 이제 될대로 되라 이렇게 살아가고 있다. 내 인생에서 중요한것이 무엇일까? 또는 Goal 이 뭘까? 라고 물어봐도, 한번 씨게 다쳤기 때문에, 아직 잘몰라, 인생 어떻게 될지 어떻게 알아? 라는 질문으로 아무것도 안하고 있는것 같다. 인생이 어떻게 열릴지 모르지만, 내 나름 노력해야되고 계획도 세워야되고, 내가 내 스스로를 사랑해야되는 그런 순간순간들이 점차점차 보이기 시작한다.

그리고 나는 계속 책읽기를 시작할거다. 내가 미래의 아들이나 딸에게, 저녁에 동화책을 읽어줬을때, 그 아이들이 행복한 추억으로 잘남겨질때까지 계속 이어가고 싶다.

Pagination


© 2021. All rights reserved.