전문가칼럼

DBMS, DB 구축 절차, 빅데이터 기술 칼럼, 사례연구 및 세미나 자료를 소개합니다.

특집2부_OOP적 개발을 위한 C++ 프로그래밍 최적화 기법

작성자
dataonair
작성일
2001-12-31 22:30
조회
2375









분야별 특성에 맞춘 Programming Optimization



프로그래밍 최적화. 코드 몇 줄을 줄이고 실행 속도를 높이기 위해 머리를 쥐어짜던 시절이 있었다. 이미 추억 저편으로 멀어진 그 기억 속에서는 그런 것이 바로 프로그래밍 최적화였다. 그럼 하드웨어의 성능이 예전의 슈퍼컴퓨터와 맞먹을 정도로 높아진 지금, 프로그래밍 최적화가 대체 무슨 의미를 가질 수 있을까 프로그램의 속도가 조금 빠르거나 느린 정도라면 이제 거의 체감할 수 없는 상황이 되지 않았는가. 성능이 아주 떨어지지만 않는다면 이제 약간의 실행 속도의 차이는 무의미해진 지 오래다. 이처럼 시대가 변했다고는 하지만 분명히 프로그래밍을 위해 갖춰야 할 최적화 항목들은 여전히 존재한다. 코드의 수를 줄이는 것이 아니더라도 OOP적 개발을 위한 기법들이 필요하고, 보다 개선된 프로그램을 위한 리팩토링도 필요하다. 심지어 예전처럼 속도를 따져야 하는 분야도 있다. 바로 임베디드 분야이다. 이번 특집에서는 각 개발 분야에서 중요하게 다뤄져야할 최적화 기법들에 대해 알아본다.



기획·정리
| 정희용 기자 flytgr@imaso.co.kr



OOP적 개발을 위한 C++ 프로그래밍 최적화 기법



정명수 | 삼성전자 메모리사업부



C++에 대한 이야기를 지면에 실어 나르자면 몇 백 페이지에 걸쳐서 써도 모자랄 것이다. 객체 지향적 프로그램 기법부터 C 영역의 포인터에 이르기 까지 하고 싶은 말도, 하지 못할 말도 많은 것이 C++이다. 감히 누가 여러 개의 머리와 수십 개의 팔다리가 달린 C++이란 괴물 언어를 불과 몇 페이지에 담을 수 있겠는가. C++을 사용하는 그 많은 프로그래머들만큼이나 많은 이야기가 담긴 것이 C++일 것이다. 필자는 C++를 객체 지향적 개념의 몇 가지 이슈를 가지고 이야기해 볼 것이다.


며칠 전에 필자의 여자 친구로부터 클래스(Class)와 스트럭처(Structure)의 차이에 대한 질문을 받았다. 필자의 대답은 ‘어떤 스트럭처에서요’라는 질문 한마디였다. 클래스와 스트럭처의 차이는 무엇인가 다들 잘 알고 있겠지만 일단 C의 스트럭처와 C++의 스트럭처는 다르다. 그도 그럴 것이 C++의 스트럭처는 C와는 다르게 Constructor와 같은 Class Abstraction의 필수 요소를 지원하며, 스트럭처 안에 함수를 바인딩 할 수 있다. 그럼 C++에서의 스트럭처와 클래스의 차이는 무엇인가. 사실 차이가 없다. 단지 멤버 선언 때 클래스의 기본 값(Default)은 Private이고 스트럭처는 Public이라는 것 외에는 다른 것이 없다.

적어도 언어적인 측면에서는 그렇다. 독자 여러분이 필자라면 같은 컴퓨터를 전공하는 여자 친구에게 단지 차이가 없다고 대답 하겠는가 물론 아니다가 정답일 것이다. 마찬가지 이유에서 C++의 최적화에서는 포인터의 이용, 코드 사이즈 등 언어 이용적인 측면만을 고려 할 수 없다. 왜 OOP를 다루는 언어는 적어도 OOP에 맞게 쓰는 것이 가장 큰 비중을 가지며 언어는 객체 지향적 패러다임을 구현하는 하나의 도구에 불과 하기 때문이다. 여기에서는 바로 그런 관점에서 C++을 살펴볼 것이다.



C++ 프로그래머 VS C 프로그래머




다음 질문에 대해 생각해 보자. ‘C로 구현하는 객체 지향은 불가능한가’ 잠시 책을 덮고 생각한 뒤에 다음 글을 읽어도 괜찮다. 우리는 개발자와 술자리에서 이런 화제를 가지고 밤 새워 가며 목에 핏대를 세우는 일이 허다하다. C로 C++처럼 객체 지향적 개발을 하는 것은 불가능 한가 물론 화려한 OOP는 어렵겠지만 잘 구조화된 C문법으로도 충분히 표현 가능하다. 상위 영역의 Object C로 제작하는 GTK+ 같은 곳에서 C++과 유사한 추상화를 가진 아키텍처를 쉽게 만날 수 있다. 또한 C의 전형적인 영역인 임베디드 소프트웨어에서도 구조체와 함수 포인터들을 이용하여 C++을 흉내 낸 추상화 코드들을 간혹 경험할 수 있다.

스트라우스트럽이 C++을 만들기 시작할 때에도 ‘C with data abstraction’라는 프로토타입 언어로부터 시작 되었던 만큼 C와 C++ 언어의 영역 차이는 애매하다. 이러한 언어를 사용하는 프로그래머도 그 차이를 명확히 구분하기 어려울 수밖에 없다. C++은 C의 불편한 점을 보완해주는 언어도 아니며 C보다 좀 더 고급 기술을 구사하는 도구도 아니다. 더욱이 현업에서 모든 문제를 한방에 해결 해주는 은 탄환(Silver bullet) 같은 존재는 더 더욱 아니다.

그럼에도 불구하고 우리는 왜 C++을 사용해야 하는가 그것은 문제 해결법이 C와는 판이하게 다르기 때문이다. 이것은 C와 C++의 언어학적 문제를 벗어난다. C 프로그래머와 C++ 프로그래머의 차이는 어떤 언어를 사용하는가가 아니라 어떠한 관점에서 문제를 해결 하는 가이다.

아무래도 하드웨어 개발자들이 주류를 이루는 필자의 현업에서는 가끔 개발 도중에 C언어를 자유자제로 구현하는 개발자가 ‘C++ 그거 하루면 하는데 뭘’이라는 얘기를 들을 때 마다 마음이 아프다. C++을 하루() 만에 하였다면 클래스를 쓰지 말고 잘 구조화 된 C를 쓰면 된다. 구조체에 각 메소드가 될 함수 포인터들을 선언하고 생성자가 될 멤버 변수를 넣어 이 함수 포인터들을 초기화 해주고 나서 전역에 존재 하는 new라는 함수를 만든다. 다시 이 함수가 각각의 구조체를 할당 받게 하면 그런 이야기를 하는 개발자들이 사용하는 클래스를 흉내 낸 C를 쉽게 만들 수 있다.


<리스트 1> Class를 흉내 낸 C코드

// 순수 C 파일이다. .
typedef struct _CLASS_MEMORY {
longlong address; // 메모리의 실제 주소 번지를 저장할 멤버
COMMAND command; // 호스트의 커멘드
longlong register; // MEMORY MAPPED IO
void (*SetRegiter)(longlong reg);
// 속성을 설정하는 멤버로 사용할 함수 포인터이다
void (*SetCommand)(COMMAND cmd);
// 커멘트를 컨트롤 할 함수 포인터
void (*CMemory)();
// 다른 함수 포인터들을 초기화 하고 하나의 청크로 만들어줄 생성자
} CMemory;
void * new(CLASS_TYPE type); // 전역 생성자



