Computer Science/C & C++

[C++] Class Inheritance | 클래스 상속

lww7438 2020. 3. 5. 11:26

Class Inheritance

클래스 상속

- OOP의 지향점 중 하나인 코드의 재활용*을 구현하는 방법이다.
- 상속 기능을 통해 Base Class(기초 클래스)로부터 모든 데이터 멤버와 멤버 함수들을 넘겨받은 Derived Class(파생 클래스)를 만들 수 있다.
- Derived Class에서는 Base Class에서 넘겨받은 멤버에 데이터 및 기능을 추가할 수 있다.
- 파생 클래스의 객체를 생성하게 되면, 프로그램은 그에 해당하는 기초 클래스 객체를 먼저 생성한 다음, 추가적인 데이터를 덧붙이는 방식으로 파생 클래스 객체를 만든다. (기초 클래스 Constructor -> 파생 클래스 Constructor)
- 생성 순서와 정반대로, 객체의 파괴는 파생 클래스의 객체부터 먼저 파괴되며, 그 이후에 기초 클래스의 객체가 파괴된다. (파생 클래스 Destructor -> 기초 클래스 Destructor)
- 상속받는 메서드는 상위 클래스의 메서드와 함수 Signature가 같다.

※ 생성자, 파괴자, 대입 연산자는 상속되지 않는다.
- 대입 연산자는 상위 클래스의 경우와 하위 클래스에서의 함수 Signatrue가 같을 수가 없기 때문이다.

* 코드의 재활용이 주는 이점
- 이미 개발되어 성능이 입증되어 있으므로 버그 발생의 소지가 적다.
- 개발 시간을 크게 절약할 수 있다.
- 세부 구현에 힘을 아낄수록 종합적인 프로그램 개발 전략을 세우는 데 집중할 수 있게 된다.


Terms (용어 정리)

Base Class (기초 클래스)
- 어떤 클래스를 다른 클래스로부터 상속받을 때, 기원이 되는 클래스이다.

Derived Class (파생 클래스)
- 기초 클래스로부터 상속을 받은 클래스이다.


Syntax (문법)

※ 생성자, 파괴자, 대입 연산자는 상속되지 않는다.
(이 세 메서드는 본질적으로 기초 클래스의 요소를 이용해서 정의해야 한다는 공통점이 있다.)

복사 생성자 : 멤버 초기자 리스트를 통한 상위 클래스의 생성자 호출
디폴트 생성자 : 명시하지 않아도 자동으로 호출된다.
파괴자 : 파괴자는 호출 순서가 생성자와 반대임을 명심한다.
대입 연산자 : 상위 클래스에서 오버로딩된 대입 연산자 함수를 \(\texttt{::}\) 표기를 이용하여 지목한다.
자세한 구현 사례는 아래 포스트를 참조

- 파생 클래스 선언방법

// Ex. 파생 클래스 선언 예시

class dClass : accessLevel bClass {
    ...
};
// 여기서 dClass는 Derived Class(파생 클래스)를,
// bClass는 Base Class(기초 클래스)를 의미하며,
// accessLevel은 접근 레벨(private, protected, public중 하나)을 의미한다.


// 실 사용 예시
class GolfClub : public MemberSet {     // public Derivation (public 파생)
    ...
};
// MemberSet 클래스의 멤버를 상속받은 GolfClub 클래스 선언

※ 기초 클래스와 파생 클래스의 정의를 별도의 헤더파일에 분리하여 정의할 수도 있지만,
두 클래스는 서로 연계되어 있기 때문에 함께 묶어두는 편이 더 체계적이라 할 수 있다.

- 파생 클래스의 생성자 (Member Initializer Syntax를 이용한, 기초 클래스의 \(\texttt{private}\) 멤버에 접근)

// Ex. 멤버 초기자 리스트 문법을 이용한 파생 클래스의 생성자 정의

using std::string;

class Father {
private:
    int oldMem_1;
    string oldMem_2;
public:
    Father(int, string&);
    Father(Father&);
    ...
};

class Son : public Father {
private:
    double newMem_1;
public:
    Son(int, string&, double);
    Son(double, Father&);
    ...
};

Son::Son(int a, string& b, double c) : Father(int a, string& b) {  // Father 클래스 객체의 생성자가 먼저 실행된다.
    newMem = c;    // 생성자에서 초기화되지 못한 파생 클래스의 멤버만 초기화한다.
}

Son::Son(double d, Father& e) : Father(e), newMem_1(d) {   // 복사 생성자를 이용한 초기화
}

// 프로그래머가 멤버 초기자 리스트에 기초 클래스의 생성자의 호출을 생략한 경우,
// 프로그램이 디폴트 기초 클래스 생성자를 자동으로 호출한다.

