17. 상속, 다형성
1. 클래스 상속
클래스의 장점:
-
코드의 재사용성 증가
-
다형성: 하나의 특징으로 여러 특징을 가질 수 있게 함
-
추상화: 부모 클래스로부터 파생되는 자식 클래스들이 구현해야 하는 함수들을 강제시킬 수 있다.
클래스의 상속은 상속을 받는 자식 클래스 선언 뒤에 : (부모 클래스)
과 같이 명시하여 상속할 수 있다.
클래스 여러개를 상속시키는 경우를 다중 상속이라고 하는데, 다중 상속은 가능한 한 지양해야 할 문법이기에 되도록이면 사용을 피하자.
클래스는 상속 과정에서 다음과 같은 특징을 가진다:
-
자식 클래스는 상속받은 부모 클래스의 멤버를 포함해서 가지고 있다. 자식 객체는 자식 클래스 멤버와 부모 클래스 멤버 모두를 갖는다.
- 자식 클래스에서 부모 클래스에 구현되어 있는 멤버를 초기화하려면 이니셜라이저에 부모 클래스를 호출한다.
- 기본적으로 부모 클래스의 기본 생성자가 생략되어 있다.
- 부모 클래스에서 상속받은 멤버변수 초기화는 생성자 호출로만 가능하다(직접 변수를 호출해서 초기화할 수 없다).
-
접근 제한 지정자
protected
는 자식에게만 접근을 허용한다.private
의 경우 자식에게도 비공개가 된다. - 상속에서 초기화는 부모부터 자식 순서로 이루어지고, 소멸자는 자식부터 부모 순서로 호출된다.
2. 추상 클래스
추상 클래스란, 하위로 파생되는 클래스에서 공통적인 부분만을 가지는 상위 클래스로써 자식 클래스에 상속을 위해 존재하며, 객체로써의 기능이 불가능하다.
추상 클래스는 클래스 내부에 순수 가상함수를 선언함으로써 추상 클래스가 되거나, 부모 클래스가 추상 클래스일 경우 부모 클래스로부터 상속 받은 뒤 순수 가상함수를 정의하지 않은 경우 클래스가 추상화되어 추상 클래스로 취급되며 객체 생성이 불가능해 진다.
- 순수 가상함수
순수 가상함수는 가상함수의 종류 중 하나로, 다음과 같이 선언된다:
virtual void func() = 0;
이 때 반환 타입과 입력값, 함수 이름은 정해져있지 않으며, virtual
키워드와 함수 선언 뒤에 = 0
을 붙여서 선언 해 주는 것이 중요하다.
또한, 보다시피 정의가 없기에 이후에 상속받는 자식 클래스 쪽에서 정의를 해 주지 않으면 자식 클래스 또한 추상 클래스가 되며 객체 생성이 불가능 해 진다.
추상 클래스의 상속을 끝내고 일반 클래스화를 하고 싶은 경우 순수 가상함수로 선언된 함수를 정의해 주면 되며, 이를 통해 하위 클래스에 구현이 필요한 함수의 정의를 강제시킬 수 있다.
3. 오버라이딩
오버라이딩이란, 부모 클래스에서 상속받은 함수를 자식 쪽에서 재정의하는 것을 말한다.
오버라이딩은 크게 다음과 같은 경우로 나눌 수 있다:
- 함수 정의를 완전히 재정의하는 경우
- 함수 정의에서 구현을 다시 하면 된다.
- 기존 부모 함수에 기능을 추가하려는 경우
- 자식 클래스의 함수 정의에서
(부모 클래스)::(함수명)()
으로 부모 함수를 호출하고, 그 뒤로 기능을 추가하면 된다. - 스코프 연산자(::)를 사용하여 부모쪽 함수를 정확히 명시해 주면 함수 오버라이딩이 된 뒤에도 부모쪽에서 정의된 함수 버전을 호출할 수 있다.
- 자식 클래스의 함수 정의에서
4. 상속 구조와 포인터
부모 클래스 포인터 타입 변수는 자식 클래스의 주소를 받을 수 있다.
단, 반대로 자식 클래스 포인터 타입 변수는 부모 클래스의 주소를 받을 수 없다.
-
이러한 특성을 이용해 부모 클래스 포인터로 부모 클래스에서 파생된 모든 하위 자식 클래스 객체들에 접근이 가능하다.
-
단일 객체 기준으로, 메모리 나열이 부모에서 자식 순으로 배치되어 있기에 접근에 문제가 없다. 다만, 클래스 “배열”인 경우 오프셋에서 문제가 생기니 주의해야 한다.
- 응용: 클래스 다형성
다형성이란, 한 종류의 타입으로 다양한 형태를 나타낼 수 있는 구조를 말한다.
부모 클래스 포인터로 해당 클래스에서 파생된 하위 클래스의 주소를 저장하고 포인터로 활용할 수 있는 점을 이용한다.
이 경우 포인터 변수가 가리키는 객체가 포인터 타입과 일치하는 객체일 수도, 파생된 클래스의 객체일 수도 있기에 저장된 객체(주소)가 실제 포인터 타입과 일치하지 않는다.
다만, 오브젝트의 구현 과정에서 상위 타입 포인터 변수로 하위 파생 객체를 모두 관리할 수 있는 장점이 매우 중요하게 작용한다.
이때 포인터가 가리키는 객체 타입을 (프로그래머가) 아는 경우 해당 포인터 타입으로 캐스팅을 해서 접근하는 것이 가능하다.
이를 “다운 캐스팅” 이라고 하며, C++ 에서는 dynamic casting 으로 지원하고 있다.
5. 가상함수
클래스 포인터 변수에는 해당 클래스와 그 자식 클래스 객체의 주소를 저장할 수 있다.
이 때, 부모 클래스에 Parent::func()
함수가 구현되어 있고, 자식 클래스에서 Child::func()
으로 오버라이딩 되었다고 가정하자.
부모 클래스의 포인터 Panent*
로 접근하면 컴파일러는 포인터가 가리키는 객체를 Parent
타입으로 읽어들이기 때문에 부모 쪽 함수인 Parent::func()
가 호출돤다.
이렇게 되면 부모 클래스 포인터로 자식 클래스를 가리키고 접근할 수 있어도 오버라이딩 된 함수를 호출할 수 없게 된다.
이러한 문제를 해결하기 위해 존재하는 문법 및 키워드가 virtual
로, 함수를 가상화하여 가상 함수로 선언하는 것이다.
함수 앞에 virtual
키워드를 붙여 가상함수로 선언하게 되면, 동적 바인딩이 이루어져 포인터로 호출 시 오버라이딩 된 함수인 Child::func()
가 호출된다.
- 가상함수의 동작 원리
가상함수가 클래스 내부에 선언되면, 클래스의 type_info 테이블에 가상함수 테이블(virtual function tavble)이 생성되며, 숨겨진 첫 멤버함수로 가상함수 테이블 안의 함수를 가리키는 포인터 변수 __vfptr
이 선언된다.
- 이로 인해 클래스의 크기(바이트 수)가 포인터 변수(8바이트, 32bit 빌드인 경우 4바이트) 기준으로 계산된다. 자세한 내용은 8. 배열과 구조체의 구조체 크기 부분 참조.
가상함수 테이블이란, 동적 바인딩에 필요한 함수의 주소를 저장하는 테이블이다.
부모 클래스 AA 와 자식 클래스 BB를 예시로 하면 아래와 같이 보여진다.
그림에서 알 수 있듯, 오버라이딩이 이루어진 함수는 오버라이딩 된 클래스의 위치에 포함된 함수로 바뀌고, 오버라이딩되지 않을 경우 기존 클래스 위치에 포함된 함수로 가상함수 테이블에 저장이 된다.
예를 들어, BB b;
로 클래스 BB 타입 객체 b 가 선언되고, AA* pA = &b;
로 클래스 AA 의 포인터 타입 변수 pA 를 선언 후 b의 주소로 초기화했다고 하자.
이후 pA->func2()
로 b 객체 주소에 접근하여 func2() 함수를 호출하면 다음과 같은 과정을 거친다:
- func2() 는 가상함수, 가상함수 테이블 상 인덱스 1에 위치해 있음
- b 객체 메모리의 vfptr 포인터로 클래스 BB 의 가상함수 테이블에 접근
- 인덱스 1에 저장된 함수 BB::func() 주소를 가져옴. (함수는 클래스 BB 에서 오버라이딩됨)
- 함수 주소에 접근하여 최종적으로 (클래스 BB 에서) 오버라이딩 된 func2() 함수가 호출된다.
순수 가상함수(virtual func() = 0;)는 가상함수 테이블에 nullptr 로 저장된다.
이로 인해 인덱싱 접근에 문제가 있으므로 객체 생성이 불가능하게 된 것임을 알 수 있다.
이러한 특성을 통해 최종적으로 구현되어야 하는 함수를 부모쪽에서 순수 가상함수로 선언해 두면 오버라이딩을 강제시킬 수 있다.
- 가상 소멸자
가상 함수가 존재하는 클래스의 경우, 또는 추상 클래스인 경우 반드시 소멸자를 virtual 로 선언해 가상 소멸자로 만들어 주어야 한다.
그 이유는 앞서 설명한 함수 호출 시 문제점과 함수의 가상화 필요성과 같은 맥락이다.
가상 함수가 존재하는 클래스에서 소멸자가 가상 함수가 아닌 경우, 부모 클래스 포인터로 가리킨 객체의 소멸자가 호출될 때 부모 클래스의 소멸자가 호출되어 하위 클래스들의 소멸자가 호출되지 않게 된다.
자식 클래스 소멸자에 부모 클래스 소멸자를 호출하는 기능이 있기 때문에 객체의 메모리를 완전히 해제하기 위해서는 가리키는 객체 자체의 소멸자를 호출 시킬 필요가 있기에 동적 바인딩이 동원되어야 하며, 이를 가상 소멸자로써 해결하는 것이다.