[전문가를 위한 C] Chapter 9. 추상화와 C++의 OOP

YUSHIN KIM·2026년 9월 6일

전문가를 위한 C

목록 보기
9/14

Chapter 9. 추상화와 C++의 OOP

9.1 추상화

프로그래밍에서의 추상화란 추상 자료형(abstract data type)을 다루는 것이다. 클래스 기반의 객체지향에서 추상 자료형은 추상 클래스(abstract class)와 같다. 추상 클래스는 인스턴스화할 수 없는 특별한 클래스로, 코드의 의존성을 줄여주는 역할을 한다.

"인간(Human) 클래스의 객체는 사과(Apple)나 오렌지(Orange) 클래스의 객체를 먹는다."라는 관계에서, 만약 과일의 종류가 늘어난다면 인간 클래스와의 관계를 지속적으로 추가해야 할 것이다.

하지만, "인간(Human) 클래스의 객체는 과일(Fruit) 클래스의 서브타입 객체를 먹는다."라고 표현한다면 과일의 종류가 늘어나도 관계는 하나만을 재사용할 수 있다. Fruit의 서브타입 클래스인 Apple, Orange를 구상 자료형(concrete type)이라고 한다.

Fruit 클래스보다 높은 수준의 Edible 클래스를 정의할 수도 있다. 하지만 문제 해결에 필요한 수준보다 과도한 추상화(over-abstraction)가 발생하면 부가적인 문제가 발생할 수 있다.

추상화 원칙(abstraction principle)에 의하면, 필요한 추상화의 정도에 대한 일반적인 가이드는 프로그램의 중요한 각 기능이 소스 코드의 오직 한 부분에서만 구현되어야 한다고 말한다.

다형성 역시 서브 클래스 수준에서 특정 필드의 값을 결정할 수 있게 한다. 예를 들어 Edible 클래스의 taste 필드는 Edible 클래스 객체에 설정될 수 없을 것이다. 어떤 음식인지 알 수 없기 때문이다. 이 클래스는 edible_get_taste 가상 함수만 포함할 수 있다. 이처럼 속성과 기본 정의를 갖지 않고 가상 함수만 포함하는 클래스를 인터페이스(interface)라고 한다.

코드 박스 9-1 C로 작성한 먹을 수 있는 것(Edible) 인터페이스)

typedef enum {SWEET, SOUR} taste_t;

// function pointer type
typedef taste_t (*get_taste_func_t)(void*);

typedef struct {
	// a pointer which points to the definition of a virtual function
    get_taste_func_t get_taste_func;
} edible_t;

edible_t* edible_new() { ... }

void edible_constructor(edible_t* edible) {
	// there's no basic definition for the virtual function
    edible->get_taste_func = NULL;
}

// virtual behavior function
taste_t edible_get_taste(edible_t* edible) {
	return edible->get_taste_func(edible);
}

코드 박스 9-2 먹을 수 있는 것(Edible) 인터페이스로 객체를 생성하고 순수 가상 함수를 호출하면 세그멘테이션 오류 발생

edible_t* edible = edible_new();
edible_constructor(edible);
taste_t taste = edible_get_taste(edible);	// segmentation fault
free(edible);

C에서는 인터페이스의 객체를 생성하는 행위 자체는 가능하지만, 사용 시 충돌이 발생한다. 객체 생성 행위를 막는 방법은 공용 인터페이스에서 할당자 함수(allocator function)를 제거하는 것이다.


9.2 C++의 객체지향 구성물

9.2.1 캡슐화

코드 박스 9-3 [예제 9-1] C의 캡슐화 예제

#include <stdio.h>

typedef struct {
    int width;
    int length;
} rect_t;

int rect_area(rect_t* rect) {
    return rect->width * rect->length;
}

int main(int argc, char** argv) {
    rect_t r;
    r.width = 10;
    r.length = 25;
    int area = rect_area(&r);
    printf("Area: %d\n", area);
    return 0;
}

코드 박스 9-4 [예제 9-1] C++의 캡슐화 예제

#include <iostream>

class Rect {
public:
    int Area() {
        return width * length;
    }
    int width;
    int length;
};

int main(int argc, char** argv) {
    Rect r;
    r.width = 10;
    r.length = 25;
    int area = r.Area();
    std::cout << "Area: " << area << std::endl;
    return 0;
}

셀 박스 9-1 C와 C++ 코드에 대한 어셈블리 출력 결과 생성하기

