[Translation] A Quick Introduction To C++ ---- Tom Anderson
[번역] C++에 대한 간단 소개 -- 원 저자 톰 앤더슨
1. Introduction
이 튜토리얼의 기본 전제는, 비록 객체지향 프로그래밍 패러다임으
프로그램을 단순하게 만드는데 도움이 되지만 객체지향 프로그래밍 언어인
C++는 잘 쓰이지 않는 특징들을 너무나 많이 포함하고 있어 매우
복잡하다는 것이다.
따라서 이 튜토리얼에서는 첫째 기본적이고 반드시 사용해야할 특징들
클래스, 멤버함수, 생성자를 설명하고, 둘째 다른 사람이 작성한
프로그램에서 사용되었다면 이해할 수 있어야 하지만 자신은 가능한
사용하지 말아야 할 특징들 단일 상속, 템플릿을 설명하고, 세째 나쁜
특징들로써 반드시 피해야할 특징들 다중 상속, 예외상황, 중복, 참조 등을
설명한다.
물론 어떤 프로그래밍 언어의 특징이 좋은가 나쁜가 하는가에 대해서는
견해차가 사람들마다 매우 다를 수 있다. 예를 들어 다중 상속의 특징이
반드시 필요하다고 생각한다면 다중 상속을 사용한 프로그램과 사용하지
않은 프로그램을 모두 작성해본 다음 비교해 볼 것을 제안한다. 이런
시도들이 어떤 특징이 유용한지 아닌지를 쉽게 알게 될 것이다.
2. C in C++
기본적으로 C++는 C를 포함하도록 언어가 정의되어 ANSI C 프로그램은 C++
프로그램으로 대부분 컴파일 된다. 하지만 중요한 다른 점도 있으므로
몇가지 나열한다.
1. 모든 함수는 사용되기 전에 선언되어야 한다. All functions must
be declared before they are used, rather than defaulting to type
int. ???
2. 모든 함수 선언과 정의 헤더 (definition headers)는 다음의 새로운
스타일로 정의되어야 한다.
extern int foo (int a, char* b);
C++에서 extern int foo(); 은 아무 인자를 받지 않는 함수 foo를
뜻한다. 이것은 C에서 인자 수와 타입을 지정하지 않은 함수 선언과
다른 의미이다. 혹자는 C 프로그램에 C++ 컴파일러를 사용해서 잘못
사용된 함수와 같은 에러를 잡아낼 수 있다고 한다. 대개 C 컴파일러는
그러한 에러를 그냥 지나친다.
3. C 함수를 C++ 프로그램에서 사용하려면 다음과 같이 선언해야 한다.
extern "C" int foo (int a, char* b);
왜냐하면 C++ 컴파일러는 프로그램에서 사용된 이름을 이상한 방법으로
바꾸기 때문이다. (Name mangling)
4. C++에서 키워드 new, delete, const, class 등은 이름으로써
사용하지 말아야 한다.
3. Basic Concepts
1. 클래스와 객체: 클래스는 C의 struct와 유사하고 struct과 같은
데이터구조에 대해 연산하는 모든 함수를 한 곳에 모아놓은 것이다.
객체는 클래스의 instance로 (struct의 instance인 자료와
유사하게) 객체는 같은 클래스의 다른 객체들과 동일한 함수를
공유한다. 물론 각각의 객체는 자신의 값을 가진다. 클래스는
이렇게 객체가 포함하는 데이터와 객체가 보이는 behavior를
정의한다.
2. 멤버 함수: 객체의 일부로 간주되는 함수이고 클래스 정의에서
선언된다. method라고 불리기도 한다. 클래스의 behavior를
정의하는 멤버 함수와 더불어 객체를 새로 만들때 무엇을 할지를
(즉, 객체의 데어터를 어떻게 초기화 할지를) 정의하는
constructor와 객체를 없앨때 무엇을 할지를 정의하는 destructor도
클래스이 behavior를 정의한다.
3. private vs. public 멤버: public 멤버는 누구나 읽고 쓰고 호출할
수 있고, private 멤버는 클래스의 멤버 함수만 읽고 쓰고 호출할
수 있다.
클래스를 사용하는 두가지 이유는, 첫째 자료와 이 자료를 다루는 함수들을
함께 모아서 전체 프로그램의 구조를 구성하기 쉽게 하고, 둘째 private
멤버를 사용해서 information hiding 방법을 제공해서 프로그램에서 정보
흐름 방식을 더 확실히 제어할 수 있다.
3.1 Classes
C++ 클래스는 C에서 struct와 유사하다. 사실 C++에서 struct는 public
데이터 멤버만 포함하는 클래스이다.
1. 멤버 함수
class Stack {
public:
void Push(int value);
int top;
int stack[10];
}
void
Stack::Push(int value) {
ASSERT(top<10);
stack[top++] = value;
}
이 클래스는 두 data 멤버 top, stack과 하나의 함수 멤버 Push를 갖고
있다. class::function 표기법은 어떤 클래스 class의 멤버 함수
function을 지칭한다. 함수는 클래스 선언 아래에 정의한다.
여담으로 ASSERT의 사용에 대해서 언급한다. 위의 ASSERT는 스택의
오버플로우 되지 않았음을 검사한다. ASSERT를 통해 구현에서 가정했던
바를 분명히 소스 코드에 쓰는 것은 매우 좋은 습관이다.
일반적으로 class Stack의 정의는 stack.h 파일에 저장하고, Stack::Push와
같은 멤버 함수의 정의는 stack.cc 파일에 저장한다.
(c.f. stack.hpp stack.cpp)
Stack 클래스의 객체 s가 주어졌다면 s->top으로 해당 데이터 멤버 값을 읽을
수 있다. 또한 다음의 구문으로 멤버 함수를 호출할 수 있다.
s->Push(17);
멤버 함수 정의 내에서는 해당 클래스의 멤버들을 이름만으로 참조할 수도
있다.
멤버 함수 정의 내에서는 특별한 변수 이름 this를 통해 해당 멤버 함수가
호출된 객체에 대한 포인터를 얻을 수 있다.
위의 Stack 클래스에서 top을 멤버 함수 Full을 통해서 읽고 오버플로우를
검사하도록 바꿔보자.
class Stack {
public:
void Push (int value);
bool Full();
int top;
int stack[10]
}
bool
Stack::Full() {
return (top == 10);
}
void
Stack::Push(int value) {
ASSERT(!Full());
stack[top++] = value;
}
위의 ASSERT를 ASSERT(!(this->Full()));와 같이 사용할 수 있지만
this->는 멤버 함수 정의 내에서는 생략할 수 있다.
멤버 함수의 목적은 객체에 포함된 자료와 함께 객체의 타입에 따라 정의된
기능들을 하나로 묶는데(encapsulate) 있다. 멤버 함수는 해당 클래스의
객체를 정의하는데 필요한 메모리 공간에 포함되는 것은 아니다.
2. private 멤버
자료 멤버와 함수 멤버는 public이나 private으로 선언될 수 있다. 위의
Stack 클래스에서 Full 멤버 함수를 public으로 선언하면 top과 stack
멤버는 굳이 외부에서 사용할 필요가 없어지므로 이 데이터 멤버들을
private으로 선언하자.
class Stack {
public:
void Push(int value);
bool Full();
private:
int top;
int stack[10];
};
이제는 s->Full()은 물론 가능하지만 s->top은 컴파일 에러를 초래할
것이다.
public:과 private: 섹션의 위치를 물론 바꿀 수 있다. class 시작
부분에서 아무런 표시가 없다면 이 부분에 선언된 멤버들은 디폴트로
private으로 간주된다.
class Stack {
int top;
int stack[10];
public:
void Push(int value);
bool Full();
};
추천하는 스타일은 모든 것을 명시적으로 선언하는 것이다.
모든 데이터 멤버들은 private으로 선언하고 필요하다면 public 멤버
함수를 통해서 데이터 멤버를 다루게 해야 한다. 이러한 방식은 데이터
멤버들을 내부적으로 재정의해도 외부에서 이를 다루는 부분은 바꾸지
않아도 되므로 전체 시스템을 더욱 모듈화하게 만든다.
3. 생성자와 new 연산자
C에서 Stack 타입의 객체를 새로 만드려면 다음과 같은 코드가 필요하다.
struct Stack *s = (struct Stack *)malloc(sizeof(struct Stack));
InitStack(s, 17);
C++에서는 다음과 같은 코드가 필요하다.
Stack *s = new Stack (17);
new 함수는 위의 C코드에서 malloc 역할을 한다. 스택을 어떻게 초기화
할지를 지정하려면 Stack 클래스이 멤버함수로서 constructor 함수를
선언한다. constructor 함수의 이름은 클래스의 이름고 동일해야 한다.
class Stack {
public:
Stack (int sz);
void Push(int value);
bool Full();
private:
int size;
int top;
int* stack;
};
Stack::Stack(int sz) {
size = sz;
top = 0;
stack = new int[size];
}
new 연산자는 자동으로 객체를 새로 만들고 이 객체의 constructor 함수를
호출한다. new 연산자를 사용하지 않더라도 함수나 블럭의 시작 부분에
자동 변수로 객체를 선언한 경우라도 (예를 들어 void f() { Stack v;
... } ) 동일한 순서로 객체가 만들어지고 constructor 함수가 호출된다.
예를 들어
void
test() {
Stack s1(17); // 스택을 자동변수로 선언해서 만드는 경우
Stack* s2 = new Stack(23); // 스택을 new를 통해서 만드는 경우
}
constructor 함수에 인자를 주는 방법으로 위의 예제에서처럼 두가지가
있다. new를 통해서 객체를 만드는 경우 클래스 이름 다음에 인자 리스트를
주고, 자동변수를 선언하여 객체를 만드는 경우 변수 이름 다음에 인자
리스트를 준다.
constructor 함수는 반드시 선언하고 해당 클래스의 데이터 멤버들을
초기화 하도록 코드를 작성하는 것이 바람직하다. 물론 construtor 함수를
정의하지 않으면 컴파일러가 자동으로 만들어주지만 컴파일러가 만든
constructor 함수는 프로그래머가 의도하는대로 constructor 함수를 만들어
주지는 못할 것이다.
함수에 선언된 C언어의 변수는 시작 시점에 새로 만들고 함수 리턴 시점에
없어지듯이 위의 예제에서 객체 s1을 함수 시작 시점에 만들고 함수 리턴
시점에 없앤다. 하지만 new연산자를 통해 만든 객체는 힙에 위치하고 함수
리턴 후에도 여전히 남는다. delete 연산자를 통해서 직접 없애야 한다.
new 연산자는 배열을 만들때도 사용할 수 있다.
stack = new int[size];
new와 delete 연산자는 Stack 클래스의 객체에 대해서도 사용할 수 있을 뿐
아니라 int와 char와 같은 built-in 타입에 대해서도 사용할 수 있다.
4. destructor와 delete 연산자
C에서 malloc을 C++에서 new로 대신한 것 처럼 C에서 free를 C++에서
delete로 대신 사용한다. 예를 들어 위의 예제에서 만들었던 스택 객체
s2를 없애기 위해서 다음의 코드를 사용할 수 있다.
delete s2;
Stack 클래스의 destructor 멤버 함수는 ~Stack()이다.
class Stack {
public:
Stack(int sz);
~Stack();
void Push(int value);
bool Full();
private:
int size;
int top;
int* stack;
};
Stack::~Stack() {
delete [] stack;
}
destructor 함수가 할 일은 주로 새로 만들어 놓았던 데이터를 없애는 일이다.
많은 경우 이 함수를 필요로 하지는 않는다. 어떤 경우는 오픈한 파일을 닫는
일을 하기도 하고, (... and otherwise clean up after themselves.???)
객체를 없애고자 할때 객체의 destructor 함수를 부른다. new 연산자를
통해 만든 객체는 반드시 delete 연산자를 통해서 없애야 한다. 그렇지
않으면 객체를 만들기 위해 할당한 메모리를 해제하지 않아서 memory
leak이 발생할 수 있다. 또한 delete 연산자는 너무 일찍 호출하면
안된다. delete 후에 그 객체를 다룰때 의미없는 자료를 읽거나 씀으로
해서 디버깅 하기 어려운 에러가 발생할 가능성이 매우 높다.
만일 위의 test() 함수 예제에서 처럼 객체를 자동변수로 선언했다면 함수
코드 실행 시작 전에 자동으로 (컴파일러가 추가한 코드에 의해) 만들었던
객체를 함수 리턴 직전에 (컴파일러가 추가한 코드에 의해) 없앤다.
저자는 new 연산자를 통해서만 객체를 만들 것을 추천한다. 왜냐하면 C++
언어에서 자동변수로 선언한 객체의 constructor 함수와 destructor 함수를
호출하는 방식이 직관적이지 않기 때문이다. (???) 물론 new 연산자를
통해서 객체를 만드는 방식은 delete 연산자를 통해 객체를 없애야 하는
것을 잊어 버릴 가능성이 있기도 하는 문제점을 가지고 있다.
배열을 없애고자 할때는 배열의 원소가 아닌 배열 자체를 없애고자 한다는
뜻으로 Stack::~Stack()에서 처럼 []를 delete 연산자와 함께 사용한다.
delete [] stack;
3.2 Other Basic C++ Features
1. class Stack을 정의했다면 Stack 이라는 이름은 타입의 이름으로 사용될
수 있다. 물론 enums으로 정의한 이름 역시 타입 이름으로
사용가능하다.
2. 함수 정의를 class 안쪽으로 옮길 수도 있다. 이 경우 함수 정의는
inline 함수로 간주되어 이 함수를 호출하는 곳에 컴파일러에 의해
카피된다. 필요하다면 하나의 라인으로 된 함수의 경우만 inline 멤버
함수로 정의하고 최대한 inline 멤버 함수는 피하는 것이 바람직하다.
예를 들어 Full() 함수를 inline 멤버 함수로 정의할 수 있다.
class Stack {
...
bool Full() { return (top == size); }
...
};
inline 멤버 함수는 편리함과 성능 측면에서 고려할 수 있다. 하지만
inline 멤버 함수를 빈번하게 사용하면 전체 프로그램이
복잡해진다. 왜냐하면 객체를 구현하는 코드가 .h와 .cc 파일에 흩어질
수 있다. 때로는 성능을 높일 수도 있지만 코드의 카피로 인해서 캐쉬
성능에 영향을 미쳐 성능이 낮아질 가능성도 있다.
3. C++ 함수 정의에서느 C와 달리 코드의 어느 위치에서도 변수를 선언할
수 있다. 이 방식은 프로그램을 쉽게 읽을 수 있게 한다. 또한 다음의
코드도 가능하다.
for (int i=0; i<10; i++);
주의해야 할 점은 컴파일러에 따라 변수 i가 for루프 다음에 나올
코드까지 스코프가 확장될 수도 있다.
4. 코멘트는 //와 /* */를 모두 사용할 수 있다.
5. ANSI C에서 유래한 const의 키워드를 C++에서 여러가지 새로운 용도로
사용할 수 있다. const를 사용하는 기본 아이디어는 변수나 함수가
어떻게 사용될지를 컴파일러에게 추가로 알려서 변수나 함수가 선언된
바와 다르게 부적절하게 사용되는 코드 부분이 있으면 컴파일 에러를
나도록 하는 것이다.
예를 들어서 멤버 함수가 멤버 데이터만을 읽기만 하고 객체를 변경하지
않음을 선언하는 방법으로 const를 다음과 같이 사용할 수 있다.
class Stack {
...
bool Full() const;
...
};
C에서와 같이 변수 선언에 const를 추가해서 변수 값이 변경되지 않아야
함을 선언할 수 있다.
const int InitialHashTableSize = 8;
#define을 통해 상수에 대한 이름을 정의할 수 있지만 const를 사용하는
것이 컴파일러에 의해 타입 검사를 할 수 있으므로 더 나은 방법이다.
6. C++에서는 >>과 << 연산자와 cin과 cout 객체를 통해서 입출력을 한다. 예를
들어 stdout에 문자열을 출력하기 위해서
cout << "Hello world! This is section " << 3 << "!";
C에서 위와 동일한 의미의 코드는
fprintf(stdout, "Hello world! This is section %d!\n", 3);
두 코드의 다른 점은 C++에서는 타입을 컴파일러가 자동으로 검사한다는
점이다. 예를 들어 위의 C코드에서 3대신 3.0을 주어도 C 컴파일러는
아무런 문제없이 컴파일 할 것이다.
C++에서 <<와 printf를 함께 사용할 수 있지만 동일한 stream에 함께 사용하지
않는 편이 낫다.
int field1, field2;
cin >> field1 >> field2;
// fscanf(stdin, "%d%d", &field1, &field2);
참고로 cin과 cout은 보통의 C++ 객체이다. 위의 예제에서 추측할 수
있듯이 operator overloading(동일한 이름의 연산자 <<와 >>를 사용해서
여러 타입들의 데이터를 입출력함)과 reference parameter(데이터를
읽을 때 해당 변수의 주소를 명시적으로 주지는 않았지만 변수의 주소가
인자로 전달됨)를 사용한다. 5장에서 설명하겠지만 이러한 C++ 특징은
사실 피해야할 특징들로 분류하므로 I/O를 위해서 이러한 특징들이
구현에 어떻게 필요한지 특별히 이해할 필요는 없다.
4 Advanced Concepts in C++: Dangerous but Occasionally Useful
단일상속과 템플릿과 같은 몇가지 C++의 특징들은 잘못 사용하기 쉽지만
일단 잘 사용하면 프로그램을 크게 간단하게 만들수 있다.
지금까지 설명한 C++ 특징들은 사실 C 특징들과 근본적으로 차이가 없는
것들이었다. 사실 능숙한 C 프로그래머는 하나의 자료 구조에 연관된
모듈들에 관련 함수들을 모아놓도록 프로그램을 구성할 줄 알고 있고
심지어는 함수나 변수 이름을 선택할때에도 C++ 프로그램을 흉내내서
프로그래밍 하고 있다. 예를 들어 StackFull()이나 StackPush()와 같은 함수
이름은 클래스 이름과 함수 이름을 합해서 만든 것이다.
하지만 이제 설명하려고 하는 단일상속과 템플릿은 프로그래밍에 있어서
일종의 패러다임 전환을 요구하는 특징들로 이를 일반 C의 특징으로
변환하는 것이 간단하지 않다. 이들 특징을 사용할 때 얻을 수 있는 장점은
다양한 성질의 객체에 적용할 수 있는 generic 코드를 만들 수 있다는
것이다.
그럼에도 불구하고 C++을 처음 프로그래밍 하는 경우 단일 상속이나 템플릿과
같은 특징을 사용하지 않아야 한다. 왜냐하면 대부분 이들 특징들을 제대로
사용하지 못할 것이기 때문이다.
4.1 Inheritance
inheritance 특징으로 객체들의 어떤 클래스들을 서로 관련이 있음을
프로그램에 반영할 수 있다. 예를 들어 리스트와 정렬된 리스트는 사용자가
각 리스트에 어떤 원소를 새로 추가하거나 없애거나 찾는 연산을
허용한다는 점에서 유사하다.
inheritance의 두가지 장점은 다음과 같다.
첫째는 객체의 종류에 generic code를 만들 수 있다. 이 코드는 자신이
다루는 객체의 종류가 무엇인지 몰라도 코드 실행할 수 있다. 예를 들어
윈도우 시스템에서 스크린에 있는 모든 것 (윈도우, 스크롤바, 타이틀,
아이콘 등)이 객체이다. 모든 객체가 공통으로 갖고 있는 멤버 함수로 예를
들어 해당객체의 내용을 화면에 다시 그리는 함수 repaint()가 있다. 이런
상황에서 전체 화면을 다시 그리려면 각 객체의 repaint() 함수를 한번씩
호출하면 된다. 모든 객체가 이 함수를 멤버로서 포함한다면 객체의 종류를
모르고도 이 함수를 호출할 수 있다.
둘째는 여러 객체들이 구현 코드를 공유할 수 있다. 예를 들어 리스트
클래스와 정렬된 리스트 클래스를 구현하는 경우를 고려하자. 두 클래스
모두 리스트에 대한 멤버 함수 insert, delete, isFull, print를 포함해야
한다.그리고 isFull이나 print 함수는 각 클래스에 따라 달라야 할 이유가
없다. 따라서 리스트 클래스를 먼저 구현하고 공유하지 않을 멤버 함수만
따로 수정하고 공유할 수 있는 멤버 함수는 모두 동일한 코드를 그대로
사용해서 정렬 리스트 클래스를 구현할 수 있다.
4.1.1 Shared Behavior
이전의 Stack 클래스는 스택을 구현하기 위해 배열을 이용했지만 대신에
리스트를 이용할 수도 있다. 이전의 Stack 클래스를 사용하는 코드는
리스트를 이용해서 새로 정의한 Stack 클래스를 사용해도 아무런 문제가
없어야 한다. (물론 리스트 기반 클래스는 오버플로우 문제가 없고 배열
기반 클래스는 오버플로우 문제가 있을 수 있지만 배열 기반 클래스의
경우도 오버 플로우가 나면 새로 배열의 크기를 조정하는 코드를 추가하면
오버플로우 발생 가능성 측면에 있어서도 두 클래스의 차이가 없어진다.)
배열과 리스트를 각각 이용하는 클래스를 함께 사용하기 위해서 다음과
같이 public 멤버 함수만을 포함하는 abstract 스택을 정의한다.
class Stack {
public:
Stack();
virtual ~Stack();
virtual void Push(int value) = 0;
virtual bool Full() = 0;
};
Stack::Stack {} // g++의 경우 초기화할 필드가 없어도
Stack::~Stack() {} // constructor와 destructor를 정의해야 한다.
위의 Stack 클래스 정의를 base class 혹은 superclass라 한다. 이
클래스로 부터 두가지 다른 derived class 혹은 subclass들을 정의할 수
있다. base class로 부터 정의한 derived class 들은 원래 클래스의
behavior를 따른다. (어떤 클래스의 behavior를 따른다 혹은 동일한
behavior를 갖는다라고 말할때는 그 클래스의 멤버의 일부 혹은 전부를
갖는다는 의미다.)
base class에 있는 함수 선언에 사용된 virtual 키워드는 derived class
들에서 이들 함수를 다시 정의할 수 있음을 표시한다. virtual 함수들은
0으로 초기화하도록 정의되어 있는데 이것은 이들 함수들을 derived
class에서 반드시 정의해야 한다는 것을 컴파일러에게 알리는
것이다. (몇가지 질문: 0이 아닌 다른 값으로도 초기화가 가능한가? 만일
초기화를 하지 않았다면 derived class에서 정의할 수 없다는 뜻은
아닌가? 실제 컴파일러가 초기화문을 어떻게 구현하는가? )
다음은 배열과 리스트에 기반한 derived class인 ArrayStack과 ListStack을
정의한 예이다. 아래의 예에서 각 derived class를 정의할때 : public
Stack 구문을 사용하는데 이것은 두 derived class가 base class인 Stack의
일종으로 base class와 동일한 behavior를 갖음을 표시한다.
class ArrayStack : public Stack {
public:
ArrayStack(int sz);
~ArrayStack();
void Push(int value);
bool Full();
private:
int size;
int top;
int *stack;
};
class ListStack : public Stack {
public:
ListStack();
~ListStack();
void Push(int value);
bool Full();
private:
List *list;
};
ListStack::ListStack() {
list = new List;
}
ListStack::~ListStack() {
delete list;
}
void ListStack::Push(int value) {
list->Prepend(value);
}
bool ListStack::Full() {
return FALSE;
}
일단 super class와 derived class로 정의하면 derived class의 객체에
대한 포인터는 super class 타입의 변수에 저장해서 그 객체들이 마치 super
class의 객체인 것 처럼 사용할 수 있다. 예를 들어 ListStack과
ArrayStack의 객체에 대한 포인터를 Stack 타입의 변수에 저장할 수 있고
이 객체들을 Stack 클래스이 객체인 것처럼 동일하게 다룰 수 있다.
Stack *s1 = new ListStack;
Stack *s2 = new ArrayStack(17);
if (!s1->Full())
s1->Push(5);
if (!s2->Full())
s2->Push(6);
delete s1;
delete s2;
위의 프로그램에서 s1에 대해서는 ListStack의 멤버 함수를 s2에 대해서는
ArrayStack 멤버 함수를 호출한다. 이런 형태로 멤버 함수를 호출하려면
컴파일러의 적절한 도움이 필요하다. 각 객체에 대해서 procedure 테이블이
마련되어서 derived class의 객체를 만들때 base class에 의해 정의된
테이블의 디폴트 내용을 적절히 재수정 (override)한다. 위의 예제에서
Full, Push, delete를 수행할 때 해당 객체에 마련된 테이블 내용을 참조
(indirection)해서 호출해야할 함수가 실제로 어느 함수인지를 결정한다.
따라서 객체의 실제 타입은 알 필요가 없다.
위의 예에서 abstract class인 Stack으로 부터 직접 객체를 만들지는 않기
때문에 virtual 멤버 함수들을 구현할 필요는 없다. Stack 클래스는, Stack
클래스를 서로 다른 방식으로 구현하는 클래스들이 공유하는 behavior가
무엇인가를 지정하는 역할을 한다.
Stack 클래스의 destructor는 virtual 멤버함수로 선언했지만
constructor는 그렇지 않다. 객체를 새로 만들때 이 객체가 어느 종류인지
즉 ArrayStack 클래스에 속하는지 ListStack 클래스에 속하는지를 알아야
한다. 컴파일러는 abstract class인 Stack으로 부터 직접 객체를 만들려고
하는 코드가 있다면 컴파일 에러를 낼 것이다. 실수로라도 abstract class
(정의되지 않는 멤버 함수를 포함하는 클래스)로 부터 직접 객체를 만들수
없다.
위의 예제에서 객체를 없애려고 할 시점에, s1과 s2의 타입은 모두 Stack에
대한 포이터 타입으로 ListStack이나 ArrayStack 타입으로 부터 만들어진
것이라는 정보를 컴파일러가 알지 못한다. 즉 컴파일러는 위의 delete s1;
delete s2; 코드로 부터 Stack 클래스의 destructor 멤버 함수를 통해
객체를 없애려고 할 것이다. 이는 derived 객체 s1과 s2의 destructor 멤버
함수를 통해 이들 객체를 없애려는 우리의 의도와 다르다. 이런 이유에서
abstract 클래스 Stack의 destructor 멤버 함수를 virtual로 선언한
것이다.
만일 예제에서 destructor 함수를 선언하는 것 자체를 생략했다면
컴파일러는 virtual 멤버 함수가 아닌 함수로 destructor 함수를 자동으로
클래스에 추가할 것이고 이것은 우리의 의도와 달리 Stack 클래스에 대한
메모리는 해제하더라도 ArrayStack과 ListStack 클래스에 대한 메모리는
해제하지 않아서 memory leak을 초래할 수 있다.
4.1.2 Shared Implementation
여기서는 inheritance의 Shared behavior 외의 다른 장점인 코드 공유를
설명한다. C++에서는 base class의 멤버를 (멤버 함수의 코드와 멤버
변수의 데이터) derived class들에서 사용할 수 있다.
뒤에서 설명하겠지만 inhertance를 이런 형식으로 사용하는 것은 그다지
바람직하지 않다.
Stack, ArrayStack, ListStack 클래스에 새로운 멤버 함수
NumberPushed()를 추가해서 스택에 있는 원소의 수를 별도로 저장하려고
한다. ArrayStack 클래스에 이 정보가 이미 있으므로 ArrayStack 클래스의
관련 멤버 변수와 코드를 그대로 중복시켜 ListStack 클래스를 고칠 수도
있다. 하지만 이상적인 방법은 두 클래스가 동일한 코드를 사용하도록 하는
것이다. C++의 inheritance 특징을 가지고 카운터를 Stack 클래스로 옮겨서
derived class에서 base class의 멤버 함수를 호출해서 이 카운터를
업데이트 할 수 있다.
class Stack {
public:
virtual ~Stack();
virtual void Push(int value);
virtual bool Full() = 0;
int NumPushed();
protected:
Stack();
private:
int numPushed;
};
void Stack::Stack() {
numPushed = 0;
}
void Stack::Push(int value) {
numPushed++;
}
int Stack::NumPushed() {
return numPushed;
}
Stack의 추가된 behavior를 이용하도록 ArrayStack과 ListStack 클래스를
변경할 수 있다. 아래에서는 ArrayStack 클래스만 예로 든다.
class ArrayStack : public Stack {
public:
ArrayStack(int sz);
~ArrayStack();
void Push(int value);
bool Full();
private:
int size;
int *stack;
};
ArrayStack::ArrayStack(int sz) : Stack() {
size = sz;
stack = new int[size];
}
void
ArrayStack::Push(int value) {
ASSERT(!Full());
stack[NumPushed()] = value;
Stack::Push();
}
위의 예제에서 몇가지 설명을 덧붙인다.
1. ArrayStack 클래스의 constructor 함수는 Stack 클래스의 numPushed
멤버변수를 초기화하기 위해서 Stack 클래스의 컨스트럭터를 호출할
필요가 있다. : Stack()를 사용한다.
ArrayStack::ArrayStack(int sz) : Stack()
destructor 함수의 경우도 유사하게 base class의 destructor를
호출할 수 있다. C++ 언어 매뉴얼에 의하면 base class의
constructor/destructor 함수 derived class의
constructor/destructor 함수를 어떤 순서로 호출할 것인지에 대한
규칙이 있다. 그러한 규칙에 따라 프로그램을 하면 이 프로그램을
읽는 사람이 C++ 매뉴얼을 참조해야 프로그램을 이해할 수 있으므로
이러한 규칙에 의존하는 프로그램을 처음부터 작성하지 않는 것이
좋다.
(규칙이 무엇인지?? 위에서 Stack()을 대신할 수 있는 식은 다른 것
어떤 것이 있는지??)
2. Stack 클래스에서 새로운 키워드 protected를 사용한다. protected로
선언된 멤버 함수와 멤버 변수는 현재 클래스로부터 derived 된
클래스에서 사용 가능하지만 다른 클래스에서는 사용 가능하지 않은
멤버이다. 즉 protected로 선언된 멤버는 derived 클래스에는
public으로 해석하고 그 외의 클래스에는 private으로 해석한다.
위의 예제에서 Stack 클래스의 컨스트럭터 Stack()은 ArrayStack과
ListStack 클래스에서 사용할 수 있고 그 외에서 사용하면 컴파일
에러가 날 것이다.
Stack 클래스의 멤버 데이터를 모두 private 속성을 갖도록
정의하였다. 여러가지 스타일이 가능하지만, 클래스 내에 있는 어떠한
데이터도 다른 클래스에서 직접 사용할 수 없도록 하는 프로그래밍
스타일을 따를 것을 추천한다. inheritance로 연관된 클래스들
사이에서도 이 스타일을 그대로 따른 것을 추천한다. 왜냐하면 만일
base class의 구현 방법을 변경하면 관련 derived class의 모든 구현
방법을 검사해서 필요한 경우에 바꿔야 하는데 이것은 프로그램의
모듈화라는 측면에서 바람직하지 않다.
3. base 클래스에 대해서 정의한 모든 함수는 derived 클래스의 멤버
함수로써 자동으로 간주한다. 예를 들어 ArrayStack 클래스에
NumPushed() 함수가 정의되어 있지 않지만 다음과 같이 사용할 수
있다.
ArrayStack *s = new ArrayStack(17);
ASSERT(s->NumPushed() == 0);
4. Stack::Push() 함수가 virtual로 선언되어 있지만 ArrayStack
클래스의 객체를 가리키는 Stack 타입 포인터를 통해 Push() 함수를
호출하면 ArrayStack 클래스의 Push() 함수를 호출하는 결과를
얻는다.
Stack *s = new ArrayStack(17);
if (!s->Full())
s->Push(5);
5. Stack::NumPushed()은 virtual로 선언되어 있지 않다. 따라서 Stack의
derived 클래스에 의해 재정의 될 수 없다. (overriding이 불가???)
(Some people belive that you should mark all functions in a base
class as virtual; that way, if you later want to implement a
derived class that redefined a function, you don't have to modify
the base class to do so.)
6. derived 클래스의 멤버 함수로 부터 base 클래스의 public/protected
멤버 함수를 클래스와 함수 이름의 쌍으로 ( Base::Function() )
명시적으로 지정해서 호출할 수 있다.
void ArrayStack::Push(int value)
{
...
Stack::Push();
}
만일 위 함수 내에서 Stack::Push() 대신 Push()를 호출했다면
컴파일러는 이를 ArrayStack::Push()로 해석할 것이므로 재귀 함수
호출 형태가 될 가능성이 있다. 이것은 물론 우리가 의도한 바가
아니다.
이 외에도 C++언어는 inheritance에 관해 아주 많은 내용을 자세히
포함하고 있다. inheritance 특징을 사용하는 프로그램의 단점은 구현
내용이 여러 파일에 걸쳐서 흩어져서 정의되는 경향이 있다는 것이다. 만일
inheritance의 트리가 크다면 어떤 멤버 함수를 호출했을때 실제로 어떤
클래스의 어떤 함수의 코드가 실행되는지를 찾는 것이 복잡해진다.
inheritance를 사용해서 프로그램을 작성하기 전에 inheritance를 사용하는
목적이 무엇인지를 심각하게 고려해야 한다. (다시 말하면 inheritance를
사용하는 것을 최대한 줄이라는 얘기! --- It's very surprising. )
inheritnace를 사용하는 것이 좋은 상황은 객체들 사이에 behavior를
공유할 때이다. 코드를 공유하기 위해서는 절대 inheritance를 사용하지
말아야 한다. (--- It's very interesting because the notion of
inheritance becomes kind of parametric polymorphism when it is only
used for capturing the shared behavior among objects.) C++에서 두가지
상황에 대해서 모두 사용할 수 있지만 "shared behavior"를 표현하기 위해
inheritance를 사용하면 정말로 읽기 쉬운 간단한 형태의 프로그램을 만들
수 있을 것이다.
(??? To illustrate the difference between shared behavior and shared
implementation, suppose you had a whole bunch of different kinds of
objects that you needed to put on lists. For example, almost
everything in an operating system goes on a list of some sort:
buffers, threads, users, terminals, etc.
A very common approach to this problem (particularly among people new
to object oriented programming) is to make every object inherit from a
single base class Object, which contains the forward and backward
pointers for the list. But what if some object needs to go on multiple
lists? The whole scheme breaks down, and it's because we tried to use
inheritance to share implementation (the code for the forward and
backward pointers) instead of to share behavior. A much cleaner
(although slightly slower) approach would be to define a list
implementation that allocated forward/backward pointers for each
object that gets put on a list.)
요약하자면, 만일 두 클래스에 몇몇 동일한 멤버 함수 signature를
공유하고 있고 (i.e. the two classes share the same behavior) 그 shared
behavior에만 의존하는 코드가 있다면 (shared behavior의 코드가 아니라)
inheritance를 사용해서 이러한 관계를 표현하는 것이 도움이 될 수도
있다. (항상 도움이 되는 것이 아니라.)
4.2 Templates
템플릿은 클래스를 타입에 독립적으로 정의할 수 있게 하는 C++의
특징이다. 지금까지 예로써 든 Stack 클래스의 경우 정수값에 대한 스택을
구현하는 것이다. 만일 문자, 실수, 포인터, 그 외의 임의의 데이터에 대한
스택을 어떻게 구현할 것인가?
template <class T>
class Stack {
public:
Stack(int sz);
~Stack();
void Push(T value);
bool Full();
private:
int size;
int top;
T *stack;
};
템플릿을 정의하기 위해서 template 키워드를 클래스 정의에 붙이고 필요한
만큼의 타입을 선언한다. <class T1, ..., class Tn>
템플릿 클래스 내의 멤버 함수 역시 필요하다면 템플릿으로 선언해야 한다.
template <class T>
Stack<T>::Stack(int sz) {
size = sz;
top = 0;
stack = new T[size];
}
template <class T>
void
Stack<T>::Push(T value) {
ASSERT(!Full());
stack[top++] = value;
}
템플릿 클래스로부터 객체를 만드는 것은 다음과 같다.
void
test() {
Stack<int> s1(17);
Stack<char> *s2 = new Stack<char>(23);
s1.Push(5);
s2->Push('z');
delete s2;
}
위의 코드에서 int 타입에 관한 스택 클래스와 char 타입에 관한 스택
클래스를 정의한 것과 동일한 효과를 갖는다. s1은 int 타입을 원소로 하는
스택 클래스의 객체이고 s2는 char 타입을 원소로 하는 스택 클래스의
객체이다. 실제로 템플릿 클래스를 구현할 때 각각의 다른 instantiated
type에 따라 템플릿 클래스 전체를 복사하는 방법을 일반적으로 사용한다.
템플릿 클래스 특징을 이용하면 프로그램을 모듈화하는데 도움이 되지만
템플릿을 사용하는 프로그램을 디버깅하기가 어렵다. 예를 들어 현재 C++
디버거의 경우 템플릿을 그다지 인식하지 못해서 디버깅을 어렵게 하는
경향이 있다. (2006년 지금은???) 물론, 타입만 다르고 거의 비슷한 두개의
구현을 디버깅하는 것보다 템플릿을 디버깅하는 것이 쉬울 수 있다.
최대한 템플릿을 사용하지 않는 편이 좋다. 만일 템플릿을 써야 한다면
일단 템플릿을 사용하지 않고 구현한 다음 이것을 템플릿을 사용해서
바꾸어도 크게 어렵지 않을 것이다.
템플릿을 사용할 때 한가지 문제점은 템플릿 클래스는 각 instantiated
type 별로 복사하는 형태로 구현되기 때문에 코드 크기가 크게 증가할 수
있다.
5. Features To Avoid Like the Plague
저자의 15년 C++ 프로그래밍 경험에 바탕해서 판단하건데 C++에는 사용하지
않는 것이 바람직한 특징들이 많이 포함되어 있다. 왜냐하면 이러한
특징들은 잘못 사용하기 쉽고, 프로그램을 읽고 이해하는데 방해하기
때문이다. 또한 많은 경우 이러한 특징들 없이도 동일한 일을 프로그래밍
할 수 있다.
1. Multiple inheritance: 여러개의 클래스로부터 behavior를 상속받아
클래스로 정의하는 것. 단일 상속도 어려운데 다중 상속은 얼마나
어렵겠는가.
2. References: call-by-reference를 지원한다. reference는 구문만
다를 뿐 포인터와 동일하다. call-by-reference를 사용한 함수
호출의 경우 언뜻 보기에 call-by-value를 사용한 함수 호출과
비슷하지만 호출한 함수에서 인자값을 변경시킬 수 있으므로
프로그램을 이해하는데 혼란스럽게 한다.
3. Operator overloading: 프로그래머로 하여금 operator의 의미를 다시
정의할 수 있게 한다. 묵시적으로 타입을 변경할 수 있는 가능성이
많은 C++ 언어의 경우 연산자 중복 기능을 사용하는 것은 바람직하지
않다. 연산자의 타입에 따라서 실제 그 연산자를 구현하는 코드가
결정이 되는데 C++ 프로그램의 경우 연산자의 타입이 직관적이지
않은 방법에 의해서 프로그램을 보고 예상했던 것과 다른 타입을
할당할 수 있고 결과적으로 예상치 못한 코드를 수행할 수 있다.
4. Function overloading: 클래스 내에서 이름은 갖지만 인자의 타입은
다른 멤버 함수를 여러개 정의할 수 있다. 프로그램에서 의도하지
않은 함수를 호출하는 경우가 쉽게 발생할 수 있다.
다른 클래스에 멤버 함수들에서 동일한 타입의 인자들을 받고 동일한
의미의 역할을 수행한다면 그 멤버 함수들에 대해서 이름을 갖게
하는 것은 바람직하다. (문맥하고 다른 얘기 아닌가???)
5. Standard template libray: 리스트, 해쉬테이블과 같은 것을 구현하는
ANSI 표준 라이브러리가 있다.
(??? Alas, the standard template libray pushes the envelope of
legal C++, and so virtually no compilers (including g++) can
support it today. Not to mention that it uses references,
operator overloading, and function overloading.)
6. Exceptions: 함수 호출로 부터 에러를 리턴하는 방법.
C의 특징 중에서 사용하지 말아야 할 것들을 몇가지 나열한다.
1. Pointer arithmetic: 통제 불능의 포인터는 C프로그램에서 버그의
주된 원인 중의 하나이다. 프로그램의 전혀 다른 부분에 위치한
데이터를 망칠 수 있다. 어떤 객체가 힙에 어느 순서로 할당되는가에
따라 포인터 에러는 무작위로 나타나기도 하고 사라지기도
한다. 예를 들어 printf를 프로그램 중간 중간에 삽입하면 printf의
코드에서 가끔 메모리 할당을 하기 때문에 printf 다음에 new
연산자로 메모리를 할당하면 이 메모리 주소는 printf를 넣지 않았을
때와 주소가 다르게 된다. 따라서 printf를 중간 중간에 넣는 것조차
통제 불능의 포인터로 인해 망치게 될 데이터가
달라진다. (Surprising!)
최대한 포인터 연산은 다른 방법으로 바꾸는 것이 좋다. 예를 들어
포인터를 증가하면서 데이터를 다루는 코드의 경우 별도의 인덱스
변수를 선언해서 이 인덱스 변수와 데이터의 경계 위치를 비교하는
코드로 다시 고쳐볼 수 있겠다. 최적화 컴파일러를 사용하면 두
코드에 대해 거의 같은 성능의 머신코드을 만들어 낼 것이다.
포인터를 사용하지 않더라도 예를 들어 1 offset만큼 배열의 경계를
잘못 다뤄 에러를 초래할 수도 있다. 이런 에러를 막기 위해서는
배열을 참조할 때 항상 배열의 경계를 넘어가지 않는지를 검사하는
멤버 함수를 포함하는 클래스를 정의해 볼 수 있겠다.
2. Casts from integers to pointers and back: 도대체 왜 포인터를
정수로 바꾸고 정수를 포인터로 바꿔야 한다는 말인가? 특히 64비트
머신의 경우 정수의 크기와 포인터의 크기가 다르므로 주의해야
한다.
3. Using bit shift in place of a multiply or divide: 산술 계산을
해야 한다면 산술 연산자를 사용하고, 비트 연산을 해야 한다면
비트 연산자를 사용하는 것이 바람직하다. 초창기 C컴파일러의 경우
비트 연산자를 사용하면 산술 계산을 더욱 빠르게 수행할 수 있는
경우도 있었지만 지금은 그렇지 않다.
4. Assignment inside conditional:
if (x=y) { ... }
위와 같은 형태가 프로그래머가 의도한 assignment일 수도 있지만
혹은 프로그래머가 실수해서 =를 빠뜨린 경우일 수도 있다. 위의
코드에서 만일 조건문에 assignment를 사용하지 않는다는
가이드라인이 있었다면 위와 같은 코드를 보고 쉽게 에러라는 것을
알아낼 수 있다.
5. Using #define when you could use enum:
enum으로 정의한 심볼은 컴파일러에 의해 타입이 맞는지를 자동으로
검사해줄 것이다.
6. Style Guidelines
Nachos 프로젝트 관련 C++ 프로그램 스타일 가이드라인에 대해서 나열한다.
1. Words in a name are separated SmallTalk-style: 각 새로운 word를
시작할 때 대문자를 사용. 모든 클래스 이름과 멤버 함수 이름은
대문자로 시작. get/set 멤버 함수는 예외.
2. 모든 전역 함수의 이름은 대문자로 시작. main 함수와 라이브러리
함수는 예외.
3. 전역 변수를 사용하는 것을 최소화. 많은 전역변수가 사용이 된다고
생각하면 관련있는 전역 변수들을 모아서 하나의 클래스로 만들거나,
인자로 함수에 전달할 수 있는 방법을 찾는다.
4. 전역 함수를 사용하는 것을 최소화. 어떤 특정 객체에 대해서
연산하는 함수라면 그 객체의 클래스 멤버 함수로 포함시키라.
5. 모든 클래스에 대해서 .h 파일과 .cc 파일을 별도로 만들어라. .h
파일은 클래스에 대한 인터페이스로, .cc 파일은 클래스에 대한
구현의 역할을 한다.
(단일 의존 관계)
하나의 .h 파일이 다른 .h 파일을 포함해야 한다면 이 의존관계를
.h 파일에 포함시켜라. (앞의 .h 파일에 #include <뒤의 .h>를
추가)
(DAG 의존 관계)
만일 여러 개의 .h 파일이 포함되는 경우 각 .h 파일에 다음과 같은
코드를 추가한다.
#ifndef STACK_H
#define STACK_H
class Stack { ... };
#endif
(Cyclic 의존 관계) 두 개의 .h간에 싸이클 형태 의존관계를
갖는다면 ad-hoc한 방법을 고려해야 한다. 기본 전략은 의존하는 .h
파일의 내용을 항상 모두 포함할 필요는 없는 경우를 찾는 것이다.
예를 들어 어떤 클래스에 대한 포인터는 사용하지만 그 클래스의
멤버 함수나 멤버 변수를 사용하지 않는다면 stack.h를 include하는
대신 아래의 선언을 해서 컴파일러에 Stack이라는 클래스 이름을
알릴 수 있다.
class Stack;
가끔 이런 전략이 적용할 수 없는 경우에는 클래스 정의 등을
적절히 옮겨서 재배치 해야 한다.
6. ASSERT 문을 적극 활용하라. ASSERT 문은 조건문으로 이 조건문이
FALSE로 결정되면 어떤 가정이 어긋나서 버그가 있음을 미리
찾아낸다. ASSERT문이 없을 경우에는 이 어긋난 가정으로 인해서
프로그램 어딘가에서 에러가 나고 이를 사용자가 눈치챌만한 상황이
벌어지고 나서야 비로소 이 버그를 발견할 수 있을 것이다.
ASSERT 문은 특히 함수의 시작과 끝에서 함수의 인자와 리턴값에
대한 가정을 검사할 수 있다.
속도가 문제라면 ASSERT는 디버그 버젼에서만 나타나도록 셋팅할
수도 있다. 제품 수준에서도 ASSERT 문을 포함하는 경우도 많다.
7. Write a module test for every module in your program: 각
모듈별로 테스팅을 반드시 한 다음 전체 시스템 수준에서
테스팅하라.
7. Compiling and Debugging
8. Example: A Stack of Integers
9. Epilogue
프로그래밍 언어에서 어떤 특징을 가지고 있다면 이 특징을 포함시켜야
하는 좋은 이유가 반드시 있어야 한다. 모든 프로그래머는 코드를 쓸 때 이
코드의 동작을 다른 사람이 즉시 분명하게 이해할 수 있도록 해야
한다. 코드를 작성할 때 얼마나 많은 문자를 썼는지 보다 이 코드가
동작하고 다른 사람이 얼마나 간단하게 수정할 수 있는지가 더욱 중요하다.
"There are two ways of constructing a software design: one way is to
make it so simple that there are obviously no deficiencies and the
other way is to make is so complicated that there are no obvious
deficiencies."
C.A.R. Hoare, "The Emperor's Old Clothes", CACM Feb 1981.
10 Further Reading
- Jamse Coplien, "Advanced C++", Addison-Wesley.
- James Gosling. "The Java Language."
- C.A.R. Hoare, "The Emperor's Old Clothes." CACM, Vol.24, No.2, February 1981, pp75-83.
- B.Kernighan and D.Ritchie, "The C Programming Language", Prentice-Hall.
- Steve Maguire, "Writing Solid Code", Microsoft Press.
- Steve Maguire, "Debugging the Development Process", Microsoft Press.
- Scott Meyers, "Effective C++".
- Bjarne Stroustrup, "The C++ Programming Language", Addison-Wesley.
11. 그 외 해로운 features
static & friend
멤버 함수 선언은 다음의 세가지 사항을 지정한다.
1) 이 멤버 함수는 해당 클래스 선언의 private 영역을 접근할 수 있다.
2) 이 멤버 함수는 클래스의 scope 내에 있다.
3) 이 밤수는 반드시 객체를 통해 호출해야 한다.
static 키워드는 1)과 2)를 지정하고, friend 키워드는 1)을 지정한다.
private & proctected & public 멤버
클래스 멤버는 private, protected, public 세가지로 지정할 수 있다.
a) private 멤버는 멤버 함수와 그 클래스의 friend에 의해서 접근할
수 있다.
b) protected 멤버는 멤버 함수와 그 클래스의 friend에 의해서 접근할
수 있고, 그 클래스로 부터 유도된 클래스의 멤버 함수와 유도된
클래스의 friend에 의해서 접근할 수 있다.
c) public 멤버는 어느 함수에 의해서도 접근 가능하다.
private & proctected & public 유도
D : ( private | protected | public ) B
a) B 클래스가 private base이면
a-1) D 클래스의 멤버 함수와 friend는 B의 public 멤버와
protected 멤버를 사용할 수 있다.
a-2) 클래스 D의 멤버 함수와 friend에 의해서만 D*를 B*로 변환할
수 있다.
b) B 클래스가 proctected base이면
b-1) D 클래스의 멤버 함수와 friend는 B의 public 멤버와
protected 멤버를 사용할 수 있고, D 클래스로 부터 유도된
클래스의 멤버 함수와 friend에 의해 B의 public 멤버와 procteded
멤버를 사용할 수 있다.
b-2) D 클래스의 멤버 함수와 friend 그리고 D 클래스로 부터 유도된
멤버 함수와 friend에 의해서만 D*를 B*로 변환할 수 있다.
c) B 클래스가 public이면
c-1) 어느 함수도 B 클래스의 public 멤버를 사용할 수 있다. B의
proctected 멤버를 D의 멤버와 friend 그리고 D로 부터 유도된
클래스의 멤버와 friend가 사용할 수 있다.
c-2) 어느 함수도 D*를 B*로 변환할 수 있다.
EOF
No comments:
Post a Comment