본문 바로가기

3.구현/C or C++

C로 객체지향 흉내내기2

거의 "Object Oriented Programming in ANSI C"내용을 번역하는 수준이군요. 사실 중간에 내용은 임의대로 제 마음대로 이해한 내용으로 채워넣은 경우도 있으니, 혹시 의문이 들거나 이게 아니다 싶으면 원문을 참고하세요.

이번에는 동적 링크와 제너릭 함수입니다. 동적 링크는 정적링크와는 다르게 실행시점에 실행되는 코드가 결정된다. 제너릭 함수란 어떤 타입이는 모두 실행할 수 있는 함수이다.

먼처 C++과 비슷하게 생성자와 소멸자를 보고 메소드와 메시지 클래스 등에 대해서 다뤄보겠다.

작성일: 2009.07.26 (http://ospace.tistory.com/), ospace114@empal.com

생성자와 소멸자

C++을 해봤으면 생성자와 소멸자는 잘 알 것이다. 잘 모르게 구글샘에게 물어보면 친절하게(?) 말해준다. 앞에서 new()와 delete()는 C++의 new와 delete와 같다. 리소스를 할당하고 리소스를 반환한다.

앞에서 new()와 delete()는

new()는 생성할 객체가 무엇인지 알고 있다. 이는 인자로 넘겨지는 객체 디스크립터가 인수로 알수있다. 이를 통해서 할당할 객체에 대한 정보를 얻는다. 이 정보를 바탕으로 if문 체인으로 각각 생성을 처리한다. 결점은 new()가 각 타입별로 처리하는 코드가 추가되어 한다.

delete()에는 큰 문제가 있다. 각 객체 타입에 따라 동작이 틀려지기 때문이다. 스트링인 경우 할당된 텍스트 버퍼를 해제해줘야한다. set역시 element에 대한 참조를 저장하기 위해서 여러 메모리 청크를 사용한다.

delete()에 다른 인자가 필요하다. 타입 디스크립터나 이를 제거해줄 함수가 필요하다. 그러나 이런 접근은 사용하기 힘들고 에러가 많다. 좀더 제너릭하고 세련된 방법으로 각 객체가 자신의 자원을 소멸하는 방법을 알고 있으면 된다. 각 객체마다 이런 기능을 갖는 함수를 두면된다. 이런 함수를 소멸자라 한다.

new()도 문제가 있다. 객체 생성 책임이 있고, 반환된 포인터는 delete()로 넘겨진다. new()은 반드시 각 객체마다 소멸 정보를 가지고 있어야 한다. 간단한 방법으로 new()에 넘겨지는 타입 디스크립터에 소멸자 부분에 대한 포인터를 만들면 된다. 그래서 필요한 정보를 다음과 같이 선언한다.

struct type {
    size_t size; /* size of an object */
    void (* dtor) (void *); /* destructor */
};
struct String {
    char * text; /* dynamic string */
    const void * destroy; /* locate destructor */
};
struct Set {
    ... information ...
    const void * destroy; /* locate destructor */
};

또 다른 문제로 누군가 타입 디스크립터에서 dtor 소멸자 포인터를 새로운 객체에 destroy로 복사하고 각 객체 클래스에 다른 위치로 복사하려고할때 이다.

초기화는 new()의 작업으로 다른 타입은 다른 작업이 필요하게 된다. new()는 아마도 다른 타입에 대한 다른 인자를 필요로 할 것이다.

new(Set); /* make a set */
new(String, "text"); /* make a string */

초기화하기 위해 특정 타입에 대한 함수를 생성자라고 한다. 생성자와 소멸자가 타입에 의존적이고 변하지 않기에 타입 디스크립터 부분으로서 양쪽을 new()에 넘겨줄 수 있다.

이제 생성자와 소멸자가 객체 자신에 대한 메모리 획득과 해제에 대한 책임은 없다. 이는 new()와 delete()가 한다. 생성자는 new()에 의해서 호출되고 단지 new()에 의해 할당된 메모리를 초기화한다. 문자열인 경우는 테스트를 저장하기 위한 메모리를 획듯하지만 공간은 struct String 자신의 new()에 의해서 할당된다. 이는 나중에 delete()에 의해서 해제된다. 그렇지만, 먼저 delete()는 소멸자를 호출하고 소멸자에 의해 할당된 역순으로 수행된다.

메소드, 메시지, 클래스와 객체

앞의 이야기를 종합적으로 정리하면, 타입 스크립터는 특정 타입에 대한 정보를 저장하고 있으며 이곳에는 할당될 객체에 대한 크기와 생성자 소멸자 함수 정보가 저장되어 있다.

참조 문서에서는 추가로 clone()와 differ() 함수도 추가되었다. clone()이야 객체를 복제하는 것이고, differ()는 객체를 서로 비교해서 다른지 여부를 판단하는 것이다. 이를 정리해서 정의하면 다음과 같다.

struct Class {
    size_t size;
    void * (* ctor) (void * self, va_list * app);
    void * (* dtor) (void * self);
    void * (* clone) (const void * self);
    int (* differ) (const void * self, const void * b);
};
struct String {
    const void * class; /* must be first */
    char * text;
};
struct Set {
    const void * class; /* must be first */
    ...
};

String은 특별하기 때문에 특별하게 추가된 것이고, Set은 현재 다룰 타입이다. String을 잘 보면 처음에 void형 class가 있다. 이는 모든 타입에 반드시 맨 처음에 선언되야한다.

Class 구조체 크기를 보면 기본적으로 함수 객체가 4개로 32bit 운영체제라면 16byte 크기가 되고 size도 정수형이라면 4byte로 20byte가 된다. 즉, 최소 모든 객체는 20byte가 추가로 증가한다. 이렇게까지 사용해야될까? 굳이 복잡하게 사용해야될까? 참 어려운 답이다. 이건 tradeoff이기 때문이다.

일단 내용은 모두 이해하기에는 심오하고 난해하기에 그냥 뛰어 넘고 코드로 바로 가보자. 솔찍히 내가 이해 못해니깐.. 쿨럭 ㅡㅡㅋ

Selectors, Dynamic Linkage, and Polymorphisms

new()를 살펴보자.

void * new (const void * _class, ...)
{
    const struct Class * class = _class;
    void * p = calloc(1, class -> size); // reset zero.
    assert(p);
    * (const struct Class **) p = class;
    if (class -> ctor)
    {
        va_list ap;
        va_start(ap, _class);
        p = class -> ctor(p, & ap);
        va_end(ap);
    }
    return p;
}

좀 복잡할지 모른다. 이정도는 가볍게 이해하자. 넘겨받은 class객체를 Class로 캐스팅한다. class에서 size로 할당될 객체 크기를 가져와서 calloc로 할당한다. 그리고 할당된 포인터를 assert하고 p객체에 void 포인터형 class에 class객체 포인터 값을 넣는다. 그리고 해당 객체에 생성자가 있다면 할당된 객체(p)와 입력받은 인자 목록을 생성자 인수로 넘겨준다. 그리고 초기화된 객체를 반환한다.

여기서 중요한 것은 모든 객체의 시작은 struct Class로 시작해야 된다. 그렇지 않으면 처음 코드에서 문제가 발생하게 된다. 또하나 난해한 코드는 다음이다.

* (const struct Class **) p = class;

타입 디스크립터 class객체가 컴파일 타임에서 초기화된다. 원문에는 그림도 있는데, 그림 붙이기 귀찮아서 건너뛴다. 참조[1]의 20page을 참조하기 바란다

delete()를 보자. 새로운 struct Class를 근거해서 변경하면,

void delete (void * self)
{
    const struct Class ** cp = self;
    if (self && * cp && (* cp) -> dtor)
        self = (* cp) -> dtor(self);
    free(self);
}

self로 넘어오는 객체도 struct Class를 가지고 있어야 한다. struct Class의 dtor인 소멸자가 있으면 해당 객체 소멸자 함수를 호출한다. dtor이 null이 될때 까지 모든 객체를 해제하고 해제가 끝나면 할당된 메모리도 해제가 된다.

모든 함수를 처음 인자가 self로서 타입 디스크립터 객체가 온다. self에서 이런 타입 디스크립터를 가져온다. 그래도 항상 null을 체크해야하다. 즉 항당 각 타입 디스크립터 시작 부분에 매직 넘버를 두고 있다. 이 값은 체킹할떄 유용하게 사용된다.

int differ (const void * self, const void * b)
{
    const struct Class * const * cp = self;
    assert(self && * cp && (* cp) -> differ);
    return (* cp) -> differ(self, b);
}

differ()는 이런 테크닉을 사용해서 구현한 예이다. 이는 정적 링크 혹은 늦은 바인딩으로 self 객체에 의해서 호출되는 differ() 수행이 결정된다.

differ()를 selector 함수라고 할 수 있다. 이는 polymorphic function의 예라고 볼 수 있다. 예를 들어 다른 타입의 인자를 받아서 그들의 타입에 따라 다른 동작을 하게 된다. class을 더 구현하게 되면 모두 differ를 포함하여 구현한다. differ()는 generic function으로서 이들 클래스 내에서 임의 객체에서 적용할 수 있다.

polymorphic function은 많은 프로그래밍 언어에서 사용되었다. 예를 들어 파스칼에서 write() 프로시저는 다른 인자 타입을 다룬다. C에서 연산자 + 은 정수, 포인터 실수형 호출에 따라 다른 작동을 한다. 이런 것을 overloading이라고 하며, 인자 타입과 연산자 명에 따라 어떤 오퍼레이터가 수행될지 결정된다. 같은 연산자 명이라도 다른 인자 타입이라면 다른 동작을 하게 된다.

differ()가 오버로드된 함수 처럼 동작한다. C컴파일러는 polymorphic function을 만들고 작동을 할 수 있다. C 컴파일러는 다른 + 연산자 사용으로 다른 반환형을 사용할 수 있지만 differ()는 같은 리턴형을 갖는다.

메소드는 동적 링크없이 polymorphic이 가능하다. 예를 들어 sizeOf()함수는 객체의 크기를 반환한다.

size_t sizeOf (const void * self)
{
    const struct Class * const * cp = self;
    assert(self && * cp);
    reurn (* cp) -> size;
}

모든 객체는 자신들의 디스크립터를 가지고 다닌다. 이들로 부터 크기를 획득할 수 있다.

void * s = new(String, "text");
assert(sizeof s != sizeOf(s));

sizeof는 C 연산자는 컴파일 타임에서 계산되고 해당 인자의 바이트 크기를 반환한다. sizeOf()는 polymorphic function으로서 런타임에 객체 크기를 반환한다.

An Application

아직 문자열에 대한 구현을 하지 않았다. 간단한 테스트를 위해서 String.h 파일에서 추상 데이터 형을 정의해보자.

extern const void * String;

모든 메소드는 모든 객체에 공통이지만, new.h에 메모리 관리 관련 함수를 선언한다.

void * clone (const void * slef);
int differ (const void * self, const void * b);
size_t sizeOf (const void * self);

처음 두개 함수는 selector로 선언되었다. 이들은 struct Class의 구성요소이다. 간단한 응용 예를 보자.

#include "String.h"
#include "new.h"
int main ()
{
    void * a = new(String, "a"), * aa = clone(a);
    void * b = new(String, "b");
    printf("sizeOf(a) == %u\n", sizeOf(a));
    if (differ(a, b))
        puts("ok");
    if (differ(a, aa))
        puts("differ?");
    if (a == aa)
        puts("clone?");
    delete(a), delete(aa), delete(b);
    return 0;
}

지금은 컴파일만 되고 링크는 되지 않는다. String 정의도 없고 clone(), differ, sizeOf()의 구현도 없다.

String 객체를 a, b인 두개 객체를 생성하고, 객체의 크그를 출력한다. 그리고, 두 객체가 다른지 비교하고 두 객체가 같은지도 원본 객체와 비교한다. 마지막으로 할당된 객체를 해제한다. 실행 결과는 다음과 같을 것이다.

sizeOf(a) == 8
ok

String 구현

앞에서 구현되지 않은 문자열 구현을 해보자. 필요한 메소드를 String 타입 디스크립션에 입력합니다. 동적 링크는 확실히 새로운 데이터 형 구현에 따라서 필요한 함수를 구별해준다.

생성자는 new()로 넘겨주는 텍스트를 획득하고 new()에 이해서 할당된 struct String에서 동적 복사하여 저장된다.

struct String {
    const void * class; /* must be first */
    char * text;
};

static void * String_ctor (void * _self, va_list * app)
{
    struct String * self = _self;
    const char * text = va_arg(* app, const char *);
    self -> text = malloc(strlen(text) + 1);
    assert(self -> text);
    strcpy(self -> text, text);
    return self;
}

생성자에서 new()가 이미 .class에 설정되기 때문에 .text만 초기화가 필요하다. 소멸자는 문자열에 의해서 생성된 동적 메모리는 해제된다. delete()는 self가 null아니면 소멸자가 호출된다. 체크가 필요 없다.

나머지 문자열 구현 코드이다.

static void * String_dtor (void * _self)
{
    struct String * self = _self;
    free(self -> text), self -> text = 0;
    return self;
}

static void * String_clone (const void * _self)
{
    const struct String * self = _self;
    return new(String, self -> text);
}

static int String_differ (const void * _self, const void * _b)
{
    const struct String * self = _self;
    const struct String * b = _b;
    if (self == b)
        return 0;
    if (! b || b -> class != String)
        return 1;
    return strcmp(self -> text, b -> text);
}

#include "new.r"
static const struct Class _String = {
        sizeof(struct String),
        String_ctor, String_dtor,
        String_clone, String_differ
};
const void * String = & _String;

별다르게 설명할 필요는 없을 것 같다. 단지 이런 코드를 어디에 배포되어 있는가하는 것이다. ㅡ.ㅡ;

이부분은 좀더 정리하고 나중에... (TODO 코드 배포 문제)

여기서 중요한 것은 static으로 선언된 Class를 _String으로 초기화한다. 이때 필효한 여러 값을 입력된다. 여기 있는 값을 기반으로 new()에 의해서 새로 할당되는 메모리에 넣게된다. 이 타입 디스크립터는 유일한 값이다.

다른 구현 - Atom

여기서 atom은 유일한 문자열 객체이다. 두개의 atom이 같은 문자열을 가져도 이를 구분할 수 있다. Atom은 값 비교하는 것은 간단하다. differ()는 두 인자 포인터가 다르면 true를 반환한다. Atom은 생성과 소멸 비용이 비싸다. 모든 atom에 대해서 순환 리스트를 사용하고 atom이 복제된 회수를 개수한다.

Atom이 이런 거라고 한다. ㅡ.ㅡ;

struct String {
    const void * class; /* must be first */
    char * text;
    struct String * next;
    unsigned count;
};

static struct String * ring; /* of all strings */

static void * String_clone (const void * _self)
{
    struct String * self = (void *) _self;
    ++ self -> count;
    return self;
}

ring에 의해서 모든 atom의 목록이 관리된다. String에서 .next 요소를 통해서 확장가능하다. String 생성자와 소멸자도 같이 유지된다. 생성자는 문자를 저장하기 전에 리스트 상에 같은 문자열이 있는지 검색한다. 다음 코드는 String_ctor()의 시작에 다음 코드가 추가된다.

if (ring){
    struct String * p = ring;
    do
        if (strcmp(p -> text, text) == 0) {
            ++ p -> count; /* 같은 문자열이면 개수를 증가 */
            free(self);
            return p;
        }
    while ((p = p -> next) != ring); /* 순환 리스트 */
} else
    ring = self; /* new atom */
self -> next = ring -> next, ring -> next = self;
self -> count = 1;

코드는 특별하게 어렵지는 않을 것이다. 그리고 링크드 리스트라는 자료 구조에 익숙하다면 쉽게 이해될 것이다.

아래는 String_dtor()의 시작 부분의 코드이다.

if (-- self -> count > 0)
    return 0; /* 아직 개수가 0이 아니면 바로 리턴 */
assert(ring);
if (ring == self)
    ring = self -> next;
if (ring == self)
    ring = 0;
else {
    struct String * p = ring;
    while (p -> next != self) {
        p = p -> next;
        assert(p != ring);
    }
    p -> next = self -> next;
}

참조 계수가 양수면 단순히 null 포인터를 반환하고 끝낸다. string이 마지막이거나 목록에서 제거되면 순환 목록 마커를 클리어한다. 대충 이런거?

링크해서 실행한 결과는

sizeOf(a) == 16
ok
clone?

요약

주어진 객체에 대한 포인터로 동적 링크는 타입 종속적 함수를 찾을 수 있다. 모든 객체는 디스크립터로 시작된다. 이 디스크립터는 객체에 적당한 함수 포인터가 포함되어 있다. 이 안에 생성자와 소멸자도 포함되어 있다.

모든 객체들이 같은 디스크립터 클래스를 공유한다. 객체는 클래스에 대한 인스탄스이고, 메소드를 호출하는 객체에 대한 함수 타입 종속적 함수와 메시지는 이런 함수를 호출한다. Selector 함수를 사용해서 동적으로 링크된 메소드를 위치시키고 호출된다.

Selector와 동적 링크롤 통해서 같은 이름 함수를 다른 클래스에 대해 다른 작동을 한다. 이런 함수를 polymorphic이라 한다.

Polymorphic 함수는 꽤 유용하다. 이는 개념적 추상 단계를 제공한다. differ()는 두 객체를 비교할수 있다. 이는 개념적으로 적합한 differ()라면 특정 브랜드가 무엇인지 기억할 필요가 없다. 싸고 상당히 유용한 디버깅 툴이 polymorphic 함수인 store()이다. 이 함수는 파일 디스크립터에 어떤 객체라도 출력한다.

연습

polymorphic 함수 작동을을 보기위해 동적 링크가 되는 Object와 Set을 구현하면 된다. 이는 set 구성요소에 더 이상 저장되지 않기데 Set이 어렵다.?

이는 문자열에 대한 더 많은 메소드가 있어야 한다. 문자열 길이, 새로운 문자열 값을 할당, 문자열 출력이 필요하다. 또한 일부 문자열을 처리하고 싶어한다.

해쉬 테이블로 다룬다면 Atom은 좀더 효과적이다. atom의 값이 변경될까?

String_clone()에 몇가지 의문점이 있다. 이 함수에서 String은 self->class와 같은 값이 된다. new()에 넘겨지는 것과 다른가? (무슨 내용인지 ㅡ.ㅡ;)

여기까지이다. 잘 이해되지 않은 것은 직역해서 대충 얼버부린 곳이 좀 있네요. 아마 나중에 이런 부분은 삭제되지 않을까하는 생각이든다. 근데, 언제 그렇게 할까? OTL

참조자료

[1] Object Oriented Programming in ANSI C, Axel-Tobias Schreiner, Rochester Institute of Technology

반응형