물론 추상화 할 수 있는 범위가 제한적이고 불필요한 코드가 들러붙기는 하지만 분명 클래스와 유사한 행동을 하도록 처리 할 수 있다. 특히 앞서 소개한 GTK+이나 brew등의 코드를 보면 이러한 기법들은 빈번하게 적용 되어 있는 것을 확인할 수 있다.

C의 구조체와는 다르게 C++에서는 가상 함수 테이블을 각 클래스 마다 가진다. 이러한 것들을 이용한 객체 지향적인 기법인 C++에서 다형성(Ploymorphism), 동적 바인딩(Dynamic binding), 클래스 상속(Inheritance)을 뺀다면 그것은 스트럭처와 같이 발라낸 자료 추상화 관점의 C++에 불과 하다. 다시 정리하자면, C++은 단지 OOP 패러다임을 구현 할 수 있게 해주는 언어적인 도구 일뿐이지 C++그 자체가 의미 있는 것은 아니다.


시스템 해석학적 관적 문제 해결법에 들어가기 전에 시스템 해석학적 관점에서 이를 관찰 해보자. C로 구현된 함수 모듈과 C++로 구현된 함수 모듈은 어떤 차이를 가질까 우리는 <그림 1>, <그림 2>와 같은 블록 개념의 다이어그램을 생각해볼 수 있다.


061226_ms_02-1.jpg


<그림 1> C 관점의 시스템



061226_ms_02-2.jpg


<그림 2> C++ 관점의 시스템


어떤 차이를 볼 수 있는가 C의 관점에서는 각각의 데이터들이 노드를 이루고 이것들이 다른 상태의 데이터로 이동할 때 링크를 함수로 만들고 있다. 하지만 C++관점에서 보면, 클래스라는 추상화 관점의 모듈이 노드를 이루고 각각의 관계가 링크를 이루게 된다. 이 둘은 큰 차이를 가져 온다. 흔히 시스템의 복잡도를 논하는 두 가지 중 하나는 시스템을 구성하는 컴포넌트 수이고 다른 하나는 그 구성원을 연결하는 링크의 수이다. 컴포넌트의 수는 n개로 상수에 비례하여 증가하지만 이 컴포넌트를 연결하는 링크인 함수들은 n개에서 2개를 선택하는 조합과 같으므로 n(n-1)/2 와 같다. 이 복잡도는 O(n2)으로 시스템이 커질수록 알고리즘의 복잡도를 제어하기 힘들어 진다. 사실 C언어로 작성하든 C++로 작성하든 결국 컴파일 하고 나면 기능과 데이터만 존재 하는 바이너리 파일에 불과 하다. 이러한 복잡도 제어는 C와 C++ 패러다임의 적용이 시스템 해석학적 입장에서 봤을 때 얼마나 복잡해지는지를 알 수 있다.



객체지향 문제 해결법




객체지향(Object Oriented)의 문제 해결법에 대해 알아보기에 앞서 다음의 함수를 잠시 살펴보자.


inline int square(int a) { return a *= a; }
inline float square(float a) { return a *= a; }



물론 이 함수를 보면서 Template을 통한 구현을 생각 하거나 Function pointer를 사용한 Generic Function을 생각 할 수도 있다. 하지만 필자가 이야기 하려는 것은 조금 다른 이야기다. square 함수의 정의역(Domain)과 치역(Range)은 무엇인가 정의역은 Integer나 float형의 숫자이고 치역도 마찬가지로 숫자이다.


static int factorial(int i)
{
if (i<2) return 1;
return i*factorial(i-1);
}


이 팩토리얼 코드에서의 공리(Axiom)는 무엇 인가. If (i<2) return 1; 쯤 될 것이다. 우리는 흔히 C언어를 라이프 니찌가 정의한 수학의 함수를 모방하고 있다고 생각할 수 있다. 프로시져(Procedure)라는 이름으로 함수와는 달리 프로세스 중심의 상태 변화를 나타내는 함수들도 존재하지만 그 또한 치역이 void인 함수일 뿐이다. 수학에서 1:1, n:1, n:n은 함수에 들어가더라도 1:n은 함수가 아니다. 여러분의 코드 중에 int float double Squre(int a)라는 함수가 없는 것과 마찬가지다.

그렇다면 소프트웨어를 개발한다는 것은 본질적으로 어떤 의미를 가지는가 실제 세계의 정보나 현상들 중 관심 있거나 구현 되어야 하는 부분만을 시스템으로 옮기는 시뮬레이션이 가장 큰 의미를 가질 것이다. 수학 또한 실세계를 매핑하는 것의 일종인 언어라고 볼 수 있으니 말이다. 다만 수학으로 증명되지 않는 것이 얼마나 많은가 우리는 윈도우에서 사용자가 클릭하고 입력하는 것을 정의역(Domain)으로, 화면의 출력 내용을 치역으로 불 수 있다. 이러한 사용자의 움직임을 수학적인 함수로만(수학적으로 증명되는 사실보다 증명하지 못하는 사실이 훨씬 많다.) 정의하기에는 배보다 배꼽이 더 큰 것처럼 오버헤드가 들며, 이러한 행동들을 수학적 증명으로 표현하기에는 무리가 있다. 우리는 이 같은 실세계를 컴퓨터 내부로 반영하기 위하여 각각의 개체들을 클래스의 속성(Attribute)과 행동(Method)들로 정의하고 이들의 관계를 통해 좀 더 편리하게 실세계를 추상화 하고 복사 반영할 수 있을 것이다.

특별한 수학적 증명 없이도 우리 눈으로 보고 느낀 것을 추상화에 이용할 수 있고 그러한 추상화 단위들이 엮어져서 하나의 프로그램이 된다. 그 덕에 좀 더 많은 분야의 사람들이 프로그램이라는 것을 할 수 있게 되었다. 이러한 객체 지향적 문제 해결법에서 중요한 것은 개체들의 동등함이다. 우리는 간혹 코드에서 전역 변수나 전역 함수 등을 보게 된다. 이러한 개체들은 클래스로 구현된 내용들과 동등한가 클래스의 속성과 행동은 그 클래스 안에 종속적이다. 생각해보면 당연한 것이다. 사람이라는 클래스를 제작할 때 사람이 먹고 입고 잔다는 행동과, 내부적으로 생각, 사고, 성별 등을 가지는 것은 사람에게만 종속적인 것이다.

이러한 행동들이 다른 여타 동물, 사물 등의 행동들과 엮이면서 상태가 변화하는 것인데, 앞에서 말한 전역 변수나 전역 함수를 살펴보라. 이 둘은 죄악인 코드이다(물론 현업에서 이러한 코드를 남발 하기도 한다). 모두 클래스가 동등 한데 이 둘만 신(神)인 것이다. 어디든지 존재 할 수 있고 어떤 사물이든지 변화 시킬 수 있는 이러한 전역함수와 변수들은 모두 제거되어야 하는 것이다.

그렇다면 객체 지향적 언어에서의 스트럭처는 어떨까 OOP언어에서 스트럭처라는 것이 필요한가에 대한 질문을 해 볼 수 있다. 객체 지향적 언어의 구성이 모두 객체라면 스트럭처는 자료 추상화에 필요 없는 것이 당연하다. 필자가 말하고자 하는 것은 C++언어로 사용되는 곳에 모든 구성 원자와 분자들은 클래스로 이루어져야 하고 모두 코드 상에서 평등한 위치에 있어야 한다는 점이다.

필자와 친분이 있는 한 교수님의 의뢰로 학생 시절에 RSA라는 회사의 Code Wizard라는 프로젝트의 외주를 맡은 적이 있다. 이 프로젝트의 요구 사항은 C 코드를 뜯어 붙이는 식의 콤포넌트로 어린아이들이 쉽게 코드를 생성하는 일종의 툴을 만들어 달라는 것이었다. <화면 1>은 그 프로그램의 실행화면이다.


061226_ms_02-3.jpg


<화면 1> Component 기반의 Code Wizard