$ gcc -S 9_1.c -o 9_1_c.s
$ g++ -S 9_1.cpp -o 9_1_cpp.s

9_1_c.s에서는 rect_area 심볼, 9_1_cpp.s에서는 _ZN4Rect4AreaEV 심볼을 찾아 비교해야 한다.

셀 박스 9-2 rect_area 함수에 대해 생성된 어셈블리 코드

$ cat 9_1_c.s
...
rect_area:
.LFB0:
        .cfi_startproc
        endbr64
        pushq   %rbp
        .cfi_def_cfa_offset 16
        .cfi_offset 6, -16
        movq    %rsp, %rbp
        .cfi_def_cfa_register 6
        movq    %rdi, -8(%rbp)
        movq    -8(%rbp), %rax
        movl    (%rax), %edx
        movq    -8(%rbp), %rax
        movl    4(%rax), %eax
        imull   %edx, %eax
        popq    %rbp
        .cfi_def_cfa 7, 8
        ret
        .cfi_endproc
...

셀 박스 9-3 Rect::Area 함수에 대해 생성된 어셈블리 코드

$ cat 9_1_cpp.s
...
_ZN4Rect4AreaEv:
.LFB1976:
        .cfi_startproc
        endbr64
        pushq   %rbp
        .cfi_def_cfa_offset 16
        .cfi_offset 6, -16
        movq    %rsp, %rbp
        .cfi_def_cfa_register 6
        movq    %rdi, -8(%rbp)
        movq    -8(%rbp), %rax
        movl    (%rax), %edx
        movq    -8(%rbp), %rax
        movl    4(%rax), %eax
        imull   %edx, %eax
        popq    %rbp
        .cfi_def_cfa 7, 8
        ret
        .cfi_endproc
...

두 어셈블리 코드는 매우 유사하다. C++ 컴파일러 역시 C의 암묵적 캡슐화와 유사한 방식을 사용하기 때문이다. System V ABI에 따라 %rdi 레지스터에 첫 번째 포인터 인수(this)가 저장되어 있다.


9.2.2 상속

C++에서 자식 클래스의 포인터는 부모 클래스의 포인터로 할당할 수 있으며, 자식 클래스가 부모 클래스의 비공개 정의에 접근할 수 있다. 즉, C++는 상속을 구현하는 첫 번째 접근법을 사용한다. 하지만 다중 상속도 지원한다.

코드 박스 9-5 [예제 9-2] C의 상속 예

#include <string.h>

typedef struct {
    char c;
    char d;
} a_t;

typedef struct {
    a_t parent;
    char str[5];
} b_t;

int main(int argc, char** argv) {
    b_t b;
    b.parent.c = 'A';
    b.parent.d = 'B';
    strcpy(b.str, "1234");
    // breakpoint here
    return 0;
}

코드 박스 9-6 [예제 9-2] C++의 상속 예

#include <string.h>

class A {
public:
    char c;
    char d;
};

class B : public A {
public:
    char str[5];
};

int main(int argc, char** argv) {
    B b;
    b.c = 'A';
    b.d = 'B';
    strcpy(b.str, "1234");
    // breakpoint here
    return 0;
}

셀 박스 9-4 gdb에서 [예제 9-2]의 C 버전 실행하기

$ gcc -g 9_2.c -o 9_2_c.out
$ gdb ./9_2_c.out
...
(gdb) b 9_2.c:18
Breakpoint 1 at 0x117e: file 9_2.c, line 19.
(gdb) r
Starting program: /home/yushin/Study/Study_C/ExtremeC/chapter09/9_2_c.out 

Breakpoint 1, main (argc=1, argv=0x7fffffffde08) at 9_2.c:19
19	    return 0;
(gdb) x/7c &b
0x7fffffffdcc1:	65 'A'	66 'B'	49 '1'	50 '2'	51 '3'	52 '4'	0 '\000'
(gdb) c
Continuing.
[Inferior 1 (process 9838) exited normally]
(gdb) q

셀 박스 9-5 gdb에서 [예제 9-2]의 C++ 버전 실행하기

$ g++ -g 9_2.cpp -o 9_2_cpp.out
$ gdb ./9_2_cpp.out
...
(gdb) b 9_2.cpp:19
Breakpoint 1 at 0x117e: file 9_2.cpp, line 20.
(gdb) r
Starting program: /home/yushin/Study/Study_C/ExtremeC/chapter09/9_2_cpp.out 

