본문 바로가기

컴퓨터공학/C, C++

C,C++> 상속

상속

클래스 확장

상속(Inheritance)는 이미 정의되어 있는 클래스의 모든 특성을 물려받아 새로운 클래스를 작성하는 기법을 말한다. 

상속의 목적과 효과는 세 가지이다. 

1. 기존 클래스를 재활용한다.

2. 공통되는 부분을 상위 클래스에 통합하여 반복을 제거하고 유지, 보수를 편리하게 한다.

3. 공동 조상을 가지는 계층을 만들어 객체 집합의 다형성을 부여한다. 

 

클래스는 필요한 멤버를 모두 포함하고 적절히 멤버를 숨겨 자신을 방어함으로써 프로그램 부품으로 사용한다. 

하지만 객체가 동작하는 환경은 다르기 때문에 완성된 클래스에 기능을 추가하거나 변경해야 하는 경우가 빈번하다. 

 

상속할 때 원본 클래스가 어떤 건지 밝히고 이 외에 필요한 멤버를 추가 선언한다. 그러면 컴파일러는 원본 클래스 모든 멤버 선언문을 가져오고 추가 선언한 멤버도 클래스 안에 같이 포함한다. 전통적인 방법인 복사, 붙여넣고 수정하는 방식을 컴파일러가 대신한다. 컴파일러가 진짜 소스를 고치는 건 아니고 컴파일 중간 단계에서 이 작업을 한다. 

 

사실 복사하고 뜯어 고치는 전통 방법과 상속을 하는 방법은 근본적으로 차이점이 없다. 그러나 유지, 보수 측면에서 차이가 있다. 왜냐하면 상속에서 원본 클래스를 고치고 싶은 경우 원본 클래스만 고치면 되기 때문이다. 

 

클래스씨리 상속될 때 상위 클래스를 기반 클래스(Base Class)라고 하며 상속 받는 클래스를 파생 클래스(Derived Class)라고 한다. 부모, 자식이라는 용어를 대신 사용하기도 하고 상위 클래스(Super Class), 하위 클래스(Sub Class)라는 용어를 쓰기도 한다. 용어는 언어에 따라서 다르다. 

 

상속과 정보 은폐

액세스 지정자 클래스 외부  파생 클래스  설명
Private 액세스 금지 액세스 금지 무조건 금지
protected  액세스 금지 액세스 허용 파생 클래스만 허용
public 액세스 허용 액세스 허용 무조건 허용

protected는 상속 관계에 있지 않는 클래스나 외부는 private와 같다. 파생 클래스는 public과 같다. 

 

상속 액세스 지정

상속 구문은 다음과 같다. 

class 파생클래스 : public/protected/private 기반 클래스{

추가 멤버 선언

};

 

기반 클래스의 private 멤버는 어떤 경우라도 파생 클래스에서 읽을 수 없다. 

기반 클래스의 public, protected 멤버는 상속 액세스 지정자에 따라 액세스 속성이 변경된다.

 

상속 액세스 지정자가 public이면 기반 클래스 액세스 속성이 그대로 유지된다. 

상속 액세스 지정자가 private, protected인 경우 부모 모든 멤버가 상속되면서 private, protected로 변경된다. 

상속 액세스 지정자가 생략되면 디폴트인 private가 적용된다. 

따라서 다음은 동일한 문장이다. 

class D: b

class D : private B

 

클래스는 정보를 숨기려는 경향이 있기 때문이다. 구조체는 생략시 public이 적용된다. 

일반적으로 상속이라고 하면 public 상속을 의미한다. 나머지 두 개는 특수한 경우에만 사용한다. 

 

상속의 특성

C++ 상속의 특성

1. 하나의 기반 클래스로부터 여러 개 클래스를 파생시킬 수 있다. 

2. 파생 깊이도 제한이 없다. 

3. C++은 두 개 이상 클래스로부터 새로운 클래스를 파생시킬 수 있는데 다중 상속이라고 한다. 

4. 기본  타입의 상속은 허가되지 않는다. 

 

2차 상속

상속은 깊이 제한이 없어 파생 클래스로 또 다른 클래스를 파생할 수 있는데 이걸 이차 상속이라고 할 수 있다. 

 

객체 생성 및 파괴