※ 파생 클래스는 인접한 직계 상위 기초 클래스에만 값을 전달할 수 있다. (가상 기초 클래스 제외)
※ \(\texttt{public}\) 상속에서, 파생 클래스는 기초 클래스의 \(\texttt{private}\) 멤버에 직접 접근할 수 없으므로, 멤버 초기자 리스트와 같은 수단을 통해 우회하여 기초 클래스 생성자에 접근한다.
※ 멤버 초기자 리스트는 생성자가 아니면 사용할 수 없다.
※ 멤버 초기자 리스트 표현을 사용하지 않으면, 기초 클래스의 디폴트 생성자가 자동으로 호출된다.

- 기초 클래스의 포인터/참조는 명시적 데이터형 변환 없이도 파생 클래스의 객체를 지시/참조할 수 있다.

// Ex. 기초 클래스 객체의 포인터/참조 변수

Son Little(10, "little", 12.34);
Father* Big_1 = &Little;       // 파생 클래스의 double 멤버는 넘어오지 않는다.
Father& Big_2 = Little;        // 파생 클래스의 double 멤버는 넘어오지 않는다.

// Father(Father&); 매개변수로 Father 클래스 객체를 요구한다.
Father Big_3 = Father(Little); // 기초 클래스 참조형(포인터형)의 매개변수로 파생 클래스 객체가 오는 것 또한 가능하다.

// 파생 클래스 객체로 기초 클래스 객체를 초기화/대입할 수도 있다.
Father Big_4 = Little;

※ Narrowing은 당연히 감수해야 한다. 파생 클래스 객체에서 기초 클래스의 멤버와 겹치는 멤버의 데이터만 가져오게 된다.
※ 파생 클래스 객체가 기초 클래스 객체를 지시/참조하게 하는 것은 불가능하다.

- 파생 클래스의 대입 연산자
(파생 클래스에서 대입 연산자를 오버로딩
할 때, 기초 클래스의 \(\texttt{private}\) 멤버에 접근해야하는 경우에 사용하는 표기법)

// Ex. 파생 클래스에서의 대입 연산자 오버로딩 예시

class Son : public Father {
private:
    char* d_Member;
public:
    Son& operator=(const Son&);
    ...
};

Son& Son::operator=(const Son& sn) {
    if (this == &sn)       // 자기 자신이 대입되는 것을 방지하는 장치
        return *this;
    Father::operator=(sn);  // *this = sn;과 같다, 하지만 여기서 = 연산자는 기초 클래스에서 오버로딩 된 대입 연산자이다. (그렇기 떄문에 ::연산자를 통한 범위 결정 과정이 필요하다.)
    delete [] d_Member; 
    d_Member = new char[std::strlen(sn.d_Member) + 1];
    std::strcpy(d_Member, sn.d_Member);
    return *this;
}

// 여기서, 14번째 구문은 기초 클래스의 대입 연산자를 이용하여
// 기초 클래스의 멤버들에 대한 대입을 한 구문으로 처리하는 역할을 한다.

// 반드시 연산자 표기 대신, 연산자 함수 표기와 ::연산자를 이용해 기초 클래스의 대입 연산자를 사용하는 것임을 명시적으로 표시해야 한다.

// 파생 클래스에서 기초 클래스의 메서드를 통해 기초 클래스의 데이터 멤버들에 접근하는 예시이다.