이러한 모습에서 좌측 코드를 구현하게 되는 컴포넌트들의 구현을 잠시 생각해보자. 좌측 컴포넌트들은 자유자재로 뜯어 붙일 수 있어야 하기에 윈도우의 다이얼로그로부터 상속 받아 구현하면 될 것이다. 코딩에 들어가기 전에 여러분이 생각할 것은 클래스 간의 다이어그램이다. 이 프로그램의 프레임워크를 제작했던 필자의 후배는 다이얼로그로부터 상속 받아 각각의 If 클래스 else 클래스 등을 만들어 내고, 그것들을 관리하는 리스트를 제작 하였다. 지금 독자가 글을 읽으면서 뭔가 잘못된 것을 느끼지 못한 독자가 있다면 C++을 제대로 사용하지 못하고 있는 것이다. 상속이라는 개념은 들어갔지만 생각해보면 모든 컴포넌트들이 유사한 속성을 띄게 된다. 이러한 컴포넌트들의 상위에 인터페이스 될 추상화 클래스가 존재 한다면 다형성을 통해서 모두 하나의 리스트로 관리 할 수 있다. 또, 프로젝트가 점차 확장됨에 따라 하나의 변경 사항을 고치기 위해 모든 클래스를 수정수정하면 된다. 별것 아닌 것 같은 이런 설계에서도 우리는 막연하게 OOP의 C++언어를 단지 간단한 자료 추상화의 도구로 사용하고 있는 것이 아닌지를 살펴보아야 한다.

당신이 C++ 개발자라면 지금 당신의 코들을 열어보라. 그 코드에서 다형성(Ploymorphism), 동적 바인딩(Dynamic binding), 클래스 상속(Inheritance)이 나오는지를 확인 해보라. 여러분이 제작한 클래스가 10개가 넘어가고 한 클래스 안에 메서드가 15개 이상 넘어가는 코드(수치는 특별한 의미는 없다. 단지 프로젝트의 크기에 대한 막연한 정의를 할 뿐이다)에서 다형성과 동적 바인딩이 없다면 당신은 단지 C++을 C처럼 사용하고 있는 것이다.



동적 바인딩(Dynamic binding)




바인딩이란 단어는 언제나 들을 수 있는 단어이다. 그럼 바인딩이라는 것은 정확히 무엇을 의미하는 것인가. 원래 바인딩이라는 단어의 사전적인 의미는 ‘묶다’이다. 그럼 우리가 쓰는 언어에서 바인딩은 무엇을 의미 할까. 이것은 Caller와 Callee의 주소를 묶어주는 것으로 생각할 수 있다.

만약 우리가 DriverEntry()라는 함수에서 PassNextTo Driver()라고 함수를 호출 하였다고 생각해보자(DriverEntry는 Caller이고 PassNextToDriver는 Callee이다). PassNextTo Driver라는 함수가 0x7FFFFFFF에 위치한다고 가정하면 Caller 쪽 코드는 에셈블러로 bch 0x7FFFFFFF와 같은 식으로 번역 될 것이다. 이러한 것을 바인딩이라고 한다. 다시 번역된 어셈블러 코드를 살펴보면 미리 주소가 들어가야 한다. 하지만 다이나믹 바인딩의 경우 이렇게 미리 결정하는 것을 런타임 시점으로 바인딩을 미루고 프로그래머가 그런 것을 구현하는 것이 아니라 언어적인 입장에서 C++ 언어 시스템이 처리한다는 것이다.

C++에서의 다이나믹 바인딩과 일반 C언어로 구현되는 구조적인 프로그래밍의 차이를 보자. 쉽게 하기 위해 상위 프로토타입은 미리 정의 된 것으로 가정한다.


<리스트 2> 동적 바인딩 예제

CList List;
// CDialogObj라는 Interface를 입출력으로 하는 리스트이다.
List.push_back(CIfDlg(10, 20, condition));
// 생성자를 통해 if 다이얼로그 내용을 list에 밀어 넣는다.
List.push_back(hileDlg(10, 20, condition));
// while 다이얼로그를 밀어 넣는다.
.
While(!List.IsEmpty())
{
CDialogObj * object = List.GetRemoveHead();
// 리스트에서 인터페이스 형식으로 인출한다.
object->SetPosition(LEFT);
// 이 부분에서 가상함수가 바인딩 된다.
}


사실 이 코드를 이해하기 위해서는 다형성(Polymorphism)을 알아야 하지만, 바로 뒷 제목에서 이를 논하고 있으니 일단 넘어가자. Object는 ABC(Abstraction Base Class)로 인터페이스 역할을 하고 있으며 이는 순수 가상 함수로 되어 있다.

<리스트 2>에 들어가 있는 각 원자 클래스들을 꺼내어 Set Postion을 호출 하려고 한다고 해보자. 우리는 두 가지의 타입을 통해 리스트에 넣어 두었고 두 클래스에서 SetPosition이 각각 구현되어 있는데 while 루프 안에서는 CDialogObj라는 인터페이스로 그 노드를 받아 SetPosition을 호출한다.

여러분은 어느 클래스의 SetPosition을 호출 하겠는가 이 호출에 대한 것이 가상함수이다. 이 가상함수는 동적으로 런타임 시에 자신의 타입을 고려하여 함수를 바인딩 한다. 이것을 C++에서는 메시지 디스패칭이라고 하며 여기서 말하는 디스패치는 win32나, 디바이스 드라이버를 개발하는 개발자들이 아는 디스패치 루틴과 같다. 메시지라는 것은 언어상의 표현이며, 우리는 단지 인터페이스 입장에서 메소드 하나만 호출 할 테니 나머지는 언어 시스템 상에서 처리 하라는 의미다. 마치 메시지를 던지는 것과 유사하게 보이기 때문이다.

이러한 동작 방식은 OOP의 기본 메소드 호출 방식이다. 앞에서 이야기했던 Code Wizard의 프로그램의 문제도 이 방법으로 수정 할 수 있다.


<리스트 3> <리스트 2>를 정적바인딩으로 처리할 경우의 C코드

