상속이란 이름을 달고 이번 항목을 시작했지만, 사실 상속과는 별 관계가 없습니다. 진짜 관계가 있는 것은 유효범위(scope)입니다. 우선 다음의 코드를 보면서 시작하도록 하죠.
1 2 3 4 5 6 7 | int x; // 전역 변수 void someFunc() { double x; // 지역 변수 std::cin >> x; // 입력을 받아, 지역 변수 x에 새 값을 읽어 넣습니다. } | cs |
값을 읽어 x에 넣는 위의 문장에서 실제로 참조하는 x는 전역 변수 x가 아니라 지역 변수 x입니다. 이유는 안쪽 유효범위에 있는 이름이 바깥쪽 유효범위에 있는 이름을 가리기(다른 말로 "덮기") 때문입니다. 컴파일러가 someFunc의 유효범위 안에서 x라는 이름을 만나면, 일단 그 컴파일러는 자신이 처리하고 있는 유효범위, 즉 지역 유효범위(local scope)를 뒤져서 같은 이름을 가진 것이 있는가를 알아봅니다. 위에서 보신 대로 x라는 이름이 바로 있기 때문에, 이 외의 유효범위에 대해서는 더 이상 탐색하지 않습니다. 타입은 중요하지 않고 C++의 이름 가리기 규칙은 어쨌든 이름을 가려 버립니다.
이제는 상속 이야기입니다. 기본 클래스에 속해 있는 것(멤버 함수, typedef 혹은 데이터 멤버)을 파생 클래스 멤버 함수 안에서 참조하는 문장이 있으면 컴파일러는 이 참조 대상을 바로 찾아낼 수 있습니다. 기본 클래스에 선언된 것은 파생 클래스가 모두 물려받기 때문이죠. 사실 이렇게 동작하는 이유는 파생 클래스의 유효범위가 기본 클래스의 유효범위 안에 중첩되어 있기 때문입니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | class Base { private: int x; public: virtual void mf1() = 0; virtual void mf2(); void mf3(); ... }; class Derived: public Base { public: virtual void mf1(); void mf4(); ... }; | cs |
보다시피 데이터 멤버와 멤버 함수의 이름이 public 으로 공개되거나 private로 숨겨진 상태로 한데 뒤섞여 있는 예제입니다. 온갖 멤버 함수, 그러니까 순수 가상, 그냥 (비순수) 가상, 비가상 함수가 전부 모여 있습니다. 어쨌든 중요한 건 '이들이 이름이다'라는 것뿐입니다.
mf4가 파생 클래스에서 다음과 같이 구현되어 있다고 가정해 봅시다.
1 2 3 4 5 6 | void Derived::mf4() { ... mf2(); ... } | cs |
컴파일러는 이 함수 안을 차례로 읽어 가다가 mf2라는 이름이 쓰이고 있다는 것을 발견하게 되는데, 이때 이 mf2가 어느 것에 대한 이름인지를 파악해야 하는 것이 급선무입니다. 이름의 출처 파악을 위해, 컴파일러는 mf2라는 이름이 붙은 것의 선언문이 들어 있는 유효범위를 탐색하는 방법을 씁니다. 우선 지역 유효범위(즉, mf4의 유효범위) 내부를 뒤져 보는데, mf2라 불리는 어떤 것도 선언된 게 없습니다. 그래서 mf4의 유효범위를 바깥에서 감싸고 있는 유효범위를 찾습니다. 그러니까 지금의 경우에는 Derived 클래스의 유효범위가 그 유효범위에 해당되지요. 그런데 여전히 mf2라는 이름을 가진 것이 보이지 않으므로, 컴파일러는 Derived 클래스를 감싸고 있는 바로 다음의 유효범위, 즉 Base 클래스의 유효범위로 옮겨 갑니다. 여기서 컴파일러는 드디어 mf2라는 이름이 붙은 함수를 찾아내고, 탐색이 비로소 끝납니다. 만약 Base 안에 mf2가 없으면 계속 탐색이 진행되는데, 우선 Base를 둘러싸고 있는 네임스페이스가 있으면 그쪽부터 탐색을 시작해서, 마지막엔 전역 유효범위까지 갑니다.
다시 앞의 예제로 돌아옵시다. 이번에는 mf1 및 mf3를 오버로드하고, mf3의 오버로드 버전을 Derived에 추가합니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | class Base { private: int x; public: virtual void mf1() = 0; virtual void mf1(int); virtual void mf2(); void mf3(); void mf3(double); ... }; class Derived: public Base { public: virtual void mf1(); void mf3(); void mf4(); ... }; | cs |
무대가 클래스로 옮겨졌을 뿐, 유효범위에 기반한 이름 가리기 규칙은 전혀 변한 것이 없기 때문에, 기본 클래스에 있는 함수들 중에 mf1 및 mf3라는 이름이 붙은 것은 모두 파생 클래스에 들어 있는 mf1 및 mf3에 의해 가려지고 맙니다. 이름 탐색의 시점에서 보면, 어처구니없게도 Base::mf1과 Base::mf3는 Derived가 상속한 것이 아니게 된단 말입니다!
1 2 3 4 5 6 7 8 9 10 | Derived d; int x; ... d.mf1(); // Derived::mf1을 호출합니다. d.mf1(x); // 에러입니다! Derived::mf1이 Base::mf1을 가립니다. d.mf2(); // Base::mf2를 호출합니다. d.mf3(); // Derived::mf3를 호출합니다. d.mf3(x); // 에러입니다! Derived::mf3가 Base::mf3를 가립니다. | cs |
이런 어처구니없는 이름 가리기는 기본 클래스와 파생 클래스에 있는 (이름이 같은) 함수들이 받아들이는 매개변수 타입이 다르거나 말거나 거리낌이 없습니다. 심지어 함수들이 가상 함수인지 비가상 함수인지의 여부에도 상관없이 이름이 가려집니다. 맨 앞에 예시로 들었던 코드에서 someFunc 함수 안에 있는 double 타입의 x가 전역 유효범위에 있는 int 타입의 x를 가렸듯이, 이번에는 Derived 클래스의 mf3 함수가 Base 클래스의 mf3 함수를 가리고 있습니다. 받아들이는 타입도 완전히 다른데 말이죠.
이렇게 동작하는 데에는 다 그만한 이유가 있습니다.
여러분이 어떤 라이브러리 혹은 응용프로그램 프레임워크를 이용하여 파생 클래스를 하나 만들 때, 멀리 떨어져 있는 기본 클래스로부터 오버로드 버전을 상속시키는 경우를 막겠다는 것입니다.
일종의 실수로 간주하겠다는 것인데, 오버로드 버전을 상속했으면 하는 프로그래머가 입장에선 애석한 일입니다.
사실, public 상속을 버젓이 쓰면서 기본 클래스의 오버로드 함수를 상속받지 않겠다는 것도 엄연히 is-a 관계 위반입니다.
가려진 이름은 using 선언을 써서 끄집어낼 수 있습니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | class Base { private: int x; public: virtual void mf1() = 0; virtual void mf1(int); virtual void mf2(); void mf3(); void mf3(double); ... }; class Derived: public Base { public: using Base::mf1; // Base에 있는 것들 중 mf1과 mf3의 이름을 가진 것들을 using Base::mf3; // Derived의 유효범위에서 볼 수 있도록(또 public 멤버로) // 만듭니다. virtual void mf1(); void mf3(); void mf4(); ... }; | cs |
이제 우리가 예상했던 대로 돌아가는 상속이 되었습니다.
1 2 3 4 5 6 7 8 9 10 | Derived d; int x; ... d.mf1(); // Derived::mf1을 호출합니다. d.mf1(x); // 이제 괜찮습니다. Base::mf1을 호출합니다. d.mf2(); // Base::mf2를 호출합니다. d.mf3(); // Derived::mf3를 호출합니다. d.mf3(x); // 이제 괜찮습니다. Base::mf3을 호출합니다. | cs |
어떤 기본 클래스로부터 상속을 받으려고 하는데, 오버로드된 함수가 그 클래스에 들어 있고 이 함수들 중 몇 개만 재정의(다른 말로 오버라이드)하고 싶다면, 각 이름에 대해 using 선언을 붙여 주어야 한다는 것입니다. 이렇게 하지 않으면 이름이 가려져 버리거든요. 가려진 이름은 여러분이 상속받고 싶어도 할 수가 없게 됩니다.
기본 클래스가 가진 함수를 전부 상속했으면 하는 것이 아닌 경우도 있긴 합니다. 물론 이 경우와 public 상속은 함께 놓고 생각하지 말아야 합니다. 기본 클래스와 파생 클래스 사이의 is-a 관계가 깨져 버리기 때문이죠. 위의 예제에서 보신 using 선언이 파생 클래스의 public 영역에 들어 있어야 하는 이유도 바로 이것입니다. 어떤 파생 클래스가 기본 클래스로부터 public 상속으로 만들어진 것일 경우, 기본 클래스의 public 영역에 있는 이름들은 파생 클래스에서도 public 영역에 들어 있어야 합니다.
하지만 private 상속을 사용한다면 이 경우가 말이 될 수 있습니다. Derived가 Base로부터 private 상속이 이루어지고, Derived가 상속했으면 하는 mf1 함수는 매개변수가 없는 버전 하나밖에 없다고 치면, 이때는 using 선언으로 해결할 수 없습니다. 그 이유는 using 선언을 내리면 그 이름에 해당되는 것들이 모두 파생 클래스로 내려가 버리기 때문입니다. 바야흐로 간단한 전달 함수(forwarding function)이란 기법이 필요한 경우가 된 거죠.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | class Base { public: virtual void mf1() = 0; virtual void mf1(int); ... // 예전과 동일합니다. }; class Derived: private Base { public: virtual void mf1() // 전달 함수입니다. 암시적으로 { Base::mf1(); } // 인라인 함수가 됩니다. ... }; ... Derived d; int x; d.mf1(); // Derived::mf1(매개변수 없는 버전)을 호출합니다. d.mf1(x); // 에러입니다! Base::mf1()은 가려져 있습니다. | cs |
지금 보신 인라인 전달 함수의 용도는 하나 더 있습니다. 기본 클래스의 이름을 파생 클래스의 유효범위에 끌어와 쓰고 싶은데, using 선언을 아예 지원하지 못하는 컴파일러를 사용하고 있다면 이 인라인 전달 함수를 써서 우회적으로 해결할 수 있습니다.
파생 클래스의 이름은 기본 클래스의 이름을 가립니다.
public 상속에서는 이런 이름 가림 현상은 바람직하지 않습니다.
가려진 이름을 다시 볼 수 있게 하는 방법으로, using 선언 혹은 전달 함수를 쓸 수 있습니다.