파생 클래스는 기반 클래스 모든 멤버를 상속받지만 이 멤버를 어떻게 초기화할 지는 모른다. 그래서 기반 클래스에게 초기화를 부탁해야 한다. 

상속받는 멤버 중 일부는 private 액세스 속성을 가질 수 있으므로 파생 클래스가 이 멤버를 초기화할 권한은 없다. 

자식 조차 공개하지 않겠다고 숨겨놓은 것이라서 파생 클래스는 부모 private 멤버에 관심을 가질 수 없고 건드릴 수 없다. 

대신 기반 클래스의 public 생성자를 호출하여 상속받은 멤버를 초기화해야 한다. 생성자는 항상 public이므로 누구나 호출할 수 있다. 

 

상속받은 멤버 의미와 초기화 방법을 가장 잘 알고 있는 주체는 이 멤버를 정의한 클래스이다. 따라서 기반 클래스 생성자를 이용하는게 합리적이다. 

 

객체 하나 생성하는데 조상 클래스 생성자를 일일이 호출한다. 생성자 특성상 길이가 짧고 내부 정의를 하는게 대부분 인라인이기 때문에 함수 호출 부담이 없어 속도에 염려하지 않아도 된다. 

 

초기화 리스트를 통해 기반 클래스 생성자를 연쇄 호출하여 상속받지 않는 멤버는 자신이 직접 초기화한다. 

기반 클래스는 파생 클래스가 동작하기 위한 전제 조건이므로 파생 클래스 멤버보다 상속받은 멤버가 먼저 초기화해야 한다. 생성자 본체가 실행하기 전 상속받은 멤버는 초기화해야 하며 그러기 위해서 초기화 리스트를 사용해야 한다. 파생 클래스 생성자 본체에서 기반 클래스 생성자를 직접 호출할 수 없다. 

Circle(int ax, int ay, char ach, int aRad) {
	Point(ax,ay,ach);
    Rad=aRad;
}

예를 들어 위와 같은 경우 생성자 본체에 있는 Point 호출문은 상속받은 멤버를 초기화하는 문장이 아니라 Point 클래스 생성자를 호출하여 이름이 없는 임시 Point 객체를 생성하는 문장이 된다. 

생성자 본체에서 상속받은 멤버를 직접 초기화하는 코드를 보자.

Circle(int ax, int ay, char ach, int aRad) {
	x=ax; y=ay; ch=ach;
    Rad=aRad;
}

상속받은 멤버를 파생 클래스가 항상 마음대로 액세스할 수 있는 건 아니기 때문에 대입은 초기화와 다르다. 

파생 클래스 생성자 초기화 리스트에는 거의 예외없이 기반 클래스 생성자 호출이 오며 자신이 전달받은 인수 일부를 기반 클래스 생성자에게 전달한다. 

 

(인수들) : (상속받은 인수들) {

본체 여기서 자신 고유 멤버 초기화

}

 

파생 클래스 객체가 파괴될 때는 생성자가 호출된 역순으로 파괴자가 호출된다. 

먼저 자신의 파괴자가 호출되어 스스로 멤버를 정리하며 상속 계층을 따라 부모 파기좌가 연쇄적으로 호출되어 상속된 모든 멤버의 정리 작업을 한다. 자식이 파괴되는 동안 부모 멤버를 참조할 수 있어야 하므로 부모는  살아 있어야 한다. 부모가 파괴될 때 자식 멤버를 참조할 일은 전혀 없다. 

 

 

멤버 함수 재정의

클래스가 파생될 때 기반 클래스로부터 대부분 멤버를 상속받지만 일부 제외되는 게 있다. 

안되는 것은 다음과 같다.

1. 생성자와 파괴자

2. 대입 연산자

3. 정적 멤버 변수와 정적 멤버 함수

4. 프렌드 관계 지정 

이 멤버들이 상속에서 제외되는 이유는 기반 클래스만의 고유 처리를 담당하기 때문이다.

생성자와 파괴자, 대입 연산자는 특정 클래스에 종속적이며 해당 클래스 멤버에만 동작하기 때문에 파생 클래스는 이 함수들을 직접 사용할 필요가 없다. 대신 초기화 리스트에서 호출할 수 있다. 생성할 때 자동으로 호출되어 상속된 멤버를 대신 초기화하며 객체가 일단 생성 완료되면 다시 호출할 필요가 없다. 파생 클래스가 이 함수들을 가지고 있어야 할 이유가 없다. 

 

