기초적인 내용이지만, 되짚어보는 의미에서 작성한 자료입니다. 실은 팀원교육때 쓰려고 정리한 자료. 몇몇 빠진 내용이 있는데 나중에 보충하기로 하지요. (과연?!)
struct and class keyword
자 생각해봅시다. C++은 왜 C++일까요? C++의 시작은 C with Object 였습니다. 즉, C의 확장판으로 시작한 언어라는 겁니다. 물론, 지금은 C와 조금 달라져서 C코드를 C++에서 컴파일하는 경우에 신경써야할 점이 몇몇 생겨난 것도 사실입니다만, C와 밀접한 관계에 있다는 (그리고 매우 흡사하다는) 사실은 누구도 부정할 수 없을 겁니다.
C++에 남아있는 C의 잔재중 한가지는 struct입니다. 실제로 C++에서 struct와 class는 완벽하게 동일합니다. (심지어 내부 Layout마저 동일하지요.) class의 특성이라고 알고 있는 상속, 가상함수, 다중상속, 오버라이딩, this포인터 등등 모든 특성을 struct 역시 가지고 있습니다. 다만, class는 선언된 멤버함수나 멤버변수들의 기본 접근권한이 private이고 struct는 public이라는 점이 다릅니다. 하지만, 이를 제외하면 완벽하게 동일하지요. 그렇다면, 왜 struct가 남아있는 것일까요? 만약 C++에서 struct를 삭제하고 언어를 설계했다면, struct를 사용하는 C코드들은 C++에서 사용할 수 없게 될 겁니다. 이런 이유로 struct가 남아있는 것이지요.
C와 C++의 다른 점이라면, 다음 코드에서 확인할 수 있습니다.
struct my_struct { (...생략...) }; my_struct instance;
위와 같은 코드는 C++에서는 valid합니다만, C에서는 문법오류입니다. C에서는 다음과 같이 써주어야 하지요.
struct my_struct // struct 태그 선언 { (...생략...) }; struct my_struct instance; // 인스턴스 선언
혹은
typedef struct // 암묵적 struct 태그 선언 { (...생략...) } my_struct; // 타입화 my_struct instance;
C에서는 struct가 형식으로 취급되지 않고 일종의 tag로 취급됩니다. 따라서, struct를 타입으로 쓰려면 struct 태그이름 처럼 써주어야 하지만, C++에서의 struct는 그 자체로 이미 형식으로 취급됩니다. 따라서, 복잡하게 쓸 필요가 없습니다.
POD and Polymorphic
C++의 멤버함수는 어떻게 관리될까요? 간단하게 코드로 시작해봅시다.
class RX78 { public: void Shoot(std::size_t count); private: std::size_t bullets_; }; void test() { RX78 gundam; gundam.Shoot(33); }
test()함수의 2번째 줄, RX78::Shoot을 호출하는 부분을 봅시다. 이 부분의 함수호출은 어떤 식으로 이루어 질까요? C++은 내부적으로 별도의 선언을 하지 않은 멤버함수는 C함수와 유사하게 처리합니다. (이름은 복잡해집니다. C++ Decoration이라고 하지요.) 의사코드를 통해 어떤 형태로 컴파일 되는지 알아봅시다.
RX78_Shoot(&gundam,3);
C++ 컴파일러는 위와 유사한 형태로 Shoot(std::size_t) 멤버함수를 일반 C함수형태로 변화시킨 후 첫번째 인자를 해당 인스턴스를 넘겨주는 것으로 호출합니다. Shoot의 C스타일 정의는 다음과 같이 되겠지요.
void RX78_Shoot(RX78* this, std::size_t count);
물론, C++컴파일러가 진짜로 RX78_Shoot과 같은 간단한 이름으로 컴파일 하지는 않습니다. 보통 훨씬 복잡하고 알아보기 난해한 형태로 이름으로 바뀝니다. VC2005의 경우에는 ?Shoot@RX78@@QAEXI@Z와 같이 컴파일 됩니다. 규칙성이 있다고는 하는데, 딱히 알아서 쓸데가 없으므로 일단 넘어가지요. (decoration풀어주는 툴들도 있습니다) 중요한 부분은 첫 인수로 사실상 해당 클래스의 인스턴스가 넘어간다고 생각하면 편하다는 것입니다. 이렇게 이해해두면 나중에 다루게 될 tr1::function과 같은 템플릿을 이해하기 편해지니까요.
이런 형태의 기본적인 멤버함수 호출형태를 정적 바인딩(static binding)이라고 합니다. 컴파일 타임(static)에 어떤 함수를 호출할 것인지 결정(binding)이 이루어진다는 의미입니다. 정적 바인딩을 사용할 경우, C의 struct와 C++의 class/struct는 메모리 레이아웃에서 차이가 존재하지 않으며 사실상 동일하다고 보셔도 됩니다. 이런 이유로 정적 바인딩을 사용하는 C++ struct/class를 POD(Plain Old Data)라고 부르기도 합니다.
하지만, 이런 정적 바인딩 만으로는 객체지향적인 다형성을 구현할 수 없습니다. 다음 코드를 보지요.
class RX78 { public: void Shoot(std::size_t count){ std::cout << "Bam!" << std::endl; } private: std::size_t bullets_; }; class GP01Fb : public RX78 { public: void Shoot(std::size_t count){ std::cout << "Boom!" << std::endl; } }; void test() { RX78* gundam = new GP01Fb; gundam->Shoot(42); delete gundam; }
일반적으로 많이 등장하는 형태의 예제입니다. test()내에 있는 gundam에는 GP01Fb객체가 생성되어 담기지만, gundam 포인터 자체는 RX78형식입니다. 따라서, 그 다음 줄은 GP01Fb::Shoot이 아닌 RX78::Shoot을 실행하게 됩니다. 포인터의 형식에 따르지 않고, 실제 그 객체에 따라서 동적으로 멤버함수를 실행시킬 수 있는 방법은 없을까요? 즉, 어떤 함수가 호출될지 컴파일시 결정하는 것이 아니라 실행시 해당 객체의 종류에 따라 호출될 함수를 결정하는 방법 말입니다.
이 문제가 다형성 문제이며, C++은 동적 바인딩(dynamic binding)을 도입하여 이 문제를 해결하고 있습니다. 방법은 간단합니다. 해당 함수를 가상 함수 (virtual function)으로 선언해주면 해결됩니다.
class RX78 // 변경된 RX78 클래스 { public: virtual void Shoot(std::size_t count); // virtual 추가 private: std::size_t bullets_; };
이렇게 변경되면 컴파일러는 컴파일시에 호출함수를 결정하는 것이 아니라 실제로 실행되는 시점에 호출함수를 결정할 수 있도록 코드를 생성합니다. 의사코드로 표현하면 다음과 같습니다.
// __vftable_ptr은 컴파일러에 따라 이름도 다르고 프로그래머는 접근할 수 없는 녀석입니다! (*(gundam->__vftable_ptr))[0x00](gundam, 42);
위 코드에서 __vftable_ptr은 가상함수 테이블의 위치를 나타내는 포인터이며 해당 가상함수 테이블에서 함수 주소를 얻어와 호출하는 형태의 코드입니다. 컴파일러가 결정하는 사항은 현재 호출하는 Shoot이란 멤버함수가 가상함수 테이블에서 몇번째에 해당하는지입니다. 이런 형태로 C++에서는 다형성을 구현합니다. (실제로는 좀 더 복잡합니다. 다중상속등의 문제로…)
이쯤 되면, 다형성을 구현하기 위해 가상함수를 사용하는 클래스는 그렇지 않은 클래스보다 크기가 커진다는 사실은 직감하실 수 있을 겁니다. 당연하죠. 어떤 가상함수 테이블을 참조해야 하는지에 관한 정보 (가상함수 테이블의 포인터)가 추가되니까요. 그리고, 정적 바인딩만을 사용하는 클래스와는 다른 구조를 갖게 됩니다. 이 녀석들은 Polymorphic이라고 부릅니다.
Polymorphic을 다룰때는 조심해야 합니다. 실수로 memset과 같은 함수를 잘못 쓰게 되면, 가상함수 테이블 정보까지 날아가서 프로그램이 멈춰버릴 테니까요! 따라서, 초기화를 할 때는 memset(obj, 0x00, size(obj)); 같은 코드 말고, 각각의 변수를 직접 초기화 하는 것을 권장합니다. 초기화 문제 말고도, 메모리 풀링을 사용할 때 overflow가 발생하게 되면 가상함수 테이블이 날아가는 경우가 생깁니다. 조심 또 조심해야겠지요.
이런 구조의 다형성 구현에서 파생된 정보들은 C++이 런타임에 사용할 수 있는 거의 유일한 형식정보입니다. dynamic_cast와 같은 동적 하향 캐스팅이나 (RX78의 포인터를 GP01Fb의 포인터로 캐스팅하는 것 같은..) std::type_info와 같은 기능을 위해 유용하게 사용됩니다.
virtual inheritance
C++이 비판받는 포인트중 한가지는 다중상속입니다. 다중상속은 C++의 가장 강력한 기능 중 하나이지만, 복잡성과 다이아몬드 상속구조의 병패로 인해 많은 비판을 받고 있으며 Java같은 언어에서는 다중상속을 아주 빼버리는 모습을 보여주기도 합니다. 하지만, virtual키워드를 잘 알고 있다면 다중상속을 활용하는데 좀 더 편리하게 작업할 수 있습니다.
역시 예제로 시작을 해보지요.
struct A { A() : a(0){}; int a; }; struct B : A { B() : b(0){}; int b; }; struct C : A { C() : c(0){}; int c; }; struct D : B, C { D() : d(0){}; int d; }; void test() { std::cout << sizeof(A) << std::endl; std::cout << sizeof(B) << std::endl; std::cout << sizeof(C) << std::endl; std::cout << sizeof(D) << std::endl; } [/cpp] 위의 코드를 실행시킬 경우 sizeof(D)의 값은 얼마로 나올까요? 쉽게 생각하면, int변수가 4개이니 sizeof(int)*4가 될 거라 예상하기 쉽지만, 실제로는 sizeof(int)*5의 값이 나옵니다. 이유는 D가 상속받고 있는 B와 C가 각각 상속받은 A를 위한 메모리가 별도로 할당되기 때문입니다.<strong> 즉, B가 상속받은 A와 C가 상속받은 A를 위한 메모리가 별도로 잡힌다는 점이지요.</strong> 다음과 같은 코드를 컴파일 해보면 좀 더 명확해집니다. [cpp] D d; std::cout << d.a << std::endl; [/cpp] 위의 코드는 컴파일 되지 않습니다. 컴파일러가 B::a를 접근해야 하는지 C::a를 접근해야 하는지 명확하지 않다고 불평을 뱉을겁니다. D의 인스턴스를 B나 C로 캐스팅한 뒤에 접근하게 되면 이러한 메시지는 출력되지 않을 것입니다. 하지만, B와 C에서 사용하는 a는 서로 다른 a가 됩니다. 그렇다면 B와 C, 그리고 D에서 사용하는 a를 모두 같은 a로 사용하는 방법은 없을까요? 당연히 있습니다. :) 상속할때 virtual 키워드를 써주면 됩니다. [cpp] struct A { A() : a(0){}; int a; }; struct B : virtual A { B() : b(0){}; int b; }; struct C : virtual A { C() : c(0){}; int c; }; struct D : B, C { D() : d(0){}; int d; }; void test() { std::cout << sizeof(A) << std::endl; std::cout << sizeof(B) << std::endl; std::cout << sizeof(C) << std::endl; std::cout << sizeof(D) << std::endl; } [/cpp] 이런 형태로 클래스를 구성하면 a에 대한 모호성은 제거됩니다. 하지만, 클래스의 크기가 달라집니다! 결정적으로 커집니다! 이는, 가상함수테이블과 유사한 생성자테이블을 위한 포인터 공간이 생기기 때문입니다. 이런 공간적 오버헤드가 문제이긴 하지만, 유용하게 사용될 수 있는 부분이 있다면 사용하는 것이 당연하겠지요. (ex. Mutant패턴) <strong>추천사항: class의 destructor는 virtual로!</strong> "class의 destructor는 virtual로 작성한다" 이 명제는 거의 당연시 되는 권고사항입니다. 작성할 내용이 없더라도 일단 빈 껍데기라도 만들어두는 것이 속이 편합니다. 다음과 같은 코드를 보지요. [cpp] class RX78 { public: void Shoot(std::size_t count){ std::cout << "Bam!" << std::endl; } ~RX78() { std::cout << "RX78 Destroyed!!" << std::endl; } private: std::size_t bullets_; }; class GP01Fb : public RX78 { public: void Shoot(std::size_t count){ std::cout << "Boom!" << std::endl; } ~GP01Fb () { std::cout << "GP01Fb Destroyed!!" << std::endl; } }; void test() { RX78* gundam = new GP01Fb; gundam->Shoot(42); delete gundam; }
test함수에서 GP01Fb의 객체를 RX78의 포인터에 담았다가 delete하고 있습니다. 이러면 delete에서 GP01Fb의 destructor가 아닌 RX78의 destructor가 호출이 되는 문제가 발생합니다! RX78의 destructor가 virtual이었다면 이런 문제는 발생하지 않겠지요. 지금은 단순히 문자열을 출력하는 예제이지만, 문자열 출력이 아닌 리소스 해제와 같은 작업이 destructor에 있었다고 상상해보면 그 여파는 매우 머리아픕니다. 찾기힘든 메모리 누수현상이지요!
이런 상황을 미연에 방지하기 위해, 특별한 이유가 있는 경우를 제외하고는 class의 destructor는 virtual로 하는 것이 업계의 관례이자 선배들의 충고랍니다. 🙂
요즘 들어 포스팅이 늘어나시는 것 같네요. 혹시 글쓰는 연습 중이세요 ㅋ
좋은 글들 많이 보고 갑니다. 그나저나 시간 되시면 책도 한권 써보시는 것이… 물론 비공개로 ^^
네. 글쓰는 연습 중입니다. 🙂
회사에서 글 쓸일이 생기기도 했구요.
책은 언제나 써보고 싶지만, 제 필력으로 감당할 수 있는 일인지가 걱정입니다. 아하하.