Breakpoint 1, main (argc=1, argv=0x7fffffffde08) at 9_2.cpp:20
20	    return 0;
(gdb) x/7c &b
0x7fffffffdcc1:	65 'A'	66 'B'	49 '1'	50 '2'	51 '3'	52 '4'	0 '\000'
(gdb) c
Continuing.
[Inferior 1 (process 9949) exited normally]
(gdb) q

두 프로그램 모두 b 객체의 주소로부터 7바이트 값이 'A', 'B', '1', '2', '3', '4', '\0'이다. C++에서는 클래스 내 위치와 상관없이 속성은 언제나 특정 객체에 대해 같은 메모리 블록 안에서 수집된다. 그리고 함수는 속성과는 언제나 독립적으로 존재한다. 그러므로 두 프로그램의 메모리 레이아웃은 같다.

코드 박스 9-7 C에서 상속을 구현하는 첫 번째 접근법에서 다중 상속이 되지 않는 이유에 관한 예

typedef struct { ... } a_t;
typedef struct { ... } b_t;

typedef struct {
	a_t a;
    b_t b;
    ...
} c_t;

c_t c_obj;
a_t* a_ptr = (a_ptr*) &c_obj;
b_t* b_ptr = (b_ptr*) &c_obj;
c_t* c_ptr = &c_obj;

C에서 위와 같이 다중 상속을 구현할 시, b_ptr 역시 a_t 객체의 필드 주소를 가리키게 되어 정상적으로 사용할 수 없다.

코드 박스 9-8 올바른 필드를 가리키도록 캐스팅(변환)을 업데이트하는 방법에 관한 예

c_t c_obj;
a_t* a_ptr = (a_ptr*) &c_obj;
b_t* b_ptr = (b_ptr*) (&c_obj + sizeof(a_t));
c_t* c_ptr = &c_obj;

C에서 다중 상속을 구현하기 위해, b_ptr의 경우 a_t의 크기만큼 건너뛴 주소를 가리키게 한다. 이렇게 하면 b_ptr이 b_t 객체의 필드 주소를 가리키게 된다.

코드 박스 9-9 [예제 9-3] C++에서의 다중 상속

#include <string.h>

class A {
public:
    char a;
    char b[4];
};

class B {
public:
    char c;
    char d;
};

class C {
public:
    char e;
    char f;
};

class D : public A, public B, public C {
public:
    char str[5];
};

int main(int argc, char** argv) {
    D d;
    d.a = 'A';
    strcpy(d.b, "BBB");
    d.c = 'C';
    d.d = 'D';
    d.e = 'E';
    d.f = 'F';
    strcpy(d.str, "1234");
    A* ap = &d;
    B* bp = &d;
    C* cp = &d;
    D* dp = &d;
    // breakpoint here
    return 0;
}

셀 박스 9-6 [예제 9-3]을 gdb에서 컴파일하고 실행하기

$ g++ -g 9_3.cpp -o 9_3.out
$ gdb ./9_3.out
...
(gdb) b 9_3.cpp:39
Breakpoint 1 at 0x11b9: file 9_3.cpp, line 40.
(gdb) r
Starting program: /home/yushin/Study/Study_C/ExtremeC/chapter09/9_3.out 

Breakpoint 1, main (argc=1, argv=0x7fffffffde18) at 9_3.cpp:40
40	    return 0;
(gdb) x/14c &d
0x7fffffffdcca:	65 'A'	66 'B'	66 'B'	66 'B'	0 '\000'	67 'C'	68 'D'	69 'E'
0x7fffffffdcd2:	70 'F'	49 '1'	50 '2'	51 '3'	52 '4'	0 '\000'

부모 클래스의 여러 객체가 d 객체의 메모리 레이아웃 내부에 있는 것을 확인할 수 있다.

셀 박스 9-7 [예제 9-3]에서 포인터에 저장된 주소를 출력하기

(gdb) print ap
$1 = (A *) 0x7fffffffdcca
(gdb) print bp
$2 = (B *) 0x7fffffffdccf
(gdb) print cp
$3 = (C *) 0x7fffffffdcd1
(gdb) print dp
$4 = (D *) 0x7fffffffdcca

C++에서는 자식 포인터를 부모 포인터로 업캐스팅 시 암묵적 형 변환을 수행할 수 있다. gdb 실행 결과에서도 확인할 수 있듯이 bp 포인터 할당 시에는 sizeof(A) 만큼 컴파일러가 포인터를 이동했다. C에서는 이러한 형 변환이 수동적이라는 중요한 차이가 있다.