이런 특수 경우를 제외하고 기반 클래스 모든 멤버가 파생 클래스로 무조건 상속된다. 원하는 멤버만 선택적 상속한다거나 특정 멤버를 상속받지 않는 방법은 없다. 

 

상속받은 멤버와 똑같은 이름으로 다시 선언하면 자신의 멤버가 우선 참조된다. 

이 상황은 전역변수와 지역변수 이름이 중복되었을 때 유사하다. 

만약 부모 멤버 참조를 하고 싶다면 멤버 이름 앞에 범위 연산자와 부모 클래스 이름을 적는다. 

부모로부터 상속 받은 멤버 함수를 다시 작성하는 것을 재정의라고 하는데 오버라이딩(Overriding)이라고 한다. 

 

다중 상속

다중 상속(Multiple Inheritance)는 두 개 이상 기반 클래스로부터 새로운 클래스를 상속하는 것이다. 

복잡도에 비해 실용성이 떨어지기 때문에 처음부터 깊이 공부할 필요는 없는 주제이다.

단일 상속과 마찬가지로 상속받은 멤버 초기화는 기반 클래스 생성자가 대신 한다. 

클래스 선언문에 나타난 기반 크래스 순서대로 생성자가 호출된다. 

 

다중 상속 문제점

다음 예시를 보자

class B : public A, public A
{
	...
};

한 클래스로 두 번 상속받는 건 금지되어 있다.  왜냐하면 이름 간 충돌이 생기기 때문이다. 

간접적으로 한 클래스를 두 번 상속할 수 있다. 예를 들어 A라는 클래스로 B, C를 만들고 B,C 다중 상속하여 D를 만들면 된다. 결국 D에는 a라는 멤버 변수 두 개 존재한다. 이 두 개가 같은 의미를 가지면 불필요한 기억 장소가 낭비된다. 

그 보다 문제는 D에서 a를 칭할 때 어떤 멤버를 가리키는지 모호하다는 것이다. 

만약 다른 의미를 가지면 B::a, C::a 이런 식으로 소속 기반 클래스를 명시하여 구분할 수 있다. 

 

가상 기반 클래스 

다중 상속 문제점은 한 클래스를 간접적으로 두 번 상속받을 경우 이 클래스 멤버가 중복되어 메모리 낭비가 생기며 어떤 멤버를 가리키는 지 알 수 없는 모호함이 생긴다는 것이다. 이 문제를 해결하기 위해 멤버를 한 번 상속하도록 하면 된다. 

이런 클래스를 가상 기반 클래스(Virtual Base Class)라고 한다. 이렇게 지정된 클래스는 간접적으로 두 번 상속되더라도 결과 클래스에 멤버를 한 번만 상속한다. 가상 기반 클래스를 지정할 때 앞에 virtual이라는 키워드를 쓴다. virtual 키워드와 상속 액세스 지정자 순서는 무관하지만 보통 virtual을 먼저 쓴다. 

class B : virtual public A
...

class C : virtual public A
...

class D : public B, public C{
protected:
	int d;
public:
	D(int aa, int ab, int ac, int ad) : A(aa), B(aa, ab), C(aa, ac) { d = ad; }
}

양쪽 다 virtual 상속을 해야 하며 한쪽만 virtual 상속하면 효과없다. 

D 생성자 초기화 리스트에서 B(aa, bb)를 부르는데 이 때 B 생성자가 A 생성자를 호출하지 않는다. 

중간 클래스는 가상 기반 클래스 멤버를 직접 소유하지 않고 최종 클래스가 이 멤버를 어떤 용도로 사용하는지 알 수 없다. 

또한 두 경로인 B(aa, ab)와 C(aa, ab)가 D 생성자 초기화 리스트에서 호출할 때 서로 A 생성자를 호출하면 A는 어떤 값으로 초기화하는지 애매해진다. 서로 값을 덮어쓰는 추돌도 발생한다. 그래서 중간 단계 클래스들은 가상 기반 클래스 멤버 초기화를 최종 클래스에게 맡긴다. 

 

