프로그래밍에서의 추상화란 추상 자료형(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-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)가 저장되어 있다.
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에서는 이러한 형 변환이 수동적이라는 중요한 차이가 있다.
다음의 의사코드는 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이라 한다. 가상 테이블은 생성자에서만 추가되기 때문에 부모 및 자식 클래스 모두 생성자에서 다형적 메서드를 호출하지 않도록 주의해야 한다.
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 값을 갖는다는 차이점이 있다. 이를 통해 컴파일 시점에 객체 생성 행위를 오류로 탐지할 수 있다.