※ 여기서 연산자 함수 표기(\(\texttt{a.operator=(b)}\)) 대신, 대입 연산자 표기(\(\texttt{a = b}\)를 사용하면,
파생 클래스의 대입 연산자(지금 정의중인 연산자 함수)가 호출되어 Recursion(재귀)이 일어나게 된다.
※ 연산자 표기에서, 어느 클래스의 연산자가 호출되는지 구분하는 간단한 방법은, 연산자 왼쪽의 객체가 호출하는 주체, 연산자 오른쪽의 객체는 매개변수로 전달되는 객체임을 기억하는 것이다. 그러므로 대입 연산자의 왼쪽의 객체에 해당되는 클래스의 연산자 함수가 호출되는 것이다.

- 파생 클래스에서 상위 클래스의 \(\texttt{friend}\) 함수에 접근하는 방법

// Ex. 파생 클래스에서 기초 클래스에 Grant된 friend 함수를 이용하는 방법

// Son 클래스는 Father 클래스의 파생 클래스이다.
using namespace std;

ostream& operator<<(ostream& os, const Father& ft) {
    cout << ft.F_Member << endl;
    return os;
}

ostream& operator<<(ostream& os, const Son& sn) {
    cout << (const Father &) sn;     // 상위 클래스로 강제 형변환하여, 상위 클래스에 Grant된 friend 함수를 호출한다.
    cout << sn.S_Member << endl
    return os;
}

※ \(\texttt{friend}\) 함수는 멤버 함수가 아니므로, \(\texttt{::}\) 연산자를 이용할 수 없기 때문에, 위 코드와 같이 강제 데이터형 변환을 통해 원형을 일치시켜 프로그래머가 의도한 함수를 선택할 수 있게 하는것이다.


Access Level of Base Class (기초 클래스의 접근 레벨)

- 상속받을 기초 클래스의 접근 레벨에 따라, 파생 클래스가 가질 수 있는 멤버의 범위가 달라진다.
- Default는 \(\texttt{private}\) Inheritance(\(\texttt{private}\) 상속)이다.

\(\texttt{public}\) Inheritance (\(\texttt{public}\) 상속)
- 기초 클래스의 \(\texttt{public}\) 멤버들이 파생 클래스의 \(\texttt{public}\) 멤버가 된다.
- 기초 클래스의나머지 멤버(\(\texttt{private}\) 멤버와 \(\texttt{protected}\)멤버들)는 기초 클래스의 \(\texttt{private}\) 메서드와 \(\texttt{protected}\) 메서드를 통해 접근할 수 있다.
- \(\texttt{public}\) 파생 클래스의 생성자는 기초 클래스의 생성자를 이용해서 상속받은 \(\texttt{private}\) 멤버의 초깃값을 설정할 수 있다.
- \(\texttt{public}\) Inheritance(\(\texttt{public}\) 상속)는 "HAS-A" 관계를 나타내지 않는다.
ex) 파생 클래스는 상속을 통해 구현되는 것이지, 기초 클래스를 멤버로 포함시키는 방법으로 구현되지는 않는다.
- \(\texttt{public}\) 상속은 "Is-Implemented-As-A" 관계를 나타내지 않는다.
ex) \(\texttt{stack}\)은 \(\texttt{array}\)를 통해 구현될 수는 있지만, \(\texttt{array}\)
로부터 \(\texttt{stack}\)을 파생시키지는 않는다. 스택은 배열에는 없는 특성이 있기 때문이다. 즉, 스택은 배열을 구현의 수단으로써 사용하지만, 배열에 포함되는 개념은 아님을 의미한다.
- \(\texttt{public}\) 상속은 "USES-A" 관계를 나타내지 않는다.
ex)

\(\texttt{public}\) 파생 기초 클래스 파생 클래스
  \(\texttt{private}\) 멤버 \(\texttt{protected}\) 멤버 \(\texttt{public}\) 멤버 \(\texttt{private}\) 멤버 \(\texttt{protected}\) 멤버 \(\texttt{public}\) 멤버
기초 클래스 메서드 O O O X X X
파생 클래스 메서드 X X O O O O
외부 함수 X X O X X O

O : 직접 접근 가능 / X : 직접 접근 불가
(경우에 따라, 간접적인 접근방법이 있음을 의미한다.)

\(\texttt{protected}\) Inheritance (\(\texttt{protected}\) 상속)
- 기초 클래스의 \(\texttt{public}\) 멤버와 \(\texttt{protected}\) 멤버가 파생 클래스의 \(\texttt{protected}\) 멤버가 된다.
- \(\texttt{private}\) 상속과의 차이는 파생 클래스로 부터 또 다른 클래스를 파생시킬 때(3세대 이상의 Class Hierarchy) 생기는데, \(\texttt{private}\) 상속에서는 3세대 클래스가 1세대 클래스의 인터페이스에 접근하지 못하고, \(\texttt{protected}\) 상속에선 3세대 클래스가 1세대 클래스의 인터페이스에 접근 가능하다.

\(\texttt{private}\) Inheritance (\(\texttt{private}\) 상속)
- 기초 클래스의 \(\texttt{public}\) 멤버와 \(\texttt{protected}\) 멤버들이 파생 클래스의 \(\texttt{private}\) 멤버가 된다.
(즉, 구현 내용은 상속되지만, 인터페이스는 상속되지 않는다. = \(\texttt{private}\) 상속과 컨테인먼트의 공통점)
- \(\private\) 상속은 이름없는 상위 클래스 객체를 클래스에 추가하는 것이며, Containment(컨테인먼트)*는 이름있는 상위 클래스 객체를 클래스에 추가하는 방법이다.
- \(\texttt{private}\) 상속은 HAS-A 관계를 나타내는 수단 중 하나이다.
- \(\texttt{private}\) 상속에서 파생 클래스의 생성자를 구현할 때, 기초 클래스의 데이터 멤버에 접근해야 하는 경우가 생기는데, 이 때 해당 멤버의 이름으로는 접근이 불가능하고, 기초 클래스의 생성자로 접근해야 한다.
(이는 생각해보면 당연한데, 기초 클래스의 \(\texttt{private}\) 멤버에는 기초 클래스의 메서드로만 접근 가능하기 때문이다.)
- \(\texttt{private}\) 상속에서 파생 클레스 내에서는 기초 클래스의 메서드(\(\texttt{public}\) 타입이나 \(\texttt{protected}\) 타입 메서드)를 기초 클래스의 이름과 \(\texttt{::}\) 연산자를 통해 접근할 수 있다.
- \(\texttt{private}\) 상속에서 파생 클래스의 메서드에서 기초 클래스의 \(\texttt{private}\) 멤버에 접근하는 방법 중 하나로, 기초 클래스로의 강제적 데이터형 변환이 있다.