그래서 가상 기반 클래스로부터 상속받은 멤버는 최종 클래스가 직접 초기화해야 한다. 그래서 D 생성자에 A(aa) 호출문이 필요한 것이다. a 멤버는 B와 C를 통해 상속받았지만 최종 클래스인 D의 것이므로 직접 초기화해야 한다. 만약 D 생성자가 A 생성자를 호출하지 않으면 A 디폴트 생성자가 호출되고 A가 디폴트 생성자를 정의하지 않는다면 에러 처리된다. 

 

파생 클래스는 바로 위 기반 클래스 생성자만 호출할 수 있으며 할아버지 생성자를 호출할 수 없지만 다중 상속 경우는 예외이다. 다중 상속은 정교한 문법에 예외로 두어야 할 정도로 복잡한 문제를 발생한다. 

 

클래스 재활용

포함

포함(Containment)이란 재활용하고 싶은 클래스 객체를 멤버 변수로 선언하는 방법이다. 

구조체 안에 구조체가 있는 것처럼 클래스도 가능하다. 

 

class Date { 
	protected:
    	int year, month, day:
    public:
    	Date(int y, int m, int d) { year = y; month = m; day = d; }
        void OutDate() { ... }; }
};

class Product {
	private:
    	char Name[64];
        char Company[32];
        Date ValidTo;
        int Price;
    public:
    	Product(char *aN, char *aC, int y, int m, int d, int aP) : ValidTo(y,m,d) {
        	...
        }
      	void OutProduct() {
        	...
        }
};

객체는 생성자 본체가 실행되기 전 상속받은 모든 멤버와 포함된 객체를 완전히 초기화해야 한다. 

포함된 객체는 반드시 초기화 리스트에서, 즉 생성자 본체 이전에 초기화해야 한다. 

이 때 초기화 리스트에는 클래스 이름이 아니라 객체 멤버 이름을 이용한다.

 

만약 초기화 리스트에서 포함된 객체 초기식이 없으면 디폴트 생성자가 호출된다. 

위에서 Date는 디폴트 생성자 정의가 없으므로 에러 처리된다. 

포함된 객체를 어떻게 초기화할 지 결정할 수 없기 때문이다. 

 

