ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • C언어로 알아보는 객체 지향 프로그래밍: 가상 함수 테이블(vtable)과 메서드 구현
    Programming/TIL 2024. 11. 23. 22:43

    C언어로 구조체와 함수 포인터를 이용해 객체 지향 프로그래밍을 구현하는 방법을 설명합니다. 객체 지향 언어의 메서드를 모방하고, 가상 함수 테이블(vtable)을 이용한 동적 함수 호출을 구현하는 과정을 다룹니다. C++의 정적/동적 바인딩, Java의 추상 메서드 등 다양한 언어의 메서드/인터페이스 구현 방식과 비교하며 객체 지향의 원리를 설명합니다.

    개요

    요전에 제어의 역전(Inversion of Control) 이해하기 1부: 객체 지향 이전의 C언어 구현이라는 글을 썼습니다.

    2부는 "객체 지향 프로그래밍에서의 제어의 역전"을 설명할 계획이었는데요.
    글의 내용을 고민하던 와중, 이걸 객체 지향 언어 대신 C언어로 설명해 볼 순 없을까?라는 생각이 들었습니다.

    그런데 글을 쓰다 보니 내용이 점점 산으로 가고... 또 길어지더라고요.

    그래서 아예 C언어로 객체 지향 프로그래밍을 구현하는 부분을 별도의 글로 작성하게 되었습니다.

    이 글은 객체 지향의 모든 개념을 다루는 글은 아닙니다.

    구조체와 함수 포인터로 객체 지향 언어의 메서드를 모방하고, 가상 함수 테이블을 이용한 동적 함수 호출을 구현하는 과정을 다룹니다.
    그리고 그 과정에서 알게 된 C언어와 객체 지향 프로그래밍 언어의 차이점을 열심히 적어보았습니다.

    오개념이나 서술이 이상한 부분이 있으면 피드백 남겨주세요. 바로 반영하겠습니다.

    C언어로 객체 지향 프로그래밍을?

    C언어는 객체 지향 프로그래밍 언어가 아닙니다. 흔히 절차 지향 프로그래밍 언어라고 하죠.
    그렇다면 C언어로 객체 지향 프로그래밍은 불가능한 걸까요?

    참 어려운 질문입니다.
    답을 위해서는 "객체 지향 프로그래밍이 무엇인지"부터 정의해야 할 텐데요. 명확한 정의를 찾기가 쉽지 않았습니다.

    대신 객체 지향 프로그래밍 언어에 어떤 것들이 있는지는 쉽게 찾을 수 있었습니다.
    C++과 Java가 대표적인 객체 지향 프로그래밍 언어라고 하더군요.

    그럼 객체 지향 언어에서 사용되는 개념을 C언어로 어찌저찌 비슷하게 구현해 본다면,
    C언어로 객체 지향 프로그래밍을 했다고 말할 수 있지 않을까요?

    이런 느슨한 정의와 함께, C언어로 객체 지향 프로그래밍을 해보겠습니다.

    객체 지향 프로그래밍 시작하기

    객체 지향 프로그래밍을 하려면, 프로그램을 만들어야 합니다.

    저는 게임 프로그램을 하나 만들어보겠습니다.
    장르는 총 게임으로 해볼까요? 2D 멀티플레이어 슈팅 게임이라면 인기가 좀 있을 것 같습니다.

    멀티 플레이어 게임이니 여러 플레이어의 상태를 저장하고 관리해야겠죠.

    먼저 플레이어 상태를 저장하는 구조체를 정의하겠습니다.

    struct player
    {
        int             id;       // 플레이어 식별자
        struct point    position; // 현 위치
        struct vector2d angle;    // 바라보는 방향
        int             health;   // 체력
    };

    슈팅 게임이면 플레이어가 총을 쏴야겠죠.
    총을 쏘는 함수도 하나 만들어 주겠습니다.

    void player_shoot(struct player *p)
    {
        // 플레이어가 바라보는 방향을 향해 총을 쏜다.
        // 맞은 플레이어는 체력이 깎인다.
    }

    총만 쏠 수 있으면 지루하니, 수류탄을 던지는 기능도 추가해 보겠습니다.
    왼쪽 클릭으로 총을 쏘고, 오른쪽 클릭으로 수류탄을 던지면 괜찮겠네요.

    void player_throw_grenade(struct player *p)
    {
        // 플레이어가 바라보는 방향으로 수류탄을 던진다.
        // 수류탄 범위 안에 있는 플레이어는 체력이 깎인다.
    }

    🤷 아니 총알과 게임 맵과 다른 플레이어 간 상호 작용은 어떻게 구현했나요? 인자로 받지도 않았는데요?
    ...넘어가 주세요. 그냥 총을 쏘나보다~ 라고 생각해 주시기 바랍니다.

    게임 루프는 다음과 같이 구현할 수 있습니다.
    event를 받아 플레이어가 취할 행동을 결정하고 함수를 호출합니다.

    /* game_loop.c */
    while (game->state != GAME_DONE)
    {
        if (get_event(event)) 
        {
            struct player *p = players[event->src];
            switch (event->type)
            {
                case LEFT_CLICK:
                    player_shoot(p);
                    break;
                case RIGHT_CLICK:
                    player_throw_grenade(p);
                    break;
                case KEY_W: case KEY_A: case KEY_S: case KEY_D:
                    // ...
                case MOUSE_MOVE:
                    // ...
            }
        }
        update_game_state(game);
        render_game(game);
    }

    함수 포인터로 메서드 구현하기

    아직까진 코드가 별로 객체 지향답지 않습니다.

    객체 지향 언어의 객체, 클래스는 메서드를 가집니다.
    클래스에는 객체의 상태 데이터뿐 아니라 객체와 관련된 동작(함수)도 함께 정의되어 있죠.

    플레이어 객체에 함수를 넣어보겠습니다.
    구조체에 함수 포인터를 추가하면 어떨까요?

    struct player
    {
        int             id;
        struct point    position;
        struct vector2d angle;
        int             health;
        void (*shoot)(struct player *this);
        void (*throw_grenade)(struct player *this);
    };

    이제 구조체 정의부만 보고도 "player는 shoot과 throw_grenade 함수를 사용할 수 있구나!"라고 바로 파악이 가능합니다.
    코드를 읽는 사람이 객체의 구성과 동작을 한눈에 파악할 수 있게 된 거죠.

    이제 게임 루프는 플레이어 객체 안에 있는 함수를 호출합니다.

    struct player *p = players[event->src];
    switch (event->type)
    {
        case LEFT_CLICK:
            p->shoot(p);
            break;
        case RIGHT_CLICK:
            p->throw_grenade(p);
            break;
        // ...
    }

    p->shoot(p) 라는 표현은 조금 어색해 보입니다. 왜 호출 할 때 자기 자신의 포인터를 인자로 넘겨줘야 할까요?

    이유는 간단합니다. 함수가 객체 내부의 데이터(position, angle 등)에 접근하려면 객체의 메모리 주소를 알아야 하니까요.

    void player_shoot(struct player *this)
    {
        struct point shooter_position = this->position;
        // ...
    }

    C++같은 객체 지향 언어에서는 그럴 필요가 없습니다. p.shoot()으로 간편하게 호출할 수 있죠.

    class Player 
    {
        void shoot() 
        {
            // 총을 쏜다.
        }
        void throw_grenade() 
        {
            // 수류탄을 던진다.
        }
    };
    
    switch (event->type)
    {
        case LEFT_CLICK:
            player.shoot();
            break;
        case RIGHT_CLICK:
            player.throw_grenade();
            break;
        // ...
    }

    사실 C++도 내부 동작은 C와 비슷합니다.

    메서드의 첫 번째 인자는 항상 객체를 가리키는 this 포인터가 들어가게끔,
    메서드 호출 시 첫 번째 인자는 항상 자기 자신이 들어가게끔 컴파일러가 변환해줍니다.

    메서드 void shoot()void shoot(Player *this)
    메서드 호출 player.shoot()player.shoot(&player)로 변환되는 거죠.

    그래서 이런 코드도 가능합니다.

    class MyClass 
    {
        int data;
    
    public:
        void setData(int data) 
        {
            this->data = data;
        }
    };

    setData 함수는 인자 하나를 받는 것 같지만, 실제로는 thisdata 두 개의 인자를 받습니다.
    그러니 this->datadata 필드를 가리키는 거죠.

    잠깐 넘어갔지만, 구조체에 들어있는 함수 포인터는 생성 시 실제 함수를 가리키도록 초기화를 해줘야 합니다.
    함수 포인터도 포인터니까요.

    객체 지향 언어였다면 이런 작업을 자동으로 해줬을 겁니다. 정적으로든 동적으로든 말이죠.

    struct player *new_player(int id)
    {
        struct player *p = malloc(sizeof(struct player));
        p->id            = id;
        p->shoot         = player_shoot;
        p->throw_grenade = player_throw_grenade;
        return p;
    }

    생성자 구현해보기

    new_player 함수는 객체 지향 언어의 생성자와 비슷한 역할을 하고 있습니다.
    그러면 new_player 함수도 함수 포인터로 객체 안에 넣어줄 수 있을까요?

    객체 안에 있는 함수 포인터를 메서드처럼 호출하려면 다음 두 가지 조건이 선행되어야 합니다.

    1. 미리 객체를 생성(메모리 할당)해야 하고
    2. 함수 포인터를 초기화해 실제 함수를 가리키도록 해야 합니다.

    new_player 함수는 이 두 가지 선행 조건을 수행하는 역할을 합니다. 따라서 객체 안에서 정의될 수 없습니다.

    물론 객체 지향 언어에서의 생성자는 객체를 생성하는 함수가 아닙니다. 객체를 초기화하는 함수죠.

    현재 new_player 함수가 수행하는 일을 정리해 보면 다음과 같습니다.

    1. struct player 객체 메모리 할당
    2. id 필드 초기화
    3. shoot 함수와 throw_grenade 함수 포인터 초기화
    4. 생성된 객체 반환

    1번과 4번은 객체 생성자의 역할이 아닙니다.
    2번과 3번은 객체 내부의 함수 포인터로 수행할 수 있는 작업이고, 이를 생성자 메서드로 분리할 수 있습니다.

    1번과 4번 작업, 그리고 생성자 함수 포인터를 초기화하고 자동으로 호출하는 것까지 매크로로 만든다면 어떨까요?

    #define NEW(type, ...)                                                         \
        ({                                                                         \
            type *tmp_##type = malloc(sizeof(type));                               \
            if (tmp_##type)                                                        \
            {                                                                      \
                tmp_##type->constructor = type##_constructor;                      \
                tmp_##type->constructor(tmp_##type, ##__VA_ARGS__);                \
            }                                                                      \
            tmp_##type;                                                            \
        })
    
    typedef struct player
    {
        int             id;
        struct point    position;
        struct vector2d angle;
        int             health;
        void (*shoot)(struct player *this);
        void (*throw_grenade)(struct player *this);
        void (*constructor)(struct player *this, int id);
    } Player;
    
    void Player_constructor(struct player *this, int id)
    {
        this->id            = id;
        this->shoot         = player_shoot;
        this->throw_grenade = player_throw_grenade;
    }

    이제 Player 객체 생성을 NEW(Player, 1)로 간단하게 할 수 있습니다.

    int main(void)
    {
        Player *player = NEW(Player, 1);
    }

    이런 게 가능하지 않을까? 하고 짜본 코드지만 효용성에 대해서는 조금 의문이 듭니다.
    너무 복잡하니 그냥 new_player 함수를 사용하기로 하겠습니다.

    가상 함수 테이블로 동적 함수 호출하기

    지금도 충분히 재밌는 게임처럼 보이지만, 뭔가 조금 아쉽습니다.
    좀 더 역동적이고 하이퍼한, 게임 플레이가 다양한 게임을 만들고 싶은데요.

    캐릭터 시스템을 도입하면 어떨까요? 개성이 뚜렷한 여러 캐릭터가 전투를 벌이는 거죠.
    캐릭터마다 특색있는 기본 공격과 특수 능력을 가지고 대결하면 게임이 더 다채로워질 것 같습니다.

    창의력을 최대한 발휘해 4가지 캐릭터를 구상해보았습니다.
    Hero Watch Characters

    구상은 끝났으니 이제 구현을 해보겠습니다.

    캐릭터 파일을 따로 만들어 각 캐릭터의 기본 공격과 특수 능력을 구현합니다.

    /* character.h */
    enum character {
        AZIRAL,
        CHRIS_KYLE,
        WINDOW_MAKER,
        RAPHAEL,
    };
    
    void aziral_basic_attack(struct player *this);
    void aziral_special_ability(struct player *this);
    // ...

    플레이어 구조체에 캐릭터 정보를 추가하고, 캐릭터에 따라 기본 공격과 특수 능력 함수를 호출하도록 합니다.

    /* player.c */
    
    struct player {
        int id;
        struct point position;
        struct vector2d angle;
        int health;
        enum character character;
        void (*basic_attack)(struct player *this);
        void (*special_ability)(struct player *this);
    };
    
    void player_basic_attack(struct player *this)
    {
        switch (this->character)
        {
            case AZIRAL:
                aziral_basic_attack(this);
                break;
            case CHRIS_KYLE:
                chris_kyle_basic_attack(this);
                break;
            case WINDOW_MAKER:
                window_maker_basic_attack(this);
                break;
            case RAPHAEL:
                raphael_basic_attack(this);
                break;
        }
    }
    
    void player_special_ability(struct player *this)
    {
        switch (this->character)
        {
            case AZIRAL:
                aziral_special_ability(this);
                break;
            case CHRIS_KYLE:
                chris_kyle_special_ability(this);
                break;
            case WINDOW_MAKER:
                window_maker_special_ability(this);
                break;
            case RAPHAEL:
                raphael_special_ability(this);
                break;
        }
    }
    
    struct player *new_player(int id, enum character character)
    {
        struct player *p = malloc(sizeof(struct player));
        p->id = id;
        p->character = character;
        p->basic_attack = player_basic_attack;
        p->special_ability = player_special_ability;
        return p;
    }

    이렇게 분류하는 방식은 코드 결합도가 너무 높습니다.

    캐릭터를 추가할 때마다 player.c 파일을 수정해야 하고, 캐릭터가 늘어날수록 코드 길이도 길어집니다.

    player 객체 속 basic_attack은 함수 포인터입니다.
    포인터기 때문에 어떤 주소든 들어갈 수 있고, 이는 프로그램 실행 도중 변경이 가능합니다.

    앞으로 이런 함수 포인터를 가상 함수라고 부르겠습니다.
    구체적으로 어떤 함수를 가리킬지는 모르지만, 함수를 가리키는 포인터라는 의미에서 가상 함수인 것이죠.

    basic_attack 가상 함수가 굳이 player_basic_attack 함수를 가리킬 필요는 없습니다.

    aziral_basic_attack, chris_kyle_basic_attack 처럼 캐릭터 별로 구현한 함수를 가르키도록 하면 되죠.

    /* character.c */
    (void *) (*character_basic_attacks[])(struct player *) = 
    {
        [AZIRAL]       = aziral_basic_attack,
        [CHRIS_KYLE]   = chris_kyle_basic_attack,
        [WINDOW_MAKER] = window_maker_basic_attack,
        [RAPHAEL]      = raphael_basic_attack,
    };
    
    (void *) (*character_special_abilities[])(struct player *) = 
    {
        [AZIRAL]       = aziral_special_ability,
        [CHRIS_KYLE]   = chris_kyle_special_ability,
        [WINDOW_MAKER] = window_maker_special_ability,
        [RAPHAEL]      = raphael_special_ability,
    };
    
    void change_character(struct player *p, enum character character)
    {
        p->character = character;
        p->basic_attack = character_basic_attacks[character];
        p->special_ability = character_special_abilities[character];
    }

    change_character 함수를 통해 객체 생성 시 캐릭터 생성, 캐릭터 변경이 가능해졌습니다.

    basic_attackspecial_ability를 따로 두어서 코드 중복이 발생하니 이참에 구조체로 묶어놓겠습니다.

    struct character_operations {
        void (*basic_attack)(struct player *this);
        void (*special_ability)(struct player *this);
    };
    
    struct player {
        int id;
        struct point position;
        struct vector2d angle;
        int health;
        enum character character;
        struct character_operations *ops;
    };

    현재 구조체 character_operations는 두 개의 가상 함수를 가지고 있습니다.
    가상 함수를 담은 구조체인 character_operations를 가상 함수 테이블(vtable/vftable)이라고 부르겠습니다.

    이제 게임 루프는 가상 함수 테이블 ops에서 basic_attackspecial_ability 함수를 찾아 호출합니다.

    /* game_loop.c */
    struct player *p = players[event->src];
    switch (event->type)
    {
        case LEFT_CLICK:
            p->ops->basic_attack(p);
            break;
        case RIGHT_CLICK:
            p->ops->special_ability(p);
            break;
        // ...
    }

    캐릭터는 각자 가상 함수를 구현한 구조체를 가지고 있고요.

    /* characters.c */
    struct character_operations *character_ops[] = {
        [AZIRAL] = &aziral_ops,
        [CHRIS_KYLE] = &chris_kyle_ops,
        [WINDOW_MAKER] = &window_maker_ops,
        [RAPHAEL] = &raphael_ops,
    };
    
    struct character_operations aziral_ops = {
        .basic_attack = aziral_basic_attack,
        .special_ability = aziral_special_ability,
    };
    
    void change_character(struct player *p, enum character character)
    {
        p->character = character;
        p->ops = character_ops[character]
    }

    이제 캐릭터를 추가하거나 수정할 때 character.c 파일만 수정하면 됩니다.
    각 캐릭터가 자신만의 가상 함수 테이블을 가지고 있기 때문에 모든 동작이 한 곳에서 관리됩니다.

    새로운 캐릭터를 추가하는 과정도 매우 단순해졌습니다.
    코드를 처음 보는 사람도 character_operations 구조체를 보고 어떤 함수를 구현해야 하는지 쉽게 알 수 있죠.

    실행 도중 캐릭터 변경도 너무 간단합니다. 가상 함수 테이블만 바꿔주면 바로 변경이 가능합니다.

    더 발전시킬 여지나 디테일 개선 사항은 많이 남아 있겠지만, 게임 개발은 이 정도만 하겠습니다.

    실제 사용 사례

    character_operations 구조체 가상 함수 테이블로 프로그램의 코드 결합도를 낮추고 확장성을 높일 수 있었습니다.
    그런데 이런 객체 지향적인 C언어 패턴을 실제로 사용하는 곳이 있을까요?

    한번 알아보기 위해 클로드에게 "인류사에서 가장 위대한 오픈소스 C언어 프로젝트가 무엇인지" 물어보았습니다.

    C언어 GOAT

    클로드는 Linux 커널이라고 하네요.

    리눅스 커널의 파일 잠금 시스템을 살펴보겠습니다.

    struct file_lock_operations {
        void (*fl_copy_lock)(struct file_lock *, struct file_lock *);
        void (*fl_release_private)(struct file_lock *);
    };
    
    struct file_lock {
        const struct file_lock_operations *fl_ops;       /* Callbacks for filesystems */
        const struct lock_manager_operations *fl_lmops;  /* Callbacks for lockmanagers */
        // ...
    };

    여기서 file_lock_operations 구조체는 가상 함수 테이블 역할을 합니다.
    이전에 만든 character_operations 구조체와 비슷하죠.

    각 파일 시스템은 자신만의 고유한 잠금 구현을 가질 수 있습니다.

    /* linux/fs/ceph/locks.c */
    static const struct file_lock_operations ceph_fl_lock_ops = {
        .fl_copy_lock = ceph_fl_copy_lock,
        .fl_release_private = ceph_fl_release_lock,
    };
    
    /* linux/fs/nfs/nfs4state.c */
    static const struct file_lock_operations nfs4_fl_lock_ops = {
        .fl_copy_lock = nfs4_fl_copy_lock,
        .fl_release_private = nfs4_fl_release_lock,
    };
    
    /* linux/fs/afs/flock.c */
    static const struct file_lock_operations afs_lock_ops = {
        .fl_copy_lock        = afs_fl_copy_lock,
        .fl_release_private    = afs_fl_release_private,
    };

    네트워크 프로토콜 코드에도 이런 패턴이 사용됩니다.

    struct proto_ops {
        int        family;
        struct module    *owner;
        int        (*release)   (struct socket *sock);
        int        (*bind)         (struct socket *sock,
                          struct sockaddr *myaddr,
                          int sockaddr_len);
        int        (*connect)   (struct socket *sock,
                          struct sockaddr *vaddr,
                          int sockaddr_len, int flags);
        int        (*socketpair)(struct socket *sock1,
                          struct socket *sock2);
        int        (*accept)    (struct socket *sock,
                          struct socket *newsock,
                          struct proto_accept_arg *arg);
        int        (*getname)   (struct socket *sock,
                          struct sockaddr *addr,
                          int peer);
        // ...
    };

    그 외에도 이러한 패턴은 커널에서 매우 광범위하게 사용되고 있습니다.
    github 레포에서 _operations 혹은 _ops로 검색해 보면 더 많은 예시를 찾을 수 있습니다.

    객체 지향 언어의 가상 함수와 가상 함수 테이블

    다른 객체지향 언어에서 가상 함수와 가상 함수 테이블은 어떻게 구현되는 걸까요?

    C++의 메서드는 객체 안에 있는 것 처럼보이지만, 사실은 객체 밖에서 정의된 함수입니다.

    즉 아래와 같은 C++ 코드는

    class Player 
    {
        int id;
    
    public:
        void shoot() 
        {
            // 총을 쏜다.
        }
    };
    
    int main() 
    {
        Player player;
        player.shoot();
    }

    아래와 같은 C 코드처럼 변환됩니다. 처음 작성했던 코드와 비슷하죠.

    struct Player 
    {
        int id;
    };
    
    void Player_shoot(struct Player *this) 
    {
        // 총을 쏜다.
    }
    
    int main() 
    {
        struct Player player;
        Player_shoot(&player);
    }

    이는 C++이 메서드를 정적 바인딩(static binding)하기 때문입니다.

    정적 바인딩은 호출할 함수를 컴파일 시간에 결정합니다.

    반면 이전에 우리가 구현했던 함수 포인터를 이용한 메서드는 동적 바인딩(dynamic binding)입니다.
    실제 어떤 함수를 실행할지는 미리 알 수 없고, 실행 시간에 결정되니까요.

    함수 포인터를 사용해서 함수를 호출할 경우, 포인터 주소를 한번 거쳐서 실행되기 때문에 추가 비용이 발생합니다.
    실행 시간이 조금 더 드는 거죠.

    정적 바인딩을 활용하면 이런 오버헤드를 줄이고 성능상 이점을 얻을 수 있습니다.

    virtual 키워드를 이용하면 가상 함수를 이용한 동적 바인딩(dynamic binding)이 가능합니다.
    함수 포인터를 이용했을 때와 마찬가지로 말이죠.

    class Player
    {
    public:
        virtual void basicAttack() = 0;
        virtual void specialAbility() = 0;
    };

    여기서 = 0순수 가상 함수(pure virtual function)를 의미합니다.
    순수 가상 함수는 구현이 없는 함수입니다. Player 클래스를 상속하는 파생 클래스에서 반드시 구현해야 합니다.

    우리가 만든 게임에서 구현했던 함수 포인터 역시 순수 가상 함수라고 볼 수 있습니다.
    처음에 구현된 함수가 없고, 객체를 생성할 때 적절한 함수로 초기화해야 하기 때문이죠.

    C++에서 클래스에 가상 함수를 추가하면, 컴파일러가 알아서 가상 함수 테이블(vtable)이 들어갈 자리를 만들어줍니다.

    이를 상속하는 파생 클래스는 가상 함수 테이블에 자신의 함수를 넣어두죠.

    class Azrial : public Player
    {
    public:
        void basicAttack() override
        {
            std::cout << "Azrial basic attack" << std::endl;
        }
    
        void specialAbility() override
        {
            std::cout << "Azrial special ability" << std::endl;
        }
    };
    
    
    int main()
    {
        Player *player = new Azrial();
        player->basicAttack();
        player->specialAbility();
    }

    전에 봤던 코드랑 비슷하죠?

    한번 최적화를 끄고 컴파일 한 다음, objdump로 어셈블리 코드를 살펴보겠습니다.

    basicAttack()을 호출하는 부분입니다.

    11b6:    48 8b 45 e8              mov    -0x18(%rbp),%rax  # rax에 player 포인터 저장
    11ba:    48 8b 00                 mov    (%rax),%rax       # rax에 vtable 주소 저장
    11bd:    48 8b 10                 mov    (%rax),%rdx       # rdx에 basicAttack 함수 주소, vtable[0] 저장
    11c0:    48 8b 45 e8              mov    -0x18(%rbp),%rax  # rax에 player 포인터 저장
    11c4:    48 89 c7                 mov    %rax,%rdi         # rdi(첫 번째 인자) this 포인터로 사용
    11c7:    ff d2                    call   *%rdx             # basicAttack 함수 호출

    specialAbility()을 호출하는 부분입니다.

    11c9:    48 8b 45 e8              mov    -0x18(%rbp),%rax  # rax에 player 포인터 저장
    11cd:    48 8b 00                 mov    (%rax),%rax       # rax에 vtable 주소 저장
    11d0:    48 83 c0 08              add    $0x8,%rax         # rax에 + 8, vtable[1] 저장
    11d4:    48 8b 10                 mov    (%rax),%rdx       # rdx에 specialAbility 함수 주소 저장
    11d7:    48 8b 45 e8              mov    -0x18(%rbp),%rax  # rax에 player 포인터 저장
    11db:    48 89 c7                 mov    %rax,%rdi         # rdi(첫 번째 인자) this 포인터로 사용
    11de:    ff d2                    call   *%rdx             # specialAbility 함수 호출

    Java는 모든 함수가 기본적으로 가상 함수입니다. vtable을 사용해 가상 함수를 호출합니다.

    따라서 이런 식으로 코드를 작성할 수 있습니다.
    상속을 하고 함수를 덮어 써 버릴 수 있쬬.

    class Player {
        void basicAttack() {
            System.out.println("Player basic attack");
        }
        void specialAttack() {
            System.out.println("Player special attack");
        }
    }
    
    class Azrial extends Player {
        @Override
        public void basicAttack() {
            System.out.println("Azrial basic attack");
        }
    
        @Override
        public void specialAttack() {
            System.out.println("Azrial special attack");
        }
    }
    
    public class Test2 {
        public static void main(String[] args) {
            Player player = new Azrial();
            player.basicAttack();
            player.specialAttack();
        }
    }

    결과

    Azrial basic attack
    Azrial special attack

    순수 가상 함수, 자바에서는 추상 메서드를 사용하려면 interface나 abstract class를 사용해야 합니다.
    마찬가지로 파생 클래스에서 함수를 구현하는 걸 강제할 수 있습니다.

    interface Player {
        void basicAttack();
        void specialAttack();
    }
    
    class Azrial implements Player {
        @Override
        public void basicAttack() {
            System.out.println("Azrial basic attack");
        }
    
        @Override
        public void specialAttack() {
            System.out.println("Azrial special attack");
        }
    }
    
    public class Test {
        public static void main(String[] args) {
            Player player = new Azrial();
            player.basicAttack();
            player.specialAttack();
        }
    }

    자바스크립트파이썬 같은 언어는 조금 다릅니다.
    동적인 특성 때문에 컴파일 시간에 가상 함수 테이블을 만들 수 없습니다.
    대신 해시맵과 비슷한 방식으로 함수를 관계망(프로토타입 체인, MRO)안에서 열심히 찾아서 호출하는 방식입니다.

    Go의 인터페이스는 가상 함수 테이블을 사용하긴 하되, 조금 특이합니다.
    가상 함수 테이블을 컴파일 시간이 아닌 실행 시간에 만드는 방식을 사용하는데요.
    그래서 상속 관계를 명시적으로 선언하지 않아도 메서드 호출이 가능하다는 장점이 있습니다.

    마치며

    You can write object-oriented code (useful for filesystems etc) in C, without the crap that is C++.
    - Linus Torvalds

    간단한 게임을 만들어가며 C언어로 객체 지향적인 코드를 작성해 보았습니다.
    구조체와 함수 포인터를 이용해 메서드와 비슷한 호출 방식을 구현하고, 가상 함수 테이블을 통해 필요한 함수를 동적으로 호출했습니다.
    여러 객체 지향 언어에서 메서드를 어떻게 호출하는지도 살펴보았고요.

    글 쓰면서 정말 재밌었습니다.

    C언어와 다른 객체 지향 언어를 비교해 보며 각종 문법과 규칙들이 무엇을 위한 것인지, 왜 그렇게 설계되었는지 조금은 이해가 되기 시작했거든요.

    언어 간 문법과 편의성의 차이가 있어도, 모든 걸 관통하는 좋은 코드 설계 원칙이란 게 존재하는 듯한 느낌도 받았습니다.
    제가 작성한 총 게임 코드가 좋은 코드인지는 잘 모르겠지만, 좋은 코드가 무엇인지에 대해 좀 더 생각 해보는 기회가 되었습니다.

    이번 글은 원래 제어의 역전을 설명하기 위해 쓰기 시작했던 글이라서, 일부러 상속에 관한 내용은 생략했습니다.
    C언어로도 구조체의 특성을 이용해서 상속을 구현할 수 있고, 이를 또 다른 언어와 비교해 보면 좋은 공부가 될 것 같은데요.
    나중에 기회가 되면 상속에 대해서도 비슷하게 다뤄보겠습니다.

    글이 도움이 되었길 바랍니다.

    감사합니다.

    Reference

    댓글