List list;
IFDLG *ifDlg = malloc(sizeof(IFDLG));
// 복사 생성자가 없으면 shadow copy가 일어나므로 포인터로 할당
WHILEDLG *whileDlg = malloc(sizof(WHILDLG));
Push_back(list, ifDlg);
// 리스트에 바인딩된 함수가 없으므로 전역 함수에 개체와 인자를 삽입
Push_back(list, whileDlg);
While(!IsListEmpty(list))
{
OBJECT *obj = GetRemoveHead(list);
switch(obj->type)
// 다이나믹 바인딩을 지원 하지 않으므로 tag를 사용해 분리한다.
{
Case IF_DLG :
((IFDLG *)obj)->SetPosition(LEFT);
// if다이얼로그의 위치를 변경
Case WHILE_DLG :
((WHILEDLG *)obj)->SetPosition(LEFT);
}


C언어로 작성된 동일 동작을 하는 코드를 보자. C에서는 태그나 타입에 대한 정보가 반드시 필요하다. C언어 시스템에서 이를 지원 하지 않는 탓에 우리가 직접 해야 하는 것이다. 이러한 코드는 switch와 tag를 이용한 분기로 가능하다. 실제로 MFC 같은 경우 메시지 맵이라는 것을 사용 할 때 tag와 함수 포인터로 이러한 것을 구분한다.

이번에는 개발자 입장에서 좀 더 곤란한 이야기를 해보자. 여러분이 Code Wizard 프로젝트를 수행하고 있는데 거의 완성이 다 되어 갈 때 쯤 클라이언트가 마음이 바뀌었다. while 대신에 loop라는 다이얼로그로 변경해주고, else 등의 다이얼로그도 넣어달라고 한 것이다. 이 경우, C++에서는 CWhileDlg 대신 CLoopDlg라는 클래스로 변경해주고 (물론 인터페이스로부터 상속 받은) CElseDlg가 추가 될 수 있을 것이다. 여기에 인터페이스가 잘 구현되고 상속 관계가 잘 엮어져 있다면 새로 추가되는 다이얼로그는 단지 상속만으로 구현 할 수 있다. 추가된 클래스 이외에 리스트의 핸들에서 변화하는 것은 아무 것도 없다. 그것은 가상함수와 다형성 때문이라고 말 할 수 있다. 여기에서는 간단한 코드를 이용한 시나리오를 예로 들었지만, 실제로 큰 프로젝트에서 이러한 문제는 유지보수, 시간 인력 면에서 우리의 생각보다 큰 위치를 차지한다. 그러면 가상함수와 밀접한 관련이 있는 다형성(Polymorphism)의 부분을 살펴보도록 하자.




다형성(Polymorphism)




다형성이란 무엇일까. 외국으로 수출 하는 자전거와 녹차를 생각해보자. 이 자전거와 녹차는 다른 클래스로 구현 될 수 있다. 사실 추상화 관점에서 보아도 이 둘은 완전히 다르다. 하지만 다시 생각해보면 외국으로 수출 한다는 관점에서 이 둘은 같을 수 도 있다. 다르면서도 같은 것 그것이 다형성의 개념이다. 우리는 이런 다형성을 통해 코드를 간결하게 하고 일관적인 관점에서 클래스를 관리 할 수 있다.



인터페이스


우리는 여기서 잠시C++의 인터페이스를 생각해보고 넘어가야 한다. 이미 알고 있는 것처럼 C++에는 인터페이스가 없다. 그렇다고 해서 C++을 가지고 다형성을 사용하지 못하는 것은 아니다.

왜 인터페이스라는 유용한 개념이 C++에는 없는 것일까 이는 C++의 태생과 연관된다. 1980년도에 벨연구소의 스트라우 스트럽에 의해 C++이 만들어질 때는 C언어에 기반한 객체 지향 언어를 만들고자 하는 것이 아니었다. 앞서 설명 한 바 있듯이 C언어에 단지 자료 추상화 기능을 추가하기 위해서 제작한 것이 C++의 초창기 언어인 C with data abstraction이다. 이에 그는 객체 지향적 언어의 장점을 살려 C++ Release 1.0을 1986년에 리비젼했다. C++의 태생이 절차적 언어인 C라는 것은 자바에 비하면 치명적인 약점을 가진다. 1980년대에 제작되기 시작한 C++은 초창기 객체지향적 언어 개념이 성숙하지 못한 시점에서 너무 일찍 생성된 탓에 인터페이스에서 빠지게 된 것이다. 우리가 C++의 역사를 이야기하려는 것이 아니므로 이쯤에서 구현 이야기로 넘어가보자. 그러면 C++에서는 인터페이스를 어떻게 흉내내야 할까. C++에서 클래스를 구현할 경우 인터페이스가 는 구체화된 클래스로 구현되어야 한다. 이 클래스에 속하는 모든 함수는 순수 가상 함수로 선언되어야 한다.

우리가 사용할 컨테이너를 간단한 리스트로 제작한다고 가정 해보자. 처음 우리가 해야 할 작업은 우리의 리스트에 들어갈 개체를 클래스로 자료 추상화를 해야 한다. 이것은 단지 클래스를 사용하는 것 외에 다형성을 사용하는 기초가 된다. 이 리스트에 들어갈 내용들은 아래 원자가 되는 것을 상속 받아 구현하게 될 것이다. 이러한 접근은 인터페이스를 사용한 다형성 리스트와 같은 맥락에서 이해 될 수 있다.


<리스트 4> 다형성의 최상위 비 단말 노드 Object Interface

// 다형성의 최상위 비단말 노드Object Interface
// 모든 하위 클래스의 공통점만을 추출 하여 삽입한다.
class CObject {
public:
virtual char * ToString() = 0;
// 구현을 제외한 순수 가상함수로 정의 한다
}
// List 성격을 띄는 구성 원자List Interface
// 여기서는 object의 상속을 받아서 링크드 리스트의 성격을 띄는 메서
드만 정의 할 것이다.
class CListItem : public CObject {
virtual CListItem * GetNext() = 0;
// 다음 리스트 아이템을 가져오는 메서드.
virtual void SetNext(CListItem *item) = 0;
// 리스트에 들어가는 아이템의 근간이 될 것으로 당연히 구현이 없다.
}



<리스트 4>는 리스트의 근간이 될 두 가지 인터페이스를 생성 했다. 이런 리스트를 선언할 때에는 몇 가지를 주의해야 한다. 모든 클래스의 내부 멤버와 return은 Pointer임을 살펴보자. 왜 Pointer로만 구성 되어 있을까 이는 OOP의 다형성 개념을 사용하기 위해 필수적인 아이템이다. 다형성을 이루기 위해서는 상위 인터페이스의 입장에서 핸들링해야 하는데 동적으로 바인딩 하기 위해서는 입출력이 포인터로 되어야 한다. 간혹 자바 프로그래머나 다른 여타 프로그래머가 포인터를 쓰는 것을 죄악인 코드로 분류 하고 있지만, C++에서는 동적 바인딩을 위해 포인터를 사용한다. 여기 포인터의 개념은 주소 번지를 가리키는 핸들러라기 보다 단지 인터페이스를 이용하기 위한 도구라고 생각하는 것이 좋다. 물론 포인터에 대한 위험 사항, 즉 내부 멤버의 포인터를 반환한다거나 뎅글링 포인터를 반환하는 일은 없도록 해야 할 것이다.

인터페이스를 선언 했으니 이제 내부를 채워 넣을 구체화 클래스를 구현 해보자. 구체화 클래스는 기본적인 리스트 동작을 구현하는 디폴트 아이템과 그 디폴트 아이템으로부터 상속 받아 구성 원자의 내용을 구현하는 구체화 클래스로 나뉠 수 있다. 이로써 우리는 이 리스트에 CListItem이라는 인터페이스로부터 상속 받은 모든 클래스들을 넣어 관리 할 수 있다.


<리스트 5> 리스트 아이템의 실제 구체화 클래스

// 구성원자로 이루어진 기본 Item, 리스트 아이템의 실제 구체화 클래스
// 기본 Item 클래스는 Interface에서 선언되어 있기만 한List link 메서드를 채워 넣
기 위해서 멤버를 선언하고 동작을 구현한다.
class CDefaultItem : public CListItem {
CListItem * nextItem;
// 당연히 포인터로 선언한다.
public :
virtual CListItem * GetNext() { return nextItem; };
virtual void SetNext(CListItem *item) { nextItem
= item;};
}


<리스트 6>의 세 가지 아이템을 구현하기만 하면 이제 리스트에서 아이템을 모두 리스트로 관리 할 수 있다. 또한 다중 상속을 통하여 자신의 부모 클래스가 리스트를 위한 Object 뿐만 아니라, 다이얼로그의 속성을 그대로 상속 받을 수도 있는 것이다. 물론 다중 상속은 신중해야 한다. 사실 필자는 다중 상속의 문제는 객체지향에서 올바르지 않다고 생각하지만, 이 부분에 대한 논의는 다음에 기회가 되면 다시 하도록 하자.


<리스트 6> 세 종류의 구체화 클래스

// 정수형 성격을 띈 구체화 클래스
// 리스트 아이템의 구현을 상속 받고 정수형 성격을 띄는 부분만 구현한다.
class CIntItem : public CDefaultItem {
int m_i;
// 정수형 인자. 이 부분은 동적으로 바인딩 할 것이 없다.
public :
CIntItem(int i) { m_i = i; nextItem = NULL;};
virtual char * ToString();
// 상위 오브젝트 인터페이스의 가상함수를 정수형 에 맞게 정의한다.
}
// 스트링 성격을 띈 구체화 클래스
class CStringItem : public CDefaultItem {
char * m_s;
public :
CStringItem(char *s) { m_s = s; nextItem = NULL};
virtual char * ToString();
}
// 좌표 성격을 띈 구체화 클래스
class CPointItem : public CDefaultItem {
double m_x;
double m_y;
public :
CPointItem(double x, double y);
virtual char * ToString();
}



이제 리스트와 구성 원자들이 모였으니 이를 사용 해보자. <리스트 7>을 보면 이것이 얼마나 깔끔하고 편리하게 사용 될 수 있는지 알 수 있다.


<리스트 7> 동적 바인딩을 위한 main 함수

int main()
{
CList * list = new CList;
// 리스트를 하나 새로 생성한다.
list->InsertList(new CIntItem(1));
// 리스트 인자로 생성자를 이용하여 힙 영역의 클래스를 삽입한다.
list->InsertList(new CStringItem("this is
sample"));
list->InsertList(new CPointItem(3, 4))
for(int i = list->begin() i != list->end(); i++)
printf("%s", list->GetNext()->ToString);
// 우리는 이 리스트에서 Interface입장에서 ToString을 모두 호
출한다.
list->Clear
// 리스트를 지우고 할당된 포인터를 반환한다.
}



사람을 동물로, 식물을 신으로..변화의 타입 캐스팅




우리가 흔히 사용하는 타입 캐스팅(Type casting)에 대해 생각해보자. 절차적인 언어상에서 조차 타입 캐스팅은 민감하고도 죄악적인 사항이다. 학교에서도 현업에서도 쓰지 말라고 그렇게 이야기해도 쓸 수밖에 없는 것이 타입 캐스팅이다. 코드라는 것은 작성 하면서부터 시간이 지날수록 돌이킬 수 없는 상태까지 복잡해지게 마련이다. 타입 캐스팅은 미숙한 학생에게는 미숙한대로, 바쁜 현업의 프로그래머에게는 시간이 부족한대로 입맛에 맞춰 사용된다.



C의 타입 캐스팅 문제

이러한 C 언어상에서 타입 캐스팅이 잘못 사용되는 예를 보자. 파일 시스템 아래 Disk Class driver에 필터를 얹어서 작업을 한다고 가정하자. 그리고 상위 DeviceIoControl로 우리에게 필요한 주소 데이터를 주는 애플리케이션은 다른 프로그래머가 제작한다고 가정한다. 우리가 가공할 정보들이 담긴 것은 애플리케이션에서 DeviceIoControl로 내려 주는 주소 값과 파일 시스템에서 내려주는 주소 값을 비교하여 일치할 때 특정 작업을 수행하려고 한다고 생각해보자. 우리가 구현 할 때 파일 시스템이 내려주는 주소 값은 32비트 unsigned long 값으로 주소가 음수로 존재 하지 않기 때문에 문제가 없다. 하지만 여기에서는 주소 값을 32비트로 처리하되 특별한 문제가 없어 보이는 long으로 처리 했다고 생각해보자.


<리스트 8> 주소 값을 비교하여 특정한 루틴을 수행하는 드라이버 코드

NTSTATUS
SomethingDeviceControl (PDEVICE_OBJECT pDevObj, PIRP Irp)
// Device IO Control 디스패치 루틴
{
IrpStack = IoGetCurrentStackLocation(Irp);
// 현재 IO스택 로케이션을 가져옴

Switch(DeviceIoControlCode)
// 상위의 컨트롤 코드를 분류
{
case START_FILTER :
// 현재 시작 주소와 상위 어플리케이션에서 내려준 유저영역의 시스템 버
퍼를 비교하여 일치 할 경우 특정 동작을 수행하도록 한다.
If(Irp->AssociatedIrp.ByteOffset == (unsigned long
)IrpStack->Parameters.SystemBuffer)
{
ToDoSomthing();
}
….
}



이러한 경우에 부호 비트를 가지는 타입을 타입 캐스팅 하여 부호가 없는 타입에 넣으려고 하면 앞에서 말한 것과 같은 알고리즘은 전혀 동작 하지 않는다. unsigned long에 signed long의 주소 값을 넣으려고 할 경우 컴파일러에 따라 조금씩 다르기는 하지만 대부분의 컴파일러가 부호 정보를 잃어버리지 않기 위해서 부호로 존재하는 ‘1’ 비트를 모두 채워 넣어버리기 때문에 값이 완전히 달라지는 것이다. 상위 코드에서 타입 캐스팅이 차지하는 중요성이 크지 않은 것처럼 느껴질 수 도 있지만, 임베디드나 커널 드라이버 개발 시에 생각보다 지루한 디버깅 작업을 가져 오게 하는 요소가 될 수 있다.

이러한 타입 캐스팅의 자체적인 문제 말고도 타입 캐스팅에는 코드 상에 존재 하지만 가시적으로 눈에 보이지 않는다는 것이 중요하다. 프로그래머들은 가끔 자신의 코드가 모두 자신의 손바닥 위에 있다고 착각하는 경우가 많다. 당연히 디버깅 시 명시된 타입 캐스팅 정도는 논리적 문제 해결보다 훨씬 뒷전이다. 하지만 디버깅 시 갑자기 값이 뒤 바뀌는 경우나 수치의 비교 상에서 값이 틀어지는 경우, 논리적 문제보다 타입 캐스팅의 포션이 훨씬 높다.



타입 캐스팅이 C++에서 가지는 의미

타입은 C++에서는 하나의 클래스를 의미한다. 이것은 객체 지향적인 입장에서 보았을 때 C언어의 타입 캐스팅과는 비교도 안 될 만큼 큰 문제로 동작 한다.

예를 들어 아들이라는 클래스를 아버지로부터 상속 받았는데 타입 캐스팅을 이용해 이를 다시 아버지로 바꾼다고 생각해보자. 아버지는 아들이 될 수 있고 아들은 아버지로 그 본질 자체를 변경 할 수 있다. 또한 사람으로 만들어놓은 CPeople이라는 클래스에 CDog dog = (CDog *)people; 이라는 코드를 써서 그 자체를 변경 할 수도 있다. 이는 코드상의 언어적인 문제를 넘어 객체 본질 자체가 변경되는 말도 안 되는 현상을 야기 한다. 사람이 한 순간에 개나 동물로 변한다는 것이 정상인가 C++에서는 타입 캐스팅이 타입을 변경하는 것을 넘어 사람을 식물로 사람을 동물로 변형 할 수 있는 키워드라는 점을 명심해야 한다. 아침에 출근 하였더니 내 직장 동료들은 다 어디 가고 화초들만 책상 앞에 앉아 있는 꼴이 된다. 다시 한 번 말하지만 프로그래머는 신이 아니다.


C++ 스타일의 타입 캐스팅



시간에 쫓기고 사람에 치이면서 프로젝트가 진행되면 될수록 죄악인 코드를 써내려 갈 수 밖에 없는 것이 또한 프로그래머의 숙명이다. 우리는 C++을 쓰면서 어쩔 수 없이 타입 캐스팅을 강행하여야 하는 경우가 있다. 이러한 경우에 우리는 어떻게 그 치명적인 약점을 조금 이나마 줄이고, C++에서 지원하는 타입 캐스팅을 사용해야 하는지에 대해 생각해보자.
타입 캐스트의 경우에 크게 네 가지의 역할을 하는 캐스팅으로 분류 해 보자(사실 이러한 네 가지 종류는 C++에서 지원한다).


1) 일반적인 타입 캐스팅
2) const와 같은 한정자 타입 캐스팅
3) 상속 관계를 가진 계층에서 사용하는 캐스팅
4) 함수 포인터의 캐스팅