Product(char *aN, char *aC, int y, int m, int d, int aP} {
	ValidTo = Date(y,m,d);

초기화 리스트에 ValidTo 초기식을 빼고 생성자 본체에 대입문을 작성한 방법이다.

에러없이 컴파일되고 정상 초기화는 되겠지만 초기화 과정이 달라진다. 

Date 디폴트 생성자로 ValidTo가 쓰레기값으로 초기화된 후 Product 생성자 본체에서 Date 생성자를 호출하여 임시 객체를 만들고 이 임시 객체가 ValidTo 객체로 대입 연산자가 실행되면서 대입된다. 

이중 생성 과정과 대입 연산자까지 호출된다.

디폴트 초기화, 임시 객체 생성, 대입 여산 중 깊은 복사, 임시 객체 파괴까지 긴 과정을 거친다. 

따라서 초기화 리스트에서 포함 객체를 초기화하는 것이 낫다. 

 

클래스가 클래스를 포험하고 있는 관계를 HAS A  관계라고 한다. 

소유 관계이며 상속관계를 표현하는 IS A와 다르다.

 

 

private 상속

포함 관계에서는 포함하는 클래스가 포함되는 객체 액세스 속성을 임의로 결정한다. 

지금까지 연구한 public 상속은 기반 클래스 액세스 속성이 파생 클래스에서 그대로 유지되는 상속이다.

private상속은 부모의 public, protected 멤버를 private로 바꾼다. 그래서 파생 클래스는 이 멤버를 액세스할 수 있지만 외부에서는 상속받은 멤버를 참조할 수 없다. 심지어 2차 파생되는 자식에게도 공개되지 않는다. 

 

포함과 private 상속

 포함과 private 상속은 둘 다 기존 클래스를 재활용하는 기법이고 HAS A 관계를 표현하는 목적도 동일하다. 

차이점은 여러 객체를 쓸 수 있는가이다. 포함은 그냥 쓰면 되지만 상속은 같은 클래스를 두 번 이상 또 쓸 수 없다.

물론 다중 상속을 이용해서 여러 개를 쓸 수 있지만 복잡하다. 

 

포함은 포함된 객체의 public 액세스 속성을 가지는 것만 직접 참조할 수 있다. 

A가 B를 포함해도 A는 B의 외부이다. 그래서 private도 안되고 protected도 안된다. 

 

인터페이스 상속과 구현 상속

포함과 private 상속은 둘 다 객체 구현만 재사용하고 인터페이스는 상속받지 않는다. 

public 상속은 구현뿐만 아니라 인터페이스까지 같이 상속한다. 

 

구현 상속이란 객체의 구체적인 동작만 재사용할 수 있고 인터페이스는 물려받지 않는 상속이다.

즉, 멤버 함수는 호출할 수 있어도 스스로 멤버함수는 가지지 않는 상속이다. 

예를 들어 위 예시를 보면 Product를 포함하는 객체 ValidTo의 OutDate를 호출하여 기능을 사용할 수 있지만 Product는 OutDate를 멤버로 가지는 건 아니다. 이걸 알 수 있는 방법은 외부에서 OutDate를 쓰면 호출할 수 없지만 S.OutDate(); 이런 식으로 사용할 수 있다. 

 

private 상속은 기반 클래스 모든 멤버를 상속과 동시에 private 속성으로 바꾼다. 그래서 Product 내부에서 상속받은 멤버 함수 OutDate를 호출할 수 있지만 외부에서는 이 멤버함수가 숨겨져서 Product는 이 인터페이스를 가지지 않는 것과 같다.

외부뿐만 아니라 Product에서 파생되는 클래스도 OutDate는 알려지지 않는다. 

 

Date 클래스로 private 상속 받은 Product에는 OutDate함수가 있지만 외부에서 호출할 수 없게 숨겨지므로 공개된 인터페이스는 없는 셈이다. 인터페이스란 클래스의 공개된 기능을 의미한다. private 상속은 부모의 공개된 기능을 상속과 동시에 안으로 숨겨버린다. 그래서 인터페이스를 상속하지 않는 것과 같다. 

 

함수는 물려받지 않고 코드만 물려받는 이런 상속을 구현 상속이라고 한다. 

 

반면 public 상속은 기반 클래스 멤버를 물려받아 완전히 자기것으로 만든다. 

파생 클래스는 기반 클래스 인터페이스를 그대로 물려받아 외부 공개하여 후손 클래스에게 물려줄 수 있다. 

 

정리하면 private 상속과 public 상속의 차이점은 상속받은 인터페이스가 외부 공개를 하는가 안 하는가에 차이점이 있다. 

구현과 인터페이스를 상속받아 외부로 공개하면 IS A관계가 된다. 

 

포함 : 외부에서 직접 호출 불가.

private 상속 : 외부에 무조건 숨겨짐.

public 상속 : 외부에 공개.

 

구현 상속은 단순히 어떤 객체를 재사용한다. 인터페이스 상속은 계층 관계를 이뤄서 다형성을 구현할 수 있기 때문에 재활용 이상의 의미를 가진다. 객체 지향의 진정한 매력은 다형성이다. 이를 위한 조건이 바로 public 상속이다. 

 

중첩 클래스

클래스 선언문 안에 다른 클래스가 선언되는 형태이다. 

내부에서만 사용하며 외부에는 전혀 알릴 필요가 없을 때 사용한다. 

 

void func()
{
	struct dummy {
    	static void localfunc(){
        	...
        }
    };
    dummy::localfunc();
}

C++는 지역 함수를 지원하지 않지만 함수를 가지는 클래스를 지역 타입으로 만들 수 있어서 지역 함수 구현 가능하다.

struct 선언은 class 선언하고 public: 을 붙인 것과 같다.

지역 클래스 함수는 static이어야 한다. 그래야 객체없이 클래스명으로 바로 호출 가능하다. 만약 정적이 아니라면 함수를 호출하기 위해 객체를 만들고 선언해야 한다. 

localfunc은 func함수 안에 있는 지역 클래스 소속 정적 멤버 함수이므로 이 함수는 func 외부로 알려지지 않는다. 따라서 main함수에서 이 함수를 호출할 수 없다. 사실 main에게 localfunc 뿐만 아니라 dummy 지역 클래스 자체가 알려지지 않는다. 

'컴퓨터공학 > C, C++' 카테고리의 다른 글

C,C++> 템플릿  (0) 2022.08.31
C,C++> 다형성  (0) 2022.08.22
C,C++> 연산자 오버로딩  (0) 2022.08.18
C,C++> 캡슐화  (0) 2022.08.09
C,C++> 생성자  (0) 2022.08.08