9.2.3 다형성

다음의 의사코드는 C에서 다형성을 구현하기 위한 접근법을 일반화한 것이다.

코드 박스 9-10 C 코드에서 가상 함수가 선언 및 정의되는 방식을 보여주는 의사코드

// define function pointer types
typedef void* (*func_1_t)(void*, ...);
typedef void* (*func_2_t)(void*, ...);
...
typedef void* (*func_n_t)(void*, ...);

// attribute structure for the parent class
typedef struct {
	// attributes
    ...
    // pointers for the functions
    func_1_t func_1;
    func_2_t func_2;
    ...
    func_n_t func_n;
} parent_t;

// basic private definitions for the virtual behavior functions
void* __default_func_1(void* parent, ...) {}
void* __default_func_2(void* parent, ...) {}
...
void* __default_func_n(void* parent, ...) {}

// constructor
void parent_constructor(parent_t* parent) {
	
    // attribute initialization
    ...
    // set the basic definitions for the virtual behavior functions
    parent->func_1 = __default_func_1;
    parent->func_2 = __default_func_2;
    ...
    parent->func_n = __default_func_n;
}

// public non-virtual behavior functions
void* parent_non_virt_func_1(parent_t* parent, ...) { // Code }
void* parent_non_virt_func_2(parent_t* parent, ...) { // Code }
...
void* parent_non_virt_func_m(parent_t* parent, ...) { // Code }

// real public virtual behavior functions
void* parent_func_1(parent* parent, ...) {
	return parent->func_1(parent, ...);
}
void* parent_func_2(parent* parent, ...) {
	return parent->func_2(parent, ...);
}
...
void* parent_func_n(parent* parent, ...) {
	return parent->func_n(parent, ...);
}

다음의 의사코드는 자식 클래스가 위 코드의 몇몇 가상 함수를 오버라이딩하는 방식을 나타낸다.

코드 박스 9-11 자식 클래스가 부모 클래스로부터 상속받은 가상 함수를 오버라이딩하는 방법을 보여주는 C에서의 의사코드

// include all things of the parent class

typedef struct {
	parent_t parent;
    // child attributes
    ...
} child_t;

void* __child_func_4(void* parent, ...) { // overriding definition }
void* __child_func_7(void* parent, ...) { // overriding definition }

void child_constructor(child_t* child) {
	parent_constructor((parent_t*) child);
    // child attributes initialization
    ...
    
    // update pointers for the functions
    child->parent.func_4 = __child_func_4;
    child->parent.func_7 = __child_func_7;
}

// child behavior functions
...

C에서 다형성을 구현하기 위해 자식 클래스는 부모의 속성 구조체에 있는 함수 포인터를 업데이트한다.

C++는 virtual 키워드 사용 시 함수 포인터의 배열을 생성한다. 이를 가상 테이블(virtual table) 또는 vtable이라 한다. 가상 테이블은 생성자에서만 추가되기 때문에 부모 및 자식 클래스 모두 생성자에서 다형적 메서드를 호출하지 않도록 주의해야 한다.


9.2.4 추상 클래스

C++에서 추상화는 순수 가상 함수(pure virtual function)를 사용할 수 있다. 멤버 함수의 값을 0으로 둔 것이 이에 해당한다.

코드 박스 9-12 C++의 먹을 수 있는 것(Edible) 인터페이스

enum class Taste { Sweet, Sour };

// interface
class Edible {
public:
	virtual Taste GetTaste() = 0;
}

자식 클래스에서는 다음과 같이 GetTaste 함수를 오버라이딩할 수 있다.

코드 박스 9-13 먹을 수 있는 것(Edible) 인터페이스를 구현하는 자식 클래스

enum class Taste { Sweet, Sour };

// interface
class Edible {
public:
	virtual Taste GetTaste() = 0;
};

class Apple : public Edible {
public:
	Taste GetTaste() override {
    	return Taste::Sweet;
    }
}

일반적인 가상 함수는 생성 진행 중 기본 정의를 가리켜야 한다. 하지만 순수 가상 함수는 null 값을 갖는다는 차이점이 있다. 이를 통해 컴파일 시점에 객체 생성 행위를 오류로 탐지할 수 있다.

profile
안녕하세요

0개의 댓글