// private 상속을 받은 파생 클래스에서 기초 클래스의 private 멤버에 접근하는 방법(상위 클래스로의 강제적 데이터형 변환) 예시

class Father{
    ...
};

class Son : private Father {
    const Father& Func() const;    // 상위 클래스의 private 멤버에 접근할 파생 클래스의 메서드
    ...
};

const Father& Son::Func() const {
    return (const Father &)*this;  // this가 가리키는 객체(파생 클래스 객체)를 강제적으로 상위 클래스의 객체로 변환시킨다.
};

- \(\texttt{private}\) 상속에서 기초 클래스에 Grant된 \(\texttt{friend}\) 함수에 접근하기 위한 방법으로, 강제 데이터형 변환을 통한 접근법이 있다. 이 때, \(\texttt{friend}\) 함수는 멤버 함수가 아니므로 \(\texttt{::}\) 연산자를 사용하여 어떤 클래스에 속하는지를 표현할 수 없다.

// Ex. private 상속에서 기초 클래스에 Grant된 friend함수를 파생 클래스의 friend 함수가 이용하는 방법 예시

class Father {
    friend ostream& operator<<(ostream&, const Father&);
    ...
};

class Son : Father {    // 상속의 디폴트는 private 형이다.
    friend ostream& operator<<(ostream&, const Son&);
    ...
};

ostream& operator<<(ostream& os, const Son& Mem) {
    os >> (const Father &) Mem;      // ostream& operator<<(ostream& os, const Father& Mem)가 여기서 호출된다.
    ...
    return os;
}
// 본질적으로, friend함수는 멤버 함수가 아니므로, 
// this나 ::와 같이 클래스에 관련된 표현은 사용하지 못하는 환경임을 명심해야한다.
// 또한, 클래스의 멤버 함수가 아니므로, 강제적으로 형변환을 하지 않는 한,
// 생성자가 호출되어 자동으로 변환되지 않는다는 점도 명심해야 한다.

* 상속에 의해 추가되거나, 컨테인먼트를 통해 클래스에 추가된 모든 객체를 Subobject(종속 객체)라고 한다.

※ \(\texttt{private}\) 상속, \(\texttt{protected}\) 상속의 경우, 파생 클래스에서 기초 클래스의 메서드를 \(\texttt{public}\) 처럼 사용하는 방법

1. \(\texttt{::}\) 연산자 활용

// Ex. private 상속, protected 상속에서 파생 클래스가 기초 클래스의 메서드를 public처럼 사용하는 방법 예시

returnType Son::Func() {
    return Father::Func();
}

2. \(\texttt{using}\) 선언 활용
- \(\texttt{using}\) 선언을 통해, 파생 클래스에서 사용할 특정한 기초 클래스의 멤버를 지정하는 방법이다.
- 파생 클래스 선언부의 상속 타입을 결정하는 부분에 명시해야 한다.
- \(\texttt{using}\) 선언은 소괄호, 리턴형, 함수 Signature(매개변수 정보)를 따로 필요로 하지 않고 멤버 함수의 이름만 요구한다.
- \(\texttt{using}\) 선언을 활용한 방법은 상속에만 적용되며, 컨테인먼트 방식에는 적용되지 않는다.

// Ex. using 선언을 활용한 기초 클래스 멤버 지정 예시

class Son : private Father {
    ...
public:
    using Father::Func_1;      // Func_1은 기초 클래스의 메서드 이름이다.
    using Father::Func_2;      // Func_2은 기초 클래스의 메서드 이름이다.
    ...
}

3. 파생 클래스의 \(\texttt{public}\) 영역에 기초 클래스의 해당 멤버 함수를 다시 정의하는 방법
- 이 방법은 2번 방법에서 \(\texttt{using}\) 선언만 빠진 형태와 비슷하다.
- 상속의 본질적인 의도를 퇴색시키는 구식 방법이기 때문에, 컴파일러가 \(\texttt{using}\) 선언을 지원한다면, 3번 방법 보다는 2번 방법을 사용할 것이 권고된다.

// Ex. 파생 클래스에서 기초 클래스의 멤버 함수를 재정의하는 방법 예시

class Son : private Father {
    ...
public:
    Father::Func_1;      // Func_1은 기초 클래스의 메서드 이름이다.
    Father::Func_2;      // Func_2은 기초 클래스의 메서드 이름이다.
    ...
}

 

※ Verieties of Inheritance