먼저, 이러한 캐스팅은 어떤 방식으로 사용해야 하는지에 대해 알아보자. 보통 타입 캐스팅을 할 때에는 다음과 같은 Syntax로 진행한다.



Unsigned 타입

타입을 이야기하다 보니 unsigned 관련 내용이 나와서 한마디 더 붙이고 넘어가고 자 한다. 다음과 같은 unsigned 타입을 생각해보자. 이 함수는 디바이스 드라이버 단에서 소팅 되어 들어오는 배열의 자료를 이진 탐색하기 위해 작성됐다. 디바이스 드라이버 개발자들, 특히 파일 시스템 개발자들은 unsigned값을 많이 사용한다. 이는 파일 시스템과 관련된 주소 정보는 음수 값을 가지는 경우가 드물기 때문이다.
Int binary_search(int *arr, long arr_len, int item)
{
unsigned long lower = 0L
unsigned long upper = (unsigned long) arr_len;
unsigned long mid;
ASSERT(arr_len >= 1L, "binary_search expects arrays
of length >= 1");
do {
mid = (lower + upper) / 2L
// 현재 비교할 값을 위해 반을 잘라 중간 인덱스 값을 찾는다.
if (item > arr[mid])
// 소팅이 되어 있으므로 mid의 값부터 비교할 대상의 item 값과 비교한다.
lower = mid + 1
// 중간치 보다 크므로 lower값을 mid 바로 상위 인덱스로 정한다.
else if (item < arr[mid])
upper = mid - 1
// 중간치 보다 작으므로 upper값을 현재 mid값보다 한단계 아래 인덱스로 잡음
else /* exact match */
return mid;
// 값을 찾았다.
} while (upper < lower);
// Base condition , 서치가 끝나서 Upper와 Lower 플래그의 위치가 뒤
집어지면 종료
}
눈썰미 있는 독자라면 금방 찾았겠지만 Base condition에서 비교 되는
upper값은 계속 1씩 감소하지만 이 알고리즘은 절대 멈추지 않고 무한
대로 동작한다. 정답은 코드에 있으니 직접 찾아보자.



C스타일의 타입 캐스팅 : (type) Expression;


이러한 타입 캐스팅을 쓰지 않고 C++ 에서는 아래와 같이 사용 한다.


C++ 타입 캐스팅 : static_cast(Expression);


우리는 일반적인 타입 캐스팅을 할 때 static_cast라는 키워드를 사용 할 수 있다. 이는 C언어의 일반적인 캐스팅과 같이 동작 한다. 대신 소스에서는 확연하게 눈에 띄게 된다. <리스트 9>를 살펴보자.

우리는 이러한 캐스팅을 통해서 어드레스의 연산 시 타입 캐스팅에 대한 존재감을 확실히 느낄 수 있다. 또, 다양한 캐스팅 연산자를 통해 캐스팅되는 범위를 1/4로 줄여서 생각할 수 있다. static_cast의 경우 클래스나 스트럭처를 기본 타입으로 변환 할 수 없으며, C와 같이 기본 타입을 기본타입의 포인터로 변환 할 수 없다.


<리스트 9> C++ 스타일의 타입 캐스팅 예

longlong address;
long deviceAddress;
int deviceNum;
address = static_cast deviceAddress /
static_cast device_num;



다음으로 const_cast를 살펴보자. 우리는 C++을 사용하면서 volatile 등의 키워드를 사용하게 된다. 이는 컴파일러가 최적화할 수 없도록 하는 키워드이자, 캐시를 사용하지 않고 인스트럭션을 메모리로부터 바로 사용하게 하는 역할을 한다. 이러한 volatile의 타입을 변환하기 위해서는 const_cast를 사용 할 수 있다.

세 번째로 dynamic_cast에 대해 알아보자. 이 캐스팅은 필자 개인적으로는 좋아하지 않는 종류이다. 우리 아버지를 필자로 변환하게 하는 캐스팅이기 때문이다. 상속관계를 가진 클래스 관계에서만 캐스팅이 가능하며, 다른 곳에서는 사용 되지 않는다.
마지막은 reinterpret_cast이다. 이 연산자는 함수 포인터 등을 변경하게 할 때 사용하는데 이러한 연산자가 적용된 코드들은 컴파일러에 따라 다르게 동작한다. 함수 포인터를 자주 사용하는 필자로서는 개인적으로 아끼는 타입 캐스팅이지만, 코드의 이식성이 불가하다는 이유로 잘 사용 되지 않는 캐스팅 방법 중에 하나이다.

세분화된 타입 캐스팅은 타입 캐스팅을 보편화하거나 프로그래머의 급한 마음을 합당화 시키는 도구가 아니다. 단지 일반적인 타입 캐스팅의 폐해를 조금이라도 줄여주기 위한 것일 뿐이니 사용하지 않는 것이 가장 좋다.




new와 malloc




메모리 할당을 할 때 우리가 사용하는 new를 한번 살펴보자. C++ 프로그래머인 여러분에게 직장 상사가 되었건 학교 교수님이 되었건 메모리 할당을 받을 때 특정 메모리 번지로부터, 또는 특정 메모리 번지로부터 할당 받아 있는 buffer를 사용하여 프로그램을 하라고 하면 여러분은 어떻게 하겠는가 이러한 작업은 메모리 맵 IO를 쓰는 경우에 유효한 기법인 덕에 유용하게 사용될 수 있다. 가장 간단한 방법은 C를 감싸는 Wrapper class를 하나 만드는 것이다. 즉 Buffer를 받아서 관련된 자료 구조형으로 바인딩 시킨 뒤 반환하는 것이다.

대충 이런 식이 될 것이다. 그러면 메타정보가 있는 2바이트와 데이터 영역의 8바이트로 바인딩 된다. 이러한 클래스의 문제는 무엇인가 어떤 특징을 가진 클래스의 내용이 아니라 단지 메모리 오프셋만큼을 파싱하기 위해 선언된 정체 모를 객체이다. 이런 경우 우리가 잘라야 하는 내용이 더 추가 된다면 우리는 이러한 클래스에 정체 모를 특징과 메소드를 추가 하게 된다. 그리고 사실 우리가 하고자 하는 것은 단지 파싱이 아니라 특정 개체가 생성될 때 그 개체의 메모리 위치를 미리 선언된 곳으로 바인딩 하려고 하는 것이다. 우리는 여기서 operator new와 new 그리고 replacement new를 생각해볼 수 있다.

C++에서 지원되는 new에 대해서는 우리의 버전으로 변경 할 수 없다. New의 동작은 크게 malloc과 같이 청크 단위의 초기화 되지 않은 메모리 단위를 할당 받고 그 메모리에 정의된 클래스의 데이터 타입에 맞게 바인딩 한 뒤 초기화 과정을 거쳐서 우리에게 전달된다. 그러면 우리는 특정 메모리 번지에 할당된 버퍼를 사용하여 우리만의 new 연산자를 만들기 위하여 할 수 있는 것은 키워드 new가 아니라 operator new에 대한 오버로딩(overloading)이다. operator new의 경우 malloc과 같이 void * 타입의 메모리 청크를 던져 주기 때문에 우리가 원하는 버퍼 영역으로 교환하면 된다. Operator new 이외에도 replacement new가 있는데, 사실 이 replacement new는 operator new의 원형에 단지 아규먼트 하나만 추가하여 똑 같은 방식으로 오버로딩한 연산자이다. 상위 코드에서 구현했던 파싱 문제를 각 클래스의 replacement new 생성자에 적용하고 특정 메모리 번지의 buffer를 받은 그 클래스를 바인딩 시키는 구조로 이루어지면 될 것이다.


<리스트 10> 메모리 맵을 위한 유틸리티 클래스

class CMemoryMap {
struct context {
int meta;
long data;
} // 메모리의 오프셋 만큼 잘라내기 위한 타입 선언
public :
context * mappedInfo(void * buffer);
}
// 특정 메모리 번지로부터 Context 자료형으로 반환하여 특정 오프셋
을 Context 타입처럼 사용하게 한다.
context * CMemoryMap::mappedInfo(void * buffer)
{
context * tempContext;
return tempContext = static_castbuffer;
}



<리스트 11> Replacement new에 의해 매모리 맵 기능이 오버로딩 된 Class

class context : public CObject {
.. // 필요한 멤버들을 선언한다.
public :
inline void * operator new(size_t, void * buffer)
// new를 할당 받을 때 특정 메모리 번지를 입력으로 받는다.
}




느림보 평가(Lazy evaluation)




이번에는 느림보 평가(Lazy evaluation)이라는 기법에 대해 알아보자. 느림보 평가 메커니즘은 APL이나 LISP, Haskell과 같은 언어에 사용된다. 이 메커니즘이 적용된 언어는 실제 사용될 때까지 평가(evaluation)를 하지 않는다. 하나 두 가지 언어로 작성된 리스트이다. 참고로 C++은 즉시 평가를 하고 Haskell은 느림보 평가(Lazy evaluation)를 한다.


<리스트 12> Lazy evaluation이 된다고 가정한 C++의 리스트 코드

CList * CList::MakeList()
{
CList * currentList = new CList();
// 새로운 리스트 원소를 할당 받는다.
currentList->value = 1;
// 리스트 원소 값을 초기화 한다.
currentList->next = MakeList();
// 리스트의 다음에 재귀적으로 다음 리스트 원소를 할당 받는다.
return currentList;
}



C로 작성된 <리스트 12>의 의도는 다음과 같다. 리스트의 헤더를 하나 만들고 헤더로부터 MakeList()를 재귀적으로 호출하여 리스트를 빌드 하는 것이다. C나 C++, 자바와 같은 언어에 익숙한 프로그래머는 이 코드를 보고 바로 말도 안 되는 소리를 할 수 있다. 이 리스트에는 Base condition이 존재 하지 않는 탓이다. 이 리스트는 호출 되는 즉시 계속 자신을 할당 하다가 결국 오버플로우로 죽거나 셧다운 버튼이 눌려질 인생이다.

하지만 Haskell의 경우를 보자.



makeList = 1 : makeList


이 Haskell의 코드는 C++에서 작성한 리스트와 같이 1로 시작하는 노드를 가지고 리스트를 무한 확장하여 빌드 하는 것인데도 정상적으로 동작한다. 이유는 무엇 일까 그것은 게으른 평가를 하기 때문이다. 즉 리스트를 선언해두고 동작 할 때만 그 만큼의 리스트를 생성한다. C++은 키워드 연산자에 대하여 부분적으로 게으른 평가를 하지만 Haskell과는 근본적인 메커니즘이 다르다.
또 다른 예를 한 번 살펴보자. 약간 억지스럽지만 만약 C++에서 if라는 키워드가 없다고 가정 해보자. 우리는 if라는 키워드가 없으므로 if라는 사용자 정의 함수를 사용하여 동작 시켜야 한다. myif(cond, trueValue, falseValue)라는 사용자 정의 함수를 제작 하였다고 가정 해보자. 첫 번째 파라미터는 조건문이며, 두 번째 파라미터는 조건이 true일 때 리턴 되는 값 그리고 세 번째는 false일 때 리턴 되는 값이다.

이 myif()로 우리는 if의 대부분을 처리 할 수 있다. 다음과 같은 재귀 함수를 만들었다고 가정해보자.


fact( x ){
myif(x<=1, 1, x*fact(x-1));
}


코드 상에서 fact(3); 을 실행하면 어떻게 될 지 직접 생각해 보자. 이 코드는 성공적으로 동작 하지 못하고 무한 루프에 빠지게 된다.


=> myif(3<=1, 1, 3*fact(3-1));
=> myif(3<=1, 1, 3*myif(2<=1,1,2*fact(2-1)));
=> myif(3<=1, 1, 3*myif(2<=1,1,2*myif(1<=1,1,1*fact(1-1));
=> myif(3<=1, 1, 3*myif(2<=1,1,2*myif(1<=1,1,1*myif(...);


이것은 C++에서 함수의 경우 return되는 Base condition까지 함수를 펼치기 때문에 파라미터 상에서 계속 재귀 되는 우리의 fact 함수는 결국 오버플로우 될 것이다.

자 그러면 우리는 게으른 평가기법을 어떻게 사용 하면 될까 C++이 게으른 평가 메커니즘을 지원 하지는 않지만 우리가 구현해서 쓰지 못할 만큼 갑갑한 언어는 아니다.

많이 사용 되고 있는 예로 행렬 연산을 살펴보자. 행렬 연산의 경우 사용자가 원하는 특정 원소는 전체 큰 행열 중에서 극히 일부에 지나지 않을 것이다. 우선 계속 계산을 미루고 있다가 마지막에 필요한 부분만 계산하게 할 수 있다. 또, 큰 클래스들이 배정(assign)되는 경우 실제 연산이 이루어지는 시점까지 메모리를 할당 하지 않고 단지 자신이 배정 되었다는 사실만 남겨두고 쓰기 정책이 오면 그때 메모리를 할당하여 배정하는 방식도 있다. 다만 우리가 잘 구분해야 할 것은 이 객체가 읽어지는 상황인가 아니면 써지는 상황인가 하는 것이다.

이와는 반대 되는 개념으로 너무 열중하는 평가(Over eager evaluation) 기법을 생각해볼 수 있다. 이는 사실 OS나 많은 여타 애플리케이션에서 사용되는 기법으로 캐싱(Cashing)과 Prefetching 등에 사용 될 수 있다.

메모리의 할당이 빈번할 경우 오버헤드를 줄이기 위해서 큰 메모리 청크를 미리 받아 놓고 할당된 memory pool에서 조금 씩 때어 주는 기법으로 속도를 개선 할 수 있다. 다른 경우에서는 자신의 클래스 중 Bitmap과 사용 되는 일부분을 미리 메모리에 올려놓고 Bitmap에서 히트가 있을 경우 메모리에서 읽어 오게 함으로써 속도를 개선하게 하는 기법이다. 이러한 기법은 기존 파일 시스템 관련 논문에서도 쉽게 발견 할 수 있으니 한번 웹에서 찾아보도록 하자.




반환 값 최적화(Return value Optimization)




우리는 가끔 값을 리턴 하는 함수를 제작해야 할 경우가 있다. 대부분 이런 경우에 직관적이지 못한 포인터 파라미터나 레퍼런스 파라미터를 사용하여 기본 값을 리턴 하게 마련이다. 4바이트 정도의 기본 타입들을 처리하는 경우에는 오버헤드가 적지만 그 타입이 사용자 정의인 클래스라면 가지고 있는 멤버와 메소드들에 따라서 무시 할 수 없는 오버헤드가 들어간다. 좌표 값을 계산하는 CPoint라는 클래스를 제작 하였다고 가정하고 임시 오브젝트의 생성 과정과 문제를 살펴보자.


임시 오브젝트(Temp object)

직관적으로 임시 오브젝트를 반환해야 하는 클래스를 생각해보자. 대충 <리스트 13>과 같은 코드를 가질 것이다.



<리스트 13> 반환 값 최적화가 필요한Class

class CPoint {
long x;
long y;
const CPoint operator + (const CPoint &lhs, const
CPoint &rhs);
}



<리스트 14> <리스트13>에 대한 구현부

const CPoint operator + (const CPoint &lhs, const CPoint
&rhs)
{
CPoint tempPoint;
tempPoint.SetX(lhs.GetX() + rhs.GetX());
tempPoint.SetY(lhs.GetY() + rhs.GetY());
return tempPoint;
}
CPoint a, b, c;
….
a = b + c;



+ 연산을 하기 위해서 우리는 오퍼레이터를 오버로딩 한다. <리스트 13>에서는 8바이트인 멤버들이 존재 하고 이러한 객체를 반환 하는 코드를 작성해야 한다.

<리스트 14>와 같은 경우 임시 객체는 더하기 연산을 위해 로컬 스택에 한 개를 반환 하면서 한 개가 생성된다. 복사는 return 되면서 임시 오브젝트로 한번 그 임시 오브젝트를 a에 복사하기 위해 한 번 더 메모리 카피가 일어난다. 이런 것을 막기 위해 가끔 우리는 다음과 같이 포인터를 반환하여 임시 객체의 생성을 막는다.


const CPoint * operator + (const CPoint &lhs, const CPoint &rhs)


물론 내부에서는 메모리의 동적 할당을 받았을 것이다. 이런 경우 사용을 위해서는 CPoint = *( a + b); 식이 될 것이다. 이러한 문장은 직관적이지 않을 뿐더러 오퍼레이터 오버로딩의 이점을 전혀 가져 올 수 없다. 또한 내부에서 동적 할당을 받아 생성된 메모리의 해제는 어디서 담당할 것인가 이러한 잘못된 코드는 레퍼런스로 반환하는 경우에도 마찬가지다. 언제 뎅글링 포인터가 될지 모르는 문제이기 때문이다.

문제의 정답부터 말한다면, 마치 변수에 대하여 DMA와 같은 동작을 하는 것처럼 보이게 하는 컴파일러 최적화 옵션이 있는데 이것을 사용하는 것이다.


RVO 반환 값 최적화

임시 객체 변수로부터 우리가 생성한 변수의 값을 이동해주는 이 소프트웨어 DMA같은 기술은 별 것 아니다. 단지 C++의 컴파일러가 최적화를 해주는 것이다. 이를 RVO(Return Value Optimization)라고 부르기도 한다.

원래 값을 반환하는 메소드는 임시 객체의 생성을 막을 언어적인 방법은 없다. 하지만 특정 패턴(내부 값을 반환하여 배정해야 하는 경우)에서 임시 객체 변수가 필요 없는 것은 사람의 눈으로도 아주 쉽게 식별할만한 내용인 만큼 각 컴파일러마다 이러한 최적화를 진행해준다. 컴파일러는 이러한 경우에 임시 객체 모두를 없애고 메소드 결과 값을 할당된 메모리에 직접 넣어 초기화 해준다.

단, 우리가 이러한 RVO를 제대로 사용하려면 어떠한 방식으로 문법을 사용 했을 때 컴파일러가 최적화를 처리 해주는 가이다.


Const CPoint operator + ( const CPoint &lhs, const CPoint &rhs)
{
return CPoint(lhs.GetX() + rhs.GetX(), lsh.GetY() + rhs.GetY());
}


이 코드는 내부에서 생성자를 통해 return 하는 경우이다. 이럴 때에는 사용자가 정의한 메모리에 직접 값을 넣어서 초기화하기 때문에 많은 프로그래머가 하는 고민을 해결할 수 있다.

사실 이러한 기법은 C++ 자체와는 관계가 없지만 컴파일러는 생각보다 우리가 모르는 곳에서 많은 최적화를 하게 된다. 어떤 코드에서는 프로그래머가 의도 하지 않은 최적화를 하게 되고, 어떤 곳에서는 프로그래머가 최적화 해주는 코드 패턴을 몰라서 최적화를 놓치게 되기도 한다. 그런 의미에서 최적화 문제에 민감한 프로그래머라면 이러한 컴파일러 동작 방식을 이해하는 것이 큰 도움이 된다.




다른 소프트웨어 엔지니어링 방법론과의 비교




다른 SE개발 방법론과 객체 지향적 C++를 비교하여 찬반을 가리자는 것은 아니다. 다만, 마무리 글을 쓰기에 앞서 필자가 독자에게 당부하고 싶은 이야기를 잠시 하고자 한다.

CBSE(Component Base Software Engineering)에서는 OOP를 너무 작은 단위의 구체화를 통해 복잡도가 올라가고 재사용성이 현저히 떨어져 실패한 것이라고 이야기 한다. 하지만 CBSE 또한 특정한 규칙이나 절차 없이 단지 큰 조각화를 하는 것으로 응용되고 있는 시점에서 그 컴포넌트 간의 복잡도는 재사용성을 현저하게 떨어뜨리는 요소라고 생각한다.

요즘 큰 이슈가 되고 있는 포항공대 강교철 교수님의 FORM (Feature Oriented Reuse Method) 바탕의 PLSE(Product Line Software Engineering) 방법론 세미나와 대화 속에서 재미있는 사실을 발견 할 수 있다. 그것은 사물의 특징 중 유사성과 이질성에 대한 구분이다. PLSE의 모델링 기법에서 이러한 유사성과 이질성에 대한 구분으로부터 제작하게 되는 컴포넌트들은 OOP에서 좋은 방향으로 응용될 수 있다.

C++을 C처럼 사용하는 것을 막고 있지 않듯이 OOP를 절차적 언어처럼 사용하는 것 또한 어느 누구도 막지 않을뿐더러 막을 수 있는 규칙 또한 없다.

우리는 개발에 앞서 PLSE와 유사하게 각 오브젝트 간의 특징을 분류하고 이러한 특징들 사이에서 유사한 그룹과 이질성을 가진 그룹을 나누고 인터페이스와 상속 관계를 좀 더 구체화 하여 자칫 OOP 프로그래밍이 절차적 프로그래밍으로 흘러가는 것을 의식 하여 막을 수 있을 것이다. 필자가 당부 하고 싶은 내용은 단 한가지이다. 클래스를 단지 추상화 개념으로만 사용한다면, 커다란 네모형 박스를 단지 잘게 쪼개는 절차적 프로그래밍 기법 밖에 될 수 없다는 것이다. 이 글에서도 몇 번이나 이야기 했지만, 그냥 달려들어 코딩하기 전에 같은 특징을 잘 묶어서 인터페이스를 형성하고 확장성을 고려하여 조금이라도 디자인을 해봐야 한다는 점이다.

지금까지 우리는 C++에 대한 원론적인 이야기부터 최적화 문제까지 살펴보았다. 사실 분량이 정해진 이 글에서는 필자가 하고 싶은 이야기나 필자에게 영향을 미친 OOP 고수들이 강조 하는 이슈들에 대해 1/10도 다 못 적었다.

필자는 C++에 대해 상당히 아끼는 편이다. 지금 현업에서는 도저히 적용 할 수 없는 문법과 패러다임임에도 불구하고 임베디드 분야에서도 차츰 객체 지향적 성격의 코드로 옮겨 가고 있다. 그 이유는 서두에 말했던 객체 지향적 문제 해결법과 귀결 된다.

TRUE와 FALSE, 이 두 가지로 증명 되지 않는 수학적 언어를 가지고 풀지 못하는 문제들이 그만큼 임베디드 쪽에서 많아지는 탓이다. 또, 임베디드 소프트웨어를 통해 삶을 이어가는 프로그래머들이 많은 것도 그 이유 중 하나이다.

필자가 마치면서 하고자 하는 이야기는, C++에서 최적화를 진행 한다는 것은 포인터나 메모리 바인딩에 대한 효율적인 이용 보다 그 언어가 가지는 본질적인 패러다임을 잘 이용 하여야 한다는 것이다. 물론 C에서 포인터와 메모리는 아주 재미있는 이슈를 가진 것 들이 많다.
그런 것 들은 다음에 임베디드 쪽 C언어 사용을 소개할 기회가 되면 다루기로 하고, 궁금한 사항이 있으면 필자의 홈페이지나 메일을 통해서도 질문 할 수 있다. 최근에 필자는 계속 메모리 관련 소프트웨어를 제작하고, 윈도우 디바이스 드라이버 쪽을 많이 개발 하고 있어 C++의 강점이 되는 것 들을 많이 잊어가고 있는 실정이지만, 독자 여러분은 객체 지향의 참된 묘미