개체지향 (OOP)
들어가는 말
이 글은 개체지향 프로그래밍(Object-Oriented Programming, OOP)의 문법을 설명하지 않는다.
개체지향을 개념적으로 어떻게 이해하면 좋을지, 내가 이해해 온 과정을 정리했다.
개체지향을 이해하려면 먼저 Object라는 단어가 어떻게 번역되는지 살펴볼 필요가 있다.
Object는 보통 객체라고 번역되지만, 일부 자료에서는 개체라고 번역되기도 한다.
두 단어가 주는 인상과 뜻이 다르므로, 먼저 객체와 개체의 뜻을 비교해 보자.
객체(客體)
- 의사나 행위가 미치는 대상.
- 문장 내에서 동사의 행위가 미치는 대상.
- 작용의 대상이 되는 쪽.
개체(個體)
- 하나의 독립된 생물체.
- 단일하고 독립적인 통일적 존재.
나의 관점에서 객체는 수동적인 의미가 강한 단어로 느껴진다.
반면 개체는 상황에 따라 능동적인 존재가 될 수도 있고, 수동적인 존재가 될 수도 있는 단어로 해석된다.
내가 학습할 때 Object를 객체의 뜻으로 바라보니 이해하기 쉽지 않았다.
그래서 나는 Object를 객체보다 개체로 바라볼 때 개체지향을 더 쉽게 이해할 수 있었다.
이 글에서는 이런 관점에 따라 개체라는 용어를 사용하겠다.
개체라는 관점이 중요한 이유는 사람이 세상을 개체 단위로 바라보는 데 익숙하기 때문이다.
사람은 TV를 하나의 독립적인 사물, 즉 개체로 인식한다.
물론 TV 전문가라면 TV가 프레임, 패널, 백라이트 필름, 편광 필름, 메인보드, 전원부, 스피커 같은 부품으로 이루어져 있다는 것을 알 것이다.
하지만 수리하거나 조립하는 상황처럼 내부 구성을 따져야 할 때가 아니라면, 전문가도 TV를 하나의 개체로 바라볼 것이다.
TV를 볼 때 우리는 몇 인치인지, 프레임 색이 무엇인지, 지금 켜져 있는지 같은 상태를 떠올린다.
또 TV를 켜거나 끄고, 소리를 키우거나 줄이고, 인터넷에 접속할 수 있다는 동작도 함께 떠올린다.
이처럼 우리는 사물을 상태와 동작이 묶인 단위로 바라본다.
개체지향 프로그래밍은 이런 사람의 사고방식으로 프로그램을 바라보고, 그 관점에 따라 소스 코드를 작성할 수 있게 해 준다.
즉 프로그램을 개체들이 서로 상호작용하며 동작하는 것으로 바라보는 프로그래밍 패러다임(paradigm)이다.
데이터와 함수를 하나로 묶어 보기
앞에서 개체를 상태와 동작이 묶인 단위로 바라본다고 했다.
C의 기본적인 형태부터 함수 포인터로 조금 발전시킨 형태까지 알아보고, C++에서 최종적으로 데이터와 함수가 어떻게 한 개체처럼 다뤄지는지 살펴보자.
C에서 분리된 데이터와 함수
C 언어는 훌륭한 언어다.
다만, 사람의 사고방식을 반영하기에는 부족한 면이 있다.
C 언어에서 데이터와 함수는 기본적으로 독립적으로 존재한다.
#include <stdio.h>
typedef struct
{
int is_on;
int brightness;
} Lamp;
void turn_on(Lamp* lamp)
{
lamp->is_on = 1;
lamp->brightness = 100;
}
void turn_off(Lamp* lamp)
{
lamp->is_on = 0;
lamp->brightness = 0;
}
void print_status(const Lamp* lamp)
{
printf("is_on=%d, brightness=%d\n", lamp->is_on, lamp->brightness);
}
int main(void)
{
Lamp lamp = {
.is_on = 0,
.brightness = 0
};
turn_on(&lamp);
print_status(&lamp);
turn_off(&lamp);
print_status(&lamp);
return 0;
}
이 코드는 이상하지 않다.
다만 Lamp의 상태를 나타내는 데이터는 is_on과 brightness다.
그리고 Lamp의 동작을 나타내는 함수는 turn_on, turn_off, print_status다.
사람은 Lamp를 하나의 독립적인 존재로 바라보지만, 이 코드에서는 데이터와 함수가 분리되어 있다.
구조체에 동작을 함께 담아 보기
함수 포인터를 이용하면 데이터와 함수를 한 구조체로 묶을 수 있다.
#include <stdio.h>
typedef struct Lamp Lamp;
struct Lamp
{
int is_on;
int brightness;
void (*turn_on)(Lamp* self);
void (*turn_off)(Lamp* self);
void (*print_status)(const Lamp* self);
};
void turn_on_lamp(Lamp* self)
{
self->is_on = 1;
self->brightness = 100;
}
void turn_off_lamp(Lamp* self)
{
self->is_on = 0;
self->brightness = 0;
}
void print_lamp_status(const Lamp* self)
{
printf("is_on=%d, brightness=%d\n", self->is_on, self->brightness);
}
int main(void)
{
Lamp lamp = {
.is_on = 0,
.brightness = 0,
.turn_on = turn_on_lamp,
.turn_off = turn_off_lamp,
.print_status = print_lamp_status
};
lamp.turn_on(&lamp);
lamp.print_status(&lamp);
lamp.turn_off(&lamp);
lamp.print_status(&lamp);
return 0;
}
위 코드에서 본 구조체와 함수 포인터를 이용한 방식은 리눅스 커널의 struct gpio_chip에서도 찾아볼 수 있다.
기존 turn_on(&lamp);는 함수를 호출하면서 lamp를 인자로 넘기고 있다.
동작은 함수가 나타내고, 그 동작을 적용할 대상은 인자로 전달하는 형태다.
반면 lamp.turn_on(&lamp);는 lamp에 turn_on이라는 동작이 묶여 있는 것처럼 보이게 한다.
또 IDE의 도움으로 멤버 자동완성 같은 기능도 사용할 수 있다.
하지만 lamp에 소속된 것처럼 보이는 함수를 호출하면서도 다시 &lamp를 넘겨야 한다는 점이 어색하다.
실제 기계가 작동하는 방식과 사람이 편하게 느끼는 방식에 차이가 있는 것이다.
C++에서 this의 정체 확인하기
OOP 언어로 프로그래밍해 본 사람이라면 앞의 C 코드에서 보인 호출 패턴이 낯설 수 있다.
개체의 메서드를 호출하면서 그 개체의 주소를 매개변수로 직접 넘기는 코드를 본 적이 없기 때문이다.
명시적으로 보이지 않았을 뿐, 언어 차원에서 대신 해 주고 문법 뒤에 숨겨져 있다.
이제 C++은 C와 어떻게 다르고 실제 저수준에서는 어떻게 작동하는지 살펴보자.
C++(1983)의 전신은 C with Classes(1979–1983)였다.
Bjarne Stroustrup의 A History of C++: 1979-1991에는 다음 내용이 있다.
“C++ was designed to provide Simula’s facilities for program organization together with C’s efficiency and flexibility for systems programming.”
1967년에 등장한 프로그래밍 언어 SIMULA 67의 Common Base Language 문서(1970) 1.3.3 Classes에는 다음 내용이 있다.
A central new concept in SIMULA 67 is the “object”.
An object is a self-contained program (block instance) having its own local data and actions defined by a “class declaration”.
이러한 흐름을 보면 상태와 동작을 하나로 묶으려는 여러 시도가 있었다는 점을 알 수 있다.
이제 같은 Lamp 예제를 C++ 코드로 보자.
#include <iostream>
class Lamp
{
public:
bool is_on;
int brightness;
Lamp()
{
this->is_on = false;
this->brightness = 0;
}
__attribute__((noinline)) void turn_on()
{
this->is_on = true;
this->brightness = 100;
}
__attribute__((noinline)) void turn_off()
{
this->is_on = false;
this->brightness = 0;
}
__attribute__((noinline)) void print_status() const
{
std::cout << "is_on=" << this->is_on << ", brightness=" << this->brightness << std::endl;
}
};
int main()
{
Lamp lamp;
lamp.turn_on();
lamp.print_status();
lamp.turn_off();
lamp.print_status();
return 0;
}
C와 달리 C++에서는 생성자 호출이든 turn_on 같은 메서드 호출이든 인자 없이 호출되는 것처럼 보인다.
하지만 소스 코드를 잘 들여다보면 우리가 매개변수로 선언한 적 없는 this를 통해서 변수들에 접근하고 있다.
C++에서 this는 해당 멤버 함수를 호출한 개체를 가리키는 포인터다.
그런데 이 포인터는 소스 코드 어디에도 명시적으로 선언되지 않는다.
다음 명령어로 빌드한 결과의 어셈블리 코드를 보면, 이 포인터가 숨겨진 매개변수(hidden parameter)처럼 전달되는 과정을 확인할 수 있다.
g++ -O0 -m32 -mpreferred-stack-boundary=2 \
-fno-inline -fno-omit-frame-pointer -fno-stack-protector \
-fno-pie -no-pie main.cpp -o main
objdump -Cd -Mintel main > main.asm
위 명령어로 실행 파일을 만들고 디스어셈블하면, main.asm에서 다음 내용을 확인할 수 있다.
이제부터 사용할 예제에서 스택의 주소는 낮은 방향으로 자란다.
0x080491a6 <main>:
0x080491a6: 55 push ebp
0x080491a7: 89 e5 mov ebp,esp
0x080491a9: 83 ec 08 sub esp,0x8
이러한 명령어 패턴은 함수가 시작될 때 자주 보인다.
main 함수가 본격적으로 실행되기 전에 스택 프레임을 준비하는 코드로, 함수 프롤로그(function prologue)라고 부른다.
여기서는 sub esp,0x8로 esp를 8바이트 줄여 지역 변수 lamp의 공간을 확보한다.
Lamp는 bool 1바이트와 int 4바이트로 구성되어 있어 단순히 더하면 5바이트처럼 보인다.
하지만 실제 sizeof(Lamp)는 5바이트가 아니라 8바이트다.
이 차이는 구조체 멤버 맞춤(structure member alignment) 때문에 생긴다.
첫 번째 규칙은 각 멤버가 자기 자신의 정렬 요구 사항에 맞춰 배치된다는 것이다.
여기서 정렬 요구 사항이란 멤버가 특정 바이트 수의 배수인 주소에서 시작해야 한다는 뜻이다.
이 예제에서 bool은 1바이트라서 어느 주소에서나 시작할 수 있지만, int는 4바이트 경계, 즉 4의 배수인 주소에 배치되어야 한다.
따라서 bool이 offset 0에 놓이면 int는 바로 뒤의 offset 1이 아니라 offset 4부터 시작해야 한다.
그 결과 offset 1부터 offset 3까지 3바이트 패딩이 삽입된다.
두 번째 규칙은 구조체 전체 크기가 구조체 안에서 가장 큰 정렬 요구값의 배수가 되어야 한다는 것이다.
이 예제에서 가장 큰 정렬 요구값은 int의 4바이트다.
앞의 배치대로라면 Lamp의 크기는 bool 1바이트, 패딩 3바이트, int 4바이트를 더한 8바이트다.
8은 이미 4의 배수이므로 구조체 뒤쪽에 추가 패딩은 필요하지 않다.
따라서 이 빌드 결과에서 Lamp의 크기는 8바이트이다.
0x080491a6 <main>:
0x080491ac: 8d 45 f8 lea eax,[ebp-0x8]
0x080491af: 50 push eax
0x080491b0: e8 3b 00 00 00 call 0x080491f0 <Lamp::Lamp()>
lea는 Load Effective Address의 줄임말이다.
ebp-0x8로 표현되는 주소를 계산해 eax 레지스터에 넣는 명령어다.
ebp-0x8은 sub esp,0x8로 확보한 8바이트 중 가장 낮은 주소로, 지역 변수 lamp를 위한 공간이다.
그 주소를 eax에 넣은 뒤, 생성자 호출에 필요한 매개변수로 전달하기 위해 push eax로 스택에 올린다.
그리고 Lamp::Lamp() 생성자를 호출한다.
소스 코드에서 생성자는 매개변수를 받지 않고, 호출할 때도 Lamp lamp;라고만 작성해 인자를 넘기지 않았다.
하지만 생성자가 실제로 값을 초기화하려면, lamp가 놓인 메모리 주소를 알아야 한다.
그래서 컴파일러는 lamp의 주소를 숨겨진 매개변수(hidden parameter)로 생성자에 전달한다.
C++ 코드에서 사용하는 this가 바로 이 숨겨진 매개변수다.
0x080491f0 <Lamp::Lamp()>:
0x080491f0: 55 push ebp
0x080491f1: 89 e5 mov ebp,esp
생성자도 함수이므로 main에서 본 것과 같이 프롤로그에 해당하는 명령어가 먼저 나온다.
다만 이 생성자에는 별도의 지역 변수가 없으므로 sub esp,... 같은 추가 공간 확보는 일어나지 않는다.
0x080491f0 <Lamp::Lamp()>:
0x080491f3: 8b 45 08 mov eax,DWORD PTR [ebp+0x8]
0x080491f6: c6 00 00 mov BYTE PTR [eax],0x0
0x080491f9: 8b 45 08 mov eax,DWORD PTR [ebp+0x8]
0x080491fc: c7 40 04 00 00 00 00 mov DWORD PTR [eax+0x4],0x0
[ebp+0x8]에 있는 값이 생성자에 전달된 첫 번째 인자다.
앞에서 매개변수 전달을 위해 push eax로 스택에 쌓아 둔 lamp의 주소가 여기에 놓인다.
왜 ebp+0x8일까.
call 명령어는 복귀 주소를 스택에 넣고 실행 흐름을 Lamp::Lamp()로 이동시킨다.
이어서 Lamp::Lamp()의 push ebp가 호출자의 ebp를 스택에 저장한다.
그래서 [ebp+0x4]는 복귀 주소이고, [ebp+0x8]이 첫 번째 인자가 된다.
첫 번째 명령어는 첫 번째 인자인 this를 eax로 옮긴다.
두 번째 명령어는 this->is_on = false;를 수행한다.
bool은 1바이트이므로 BYTE PTR로 1바이트만 0으로 채운다.
세 번째 명령어는 최적화가 꺼져 있어 기존 eax 값을 그대로 활용하지 않고 다시 대입하는 형태로 보인다.
마지막 줄은 this->brightness = 0;를 수행한다.
eax+0x4는 bool 1바이트와 패딩된 3바이트 뒤에 있는 brightness의 위치를 가리키고,
brightness는 int이므로 DWORD PTR로 그 위치부터 4바이트를 0으로 채운다.
0x080491f0 <Lamp::Lamp()>:
0x08049203: 90 nop
0x08049204: 5d pop ebp
0x08049205: c3 ret
nop는 no operation의 줄임말로, 아무 동작도 하지 않는 명령어다.
여기서는 주소 정렬 같은 이유로 들어간 것으로 보인다.
pop ebp와 ret는 스택 프레임을 정리하는 전형적인 패턴으로, 함수 에필로그(function epilogue)라고 부른다.
0x080491a6 <main>:
0x080491b5: 83 c4 04 add esp,0x4
Lamp::Lamp()가 끝나면 실행 흐름이 다시 main으로 돌아온다.
add esp,0x4는 숨겨진 매개변수를 전달하기 위해 push eax로 스택에 올려 두었던 4바이트 공간을 정리한다.
이후로도 지금과 같은 코드 패턴이 계속 반복된다.
lea eax,[ebp-0x8]
push eax
call function
add esp,0x4
생성자뿐만 아니라 lamp.turn_on()처럼 인자 없이 호출되는 것처럼 보이는 코드도 저수준에서는 같은 흐름을 따른다.
먼저 lamp의 주소를 계산하고, 그 주소를 숨겨진 인자로 스택에 올린 뒤, 함수를 호출한다.
함수 안에서는 그 인자를 this로 사용해 lamp의 데이터를 읽거나 수정한다.
호출이 끝나면 호출자인 main이 숨겨진 인자 전달을 위해 스택에 올렸던 공간을 정리한다.
결국 C++의 메서드 호출은 겉으로는 lamp.turn_on()처럼 보이지만,
내부적으로는 turn_on(&lamp)와 비슷한 방식으로 동작한다.
원형과 모상, 클래스와 개체
플라톤 이야기
class와 object가 플라톤 철학과 관계가 있는 것은 아니지만,
붕어빵과 붕어빵 틀을 이용한 비유가 널리 쓰이듯이, 플라톤의 이데아를 빌려 OOP를 다른 관점에서 바라볼 수 있다.
플라톤의 『국가』 10권에는 침대와 테이블에 대한 예시가 나온다.
Well then, shall we begin the enquiry in our usual manner: Whenever a number of individuals have a common name, we assume them to have also a corresponding idea or form:—do you understand me?
I do.
Let us take any common instance; there are beds and tables in the world—plenty of them, are there not?
Yes.
But there are only two ideas or forms of them—one the idea of a bed, the other of a table.
True.
And the maker of either of them makes a bed or he makes a table for our use, in accordance with the idea—that is our way of speaking in this and similar instances—but no artificer makes the ideas themselves: how could he?
Impossible.
위 인용문을 철학적으로 논하려는 것은 아니고, OOP를 이해하기 위한 재료로 삼아 풀어보겠다.
현실 세계에는 여러 침대가 있고, 모양, 재료, 크기가 달라도 우리는 그것을 침대라고 인식할 수 있다.
그럴 수 있는 이유는 이상 세계에는 그 모든 침대를 침대라고 부를 수 있게 하는 원형이 있기 때문이다.
현실의 침대들은 그 원형을 본떠 만들어진 것, 즉 모상에 가깝다.
그 원형을 바탕으로 서로 다른 재료, 모양, 크기를 가진 침대들이 만들어진다.
여기서 중요한 점은 현실의 침대가 사라져도 원형 자체가 사라지는 것은 아니라는 점이다.
또한 같은 원형을 바탕으로 만들어진 모상이라도 각각은 독립적이다.
우리 집 침대가 부서졌다고 해서 친구 집 침대가 함께 부서지는 것은 아니다.
프로그래밍 세계로 옮겨 보기
class는 프로그램에서 다루려는 대상을 추상화해, 프로그래밍 세계에 원형으로 정의한 것이다.
object는 그 class를 바탕으로 실제 메모리에 만들어지며, 자기만의 상태를 가지는 개별 실체다.
object와 instance는 비슷한 의미로 사용되기도 하지만, 강조점이 다르다.
instance는 예시나 사례라는 뜻이다.
OOP에서는 어떤 object가 특정 class를 바탕으로 만들어졌다는 관계를 강조할 때 instance라는 표현을 쓴다.
예를 들어 Socrates는 Human 클래스의 instance다.
추상화의 범위 정하기
현실의 옷을 프로그래밍 세계로 가져온다고 해 보자.
처음에는 옷의 색을 표현하기 위해 int rgb 하나를 두면 충분해 보일 수 있다.
하지만 시간이 지나면 문제가 생긴다.
옷은 단색만 있는 것이 아니다.
2색, 3색, 4색처럼 옷에 몇 가지 색이 들어갈지 알 수 없다.
게다가 시스루 같은 옷은 어떻게 표현해야 할지 고민이 든다.
투명도를 표현하려면 rgba로 해야 할까?
점점 머리가 아파진다.
그래서 추상화(抽象化)가 중요하다.
사물에서 어떤 상태와 동작을 코드로 옮길지 정해야 하기 때문이다.
도대체 얼마나 추상화해야 할까?
추상화의 대상과 정도는 정해져 있지 않다.
현실의 모든 것을 완벽하게 옮기는 것은 좋은 추상화가 아니며, 애초에 가능하지도 않다.
무엇을 코드로 옮기고 무엇을 생략할지는 해결하려는 문제에 달려 있다.