1. 상속의 개념
상속(inheritance)은 객체 지향 프로그래밍에서 코드 재사용을 가능하게 하는 가장 중요한 메커니즘입니다. 기존 클래스의 특성을 유지하면서 확장하여 새로운 기능을 추가할 수 있게 해주며, 이렇게 생성된 새로운 클래스를 파생 클래스(derived class)라고 합니다. 상속은 객체 지향 프로그래밍의 계층 구조를 나타내며, 단순한 개념에서 복잡한 개념으로 이해해 나가는 인지 과정을 구현합니다. 이전에 접했던 재사용은 함수 수준의 재사용이었지만, 상속은 클래스 설계 수준의 재사용입니다.
2. 상속 정의
Person은 부모 클래스(base class)이고, Student는 자식 클래스(derived class)입니다.
상속 관계와 접근 제한자:
기본 클래스 멤버 접근 방식
요약:
보호와 private 접근 제한자는 상속에서 다르게 작동합니다.
- 기본 클래스의 private 멤버는 어떤 방식으로 상속하든 파생 클래스에서는 보이지 않습니다. 여기서 보이지 않는다는 것은 기본 클래스의 private 멤버가 파생 클래스 객체에 상속되었지만, 문법적으로 파생 클래스 객체가 클래스 내부와 외부에서 접근할 수 없다는 의미입니다.
- protected 멤버는 파생 클래스에서 접근할 수 있지만, 클래스 외부에서는 접근할 수 없습니다. 이는 protected 멤버 제한자가 상속을 위해 등장했음을 보여줍니다.
#include<iostream>
using namespace std;
class Human
{
//public:
protected:
void Display()
{
cout << "이름:" << _name << endl;
cout << "나이:" << _age << endl;
}
protected:
string _name = "홍길동"; // 이름
int _age = 25; // 나이
};
class Graduate : protected Human
{
public:
Graduate()
:_studentID(2023001)
{ }
protected:
int _studentID; // 학번
};
class Professor : protected Human
{
public:
Professor()
:_employeeID(3020101)
{ }
protected:
int _employeeID; // 직원번호
};
- 표를 요약하면 기본 클래스의 private 멤버는 모두 자식 클래스에서 보이지 않습니다. 기본 클래스의 다른 멤버에 대한 자식 클래스에서의 접근 방식 == Min(멤버의 기본 클래스 접근 제한자, 상속 방식), public > protected > private 입니다.
- 키워드 class를 사용할 때 기본 상속 방식은 private이고, struct를 사용할 때 기본 상속 방식은 public입니다. 상속 방식을 명시적으로 작성하는 것이 좋습니다.
- 멤버 변수는 상속되지만, 각 객체의 멤버 변수는 고유하므로 상속됩니다. 멤버 함수는 상속할 필요가 없으며, 공통 부분에 정의하고 사용 시 호출하면 됩니다.
- 실제 사용에서는 일반적으로 public 상속을 사용하며, protected/private 상속은 거의 사용되지 않으며 권장되지도 않습니다. protected/private 상속으로 상속된 멤버는 파생 클래스 내부에서만 사용할 수 있으므로 실제로 확장 및 유지보수성이 좋지 않습니다.
- 파생 클래스가 기본 클래스를 상속할 때 상속 방식을 지정하지 않으면 기본값은 private입니다.
- 테스트 코드
#include<iostream>
using namespace std;
class Human
{
public:
void Display()
{
cout << "이름:" << _name << endl;
cout << "나이:" << _age << endl;
}
protected:
string _name = "홍길동"; // 이름
int _age = 25; // 나이
};
class Graduate : public Human
{
public:
Graduate()
:_studentID(2023001)
{ }
protected:
int _studentID; // 학번
};
class Professor : public Human
{
public:
Professor()
:_employeeID(3020101)
{ }
protected:
int _employeeID; // 직원번호
};
int main()
{
Graduate g;
Professor p;
g.Display();
p.Display();
return 0;
}
3. 기본 클래스와 파생 클래스 객체의 할당 변환
- 파생 클래스 객체는 기본 클래스 객체 / 기본 클래스 포인터 / 기본 클래스 참조에 할당할 수 있습니다. 이를 "슬라이싱" 또는 "커팅"이라고 부릅니다. 파생 클래스에서 부분을 잘라서 할당한다는 의미입니다.
- 기본 클래스 객체는 파생 클래스 객체에 할당할 수 없습니다: 기본 클래스와 파생 클래스의 메모리 레이아웃이 다르기 때문입니다. 기본 클래스 객체에는 파생 클래스에 새로 추가된 멤버 변수와 메서드가 없습니다. 이러한 할당은 정보 손실이나 정의되지 않은 동작을 유발할 수 있습니다.
- 변수 할당에는 암시적 형식 변환과 명시적 형식 변환이 있지만, 파생 클래스 객체는 기본 클래스 참조나 포인터에 할당할 수 있습니다. 이러한 할당에는 중간 변수가 없습니다. 파생 클래스 객체는 기본 클래스 객체의 확장이기 때문입니다. 이 할당은 암시적이며 컴파일러가 형식 변환을 자동으로 처리합니다. 4(참고). 기본 클래스 포인터나 참조는 명시적 형식 변환을 통해 파생 클래스 포인터나 참조에 할당할 수 있으며, 이는 후속 다형성에서 자세히 학습합니다.
4. 상속에서의 스코프
- 상속 체계에서 기본 클래스와 파생 클래스는 독립적인 스코프를 가집니다.
자식 클래스와 부모 클래스에 동일한 이름의 멤버가 있을 경우, 자식 클래스 멤버가 부모 클래스의 동일한 이름 멤버에 대한 직접 접근을 차단합니다. 이를 숨김(hiding) 또는 재정의(redefinition)라고 합니다. (자식 클래스 멤버 함수 내에서 기본 클래스::기본 클래스 멤버를 사용하여 명시적으로 접근할 수 있습니다)
여기서 _num은 동일한 이름의 멤버로 숨김이 구성됩니다.
멤버 함수의 숨김의 경우, 함수 이름이 같으면 숨김이 구성됩니다. 멤버 변수도 숨길 수 있습니다
B의 fun과 A의 fun은 재정의가 아닙니다. 왜냐하면 동일한 스코프가 아니기 때문입니다.
- 실제로 상속 체계 내에서 동일한 이름의 멤버를 정의하지 않는 것이 좋습니다.
- 스코프의 본질은 컴파일러가 "찾기"를 위한 규칙을 지시하는 것입니다. 이는 컴파일 단계에서 문법을 확인합니다. 후속 코드 세그먼트의 "찾기"는 링크 단계에서 발생하며, "찾기"를 구분해야 합니다.
- 동일한 이름의 변수를 사용할 때 가까운 우선 원칙을 따르며, 자식 클래스 스코프 - 부모 클래스 스코프 - 전역 스코프 순서입니다.
- 정적 멤버 함수는 상속될 수 있습니다: 왜냐하면 정적 멤버 함수는 클래스 인터페이스의 일부이며, 객체 존재와 독립적이기 때문입니다. 정적 멤버 함수는 특정 객체 인스턴스에 의존하지 않고, 객체 상태와 바인딩되지 않고 상속될 수 있습니다. 이는 정적 멤버 함수가 클래스 객체를 생성하지 않고도 호출될 수 있음을 의미합니다.
- 기본 클래스 객체는 정적 변수를 포함하지 않습니다: 왜냐하면 정적 변수는 클래스 수준에서 존재하며, 객체 존재와 독립적이기 때문입니다. 정적 변수는 클래스 로딩 시 초기화되고 클래스의 생애 주기 동안 존재하며, 객체 생성 시 초기화되지 않습니다. 정적 변수는 클래스의 멤버이며, 객체의 멤버가 아닙니다. 메모리를 할당하며 단 하나만 존재하며, 클래스의 모든 인스턴스 간에 공유됩니다.
5. 파생 클래스의 기본 멤버
"기본"이란 작성하지 않아도 컴파일러가 자동으로 생성해준다는 의미입니다. 파생 클래스에서 다음과 같은 멤버 함수가 생성됩니다:
- 초기화는 부모 클래스를 하나의 전체로 보고, 자식 클래스는 자신의 것을 초기화하며 부모 클래스의 것은 초기화 목록에서 자동으로 부모 클래스 기본 생성자를 호출하여 완료합니다. 만약 부모 클래스에 기본 생성자가 없다면 초기화 목록을 통해 부모 클래스 생성자를 호출하며, 이는 익명 객체처럼 초기화됩니다. 그렇지 않으면 부모 클래스에 기본 생성자가 없으면 오류가 발생합니다. 초기화는 선언 순서에 따라 이루어지며, 상속은 파생 클래스에서 선언된 것과 같으므로 먼저 초기화됩니다
이 코드에서 부모 클래스는 기본 생성자가 없으므로 초기화 목록을 통해 명시적으로 기본 클래스 생성자 Person(name)를 호출합니다. 초기화 목록의 역할:
- 자식 클래스 생성자는 초기화 목록을 통해 부모 클래스 생성자를 명시적으로 호출할 수 있습니다. 자식 클래스 생성자의 초기화 목록에서 부모 클래스 생성자를 명시적으로 호출하지 않으면, 컴파일러는 부모 클래스의 기본 생성자를 호출하려고 시도합니다.
- 부모 클래스에 기본 생성자가 없고, 자식 클래스 생성자의 초기화 목록에서도 부모 클래스의 다른 생성자를 명시적으로 호출하지 않으면, 컴파일러는 적절한 부모 클래스 생성자를 찾을 수 없기 때문에 오류를 발생시킵니다.
- 파생 클래스의 복사 생성자는 반드시 기본 클래스의 복사 생성자를 호출하여 기본 클래스의 복사 초기화를 완료해야 합니다.
- 파생 클래스의 operator=는 반드시 기본 클래스의 operator=를 호출하여 기본 클래스의 복사를 완료해야 합니다.
할당 시 부모 클래스와 자식 클래스의 호출 순서에 주의하세요. 이는 공개 멤버에서만 사용되며, 보호와 private에서는 권한이 변경됩니다.
- 파생 클래스의 소멸 함수는 호출된 후 자동으로 기본 클래스 소멸 함수를 호출하여 기본 클래스 멤버를 정리합니다. 이렇게 해야 파생 클래스 객체가 먼저 파생 클래스 멤버를 정리한 후 기본 클래스 멤버를 정리하는 순서를 보장할 수 있습니다.
부모 먼저 생성, 자식 먼저 소멸이라는 규정은 왜 그런가요? 왜냐하면 자식 클래스가 소멸한 후에도 부모 클래스가 사용될 수 있지만, 부모 클래스는 자식 클래스를 사용할 수 없기 때문입니다.
- 전체 코드
class Human
{
public:
// 생성자
/*Human(const char*name="김민정")
:_name(name)
{
cout << "Human()" << endl;
}*/
Human(const char* name)
:_name(name)
{
cout << "Human()" << endl;
}
// 복사 생성자
Human(const Human& h)
:_name(h._name)
{
cout << "Human(const Human& h)" << endl;
}
// 할당 연산자 오버로드
Human& operator=(const Human&h)
{
cout << "Human& operator=()" << endl;
// 자기 할당 확인
if (this!=&h)
{
_name = h._name;
}
return *this;
}
// 소멸자
~Human()
{
cout << "~Human()" << endl;
delete _pstr;
}
protected:
string _name;
string* _pstr = new string("20230001");
};
class Graduate :public Human
{
public:
// 파생 클래스 생성자, 초기화 순서는 부모 먼저 자식 나중
Graduate(const char* name = "이학생", int id = 0)
:Human(name)
, _id(id)
{
cout << "Graduate()" << endl;
}
Graduate(const Graduate& g)
:Human(g)
, _id(g._id)
{
cout << " Graduate(const Graduate& g)" << endl;
}
Graduate& operator=(const Graduate&g)
{
if (this != &g)
{
Human::operator=(g);
_id = g._id;
}
return *this;
}
~Graduate()
{
// 다형성의 이유로(나중에 자세히 설명), 소멸 함수의 함수명은
// 특별히 처리되어 destructor로 통일됩니다
// 부모 클래스 소멸을 명시적으로 호출하면, 자식-부모 순서를 보장할 수 없습니다
// 따라서 자식 클래스 소멸 함수가 완료되면 자동으로 부모 클래스 소멸을 호출하여 자식-부모 순서를 보장합니다
//Human::~Human();
cout << *_pstr << endl;
delete _ptr;
}
protected:
int _id;
int* _ptr = new int;
};
int main()
{
//Human h;
Graduate g1;
Graduate g2(g1);
Graduate g3("박교수", 42);
g1 = g3;
return 0;
}
호출 순서는 다음과 같습니다
6. 상속과 친구 관계
친구 관계는 상속되지 않습니다. 즉, 기본 클래스의 친구는 자식 클래스의 private 및 protected 멤버에 접근할 수 없습니다.
파생 클래스에 친구 선언이 없으면 오류가 발생합니다
전방 선언의 개념을 이해하세요:
역할:
- 선언만 하고 정의하지 않음: 컴파일러에게 특정 클래스나 함수의 존재를 알려주지만, 전체 구현 세부사항은 필요하지 않습니다.
- 헤더 파일 포함 감소: #include를 통해 다른 헤더 파일을 포함하는 것을 피하여 컴파일 시 의존 관계를 줄입니다. 핵심 목표: 불필요한 헤더 파일 포함을 줄여 모듈 간 의존 관계를 낮춥니다. 적용 시나리오: 타입 선언(예: 포인터, 참조, 함수 매개변수)만 필요한 경우 전방 선언을 우선 사용합니다.
주의사항: 완전한 타입 정보가 필요한 경우 반드시 헤더 파일을 포함해야 합니다.
7. 상속과 정적 멤버
기본 클래스에 static 정적 멤버가 정의되면, 전체 상속 체계에서 이러한 멤버는 하나만 존재합니다. 얼마나 많은 자식 클래스가 파생되든, 하나의 static 멤버 인스턴스만 존재하며, 모든 부모 클래스 객체가 동일한 static 멤버 인스턴스를 공유합니다. 정적 멤버는 부모 클래스와 자식 클래스에 속하며, 파생 클래스에서 별도로 복사되지 않습니다. 이는 사용권이 상속된다고 이해할 수 있습니다.
이러한 특성을 활용하여 사람의 수를 세어 총 몇 개의 객체가 생성되었는지 계산할 수 있습니다
Human::_count가 0으로 설정된 이유는 Student 클래스가 Human 클래스에서 상속받았고, _count가 protected 접근 권한이기 때문입니다. 이는 Student 클래스가 _count에 접근하고 수정할 수 있음을 의미합니다. 따라서 최종 출력 결과는 0입니다
8. 다이아몬드 상속과 가상 상속 (중요)
상속 방식
단일 상속: 자식 클래스가 하나의 직접 부모 클래스만 가질 때 이 상속 관계를 단일 상속이라고 합니다
다중 상속: 자식 클래스가 두 개 이상의 직접 부모 클래스를 가질 때 이 상속 관계를 다중 상속이라고 합니다
다이아몬드 상속: 다이아몬드 상속은 다중 상속의 특별한 경우입니다.
다이아몬드 상속 문제
아래의 객체 멤버 모델 구조에서 다이아몬드 상속에는 데이터 중복과 모호성 문제가 있음을 알 수 있습니다. Assistant 객체에서 Human 멤버가 두 개 존재합니다.
명시적으로 어떤 부모 클래스의 멤버에 접근할지 지정하여 모호성 문제를 해결할 수 있지만, 데이터 중복 문제는 해결할 수 없습니다.
모호성은 한 사람이 생활에서 두 가지 다른 신분을 가질 때의 상황으로 이해할 수 있지만, 기본 특징은 변하지 않습니다. 예를 들어 나이, 주민번호 등입니다. 모호성 문제는 두 가지 다른 역할을 나눌 때 발생하며, 기본 특징이 두 개 존재하지만 실제로는 동일하다는 데서 비롯됩니다.
가상 상속
가상 상속은 다이아몬드 상속의 모호성과 데이터 중복 문제를 해결할 수 있습니다. 가상 상속은 "허리" 부분에서 사용하며, 상속 방식 앞에 virtual 한정자를 추가합니다. 위 그림에서 Student와 Teacher가 Human을 상속할 때 가상 상속을 사용합니다.
class Human
{
public:
string _name; // 이름
int _age;
};
class Graduate : virtual public Human
{
protected:
int _studentID; //학번
};
class Professor : virtual public Human
{
protected:
int _employeeID; // 직원번호
};
class Assistant : public Graduate, public Professor
{
protected:
string _majorCourse; // 주과목
};
사용 후 파생 클래스가 _age를 공유하며 수정할 수 있습니다.
가상 상속을 사용하지 않은 문제
가상 상속의 공간 절약 문제
비가상 객체에서 B와 C는 부모 클래스 멤버 포인터 크기(사본)를 포함하며, 자체적으로 각각 8바이트입니다. D 객체 자체는 4바이트이므로 총 20바이트입니다.
가상 상속에서 B와 C가 모두 A를 가상 상속하므로 A가 한 번만 인스턴스화됩니다. 따라서 메모리 크기는 각 객체의 포인터 크기 + 가상 기본 테이블 포인터 크기입니다. 각 가상 상속 파생 클래스는 가상 기본 테이블 포인터를 포함합니다. 이 포인터들은 각각의 가상 기본 테이블을 가리킵니다. 컴파일러의 최적화를 배제할 수 있으며, 최적화 모드에서는 가상 기본 테이블을 병합하여 메모리 오버헤드를 줄일 수 있지만, 우리는 이를 고려하지 않습니다. 여기서 B와 C는 모두 가상 기본 테이블 포인터를 포함하며, 32비트 시스템: 일반적으로 4바이트; 64비트 시스템: 일반적으로 8바이트입니다. 여기서 32비트 시스템에서는 4*4+4+4 크기로 공간을 차지합니다.
멤버가 차지하는 공간이 클수록 가상 기본 테이블의 공간 절약 효과가 더욱 두드러집니다. 포인터 크기는 고정되어 있으므로 중복 공간을 대체하여 공간을 절약합니다.
가상 상속이 데이터 중복과 모호성을 해결하는 원리
가상 상속의 원리를 연구하기 위해 단순화된 다이아몬드 상속 체계를 제공하고, 메모리 창을 통해 객체 멤버 모델을 관찰합니다.
- 다이아몬드 상속
메모리 주소는 다중 상속 순서에 따라 엄격하게 저장됩니다. A와 B 모두 A 값의 주소를 저장하고 있음을 알 수 있습니다.
- 가상 상속
메모리 주소는 왼쪽이 높고 오른쪽이 낮습니다. 여기서는 리틀 엔디안 저장이며, 입력 시 왼쪽에서 오른쪽으로 입력하며, 메모리의 오른쪽 데이터(리틀 엔디안)부터 입력합니다. 객체에서 A를 객체 구성의 가장 아래에 배치합니다. 이 A는 동시에 B와 C에 속합니다. 그렇다면 B와 C는 공통의 A를 어떻게 찾을까요? 여기서 B와 C의 두 포인터가 테이블을 가리킵니다. 이 두 포인터를 가상 기본 테이블 포인터라고 하며, 이 두 테이블을 가상 기본 테이블이라고 합니다. 가상 기본 테이블에는 오프셋이 저장됩니다. 오프셋을 통해 아래의 A를 찾을 수 있습니다. 가상 상속은 중복 데이터를 별도로 꺼내며, 어디에 배치할지는 컴파일러가 결정하지만, 가상 기본 테이블 포인터가 추가됩니다.
왜 메모리에 가상 기본 테이블 포인터를 저장하고 부모 클래스 멤버의 주소를 직접 저장하지 않는가?
- 예를 들어 기본 클래스와 파생 클래스 객체 할당 변환에서 슬라이싱이 발생하면, 일반적인 시나리오처럼 메모리 주소에서 부모 클래스 멤버 변수를 직접 찾을 수 없으며, 오프셋을 통해 찾아야 합니다.
- 만약 부모 클래스에 많은 멤버가 있다면, 파생 클래스가 주소를 하나씩 저장하는 것은 중복됩니다. 가상 기본 테이블 포인터를 통해 주소를 가리키면 충분합니다.
가상 기본 테이블 포인터
역할:
가상 기본 테이블 포인터는 가상 상속의 경우 기본 클래스 멤버 주소를 동적으로 해석하는 데 사용됩니다. 이는 가상 기본 클래스의 주소가 컴파일 시점에 결정될 수 없기 때문입니다.
필요성:
동적 해석: 가상 상속에서 기본 클래스의 주소는 컴파일 시점에 결정될 수 없습니다. 왜냐하면 기본 클래스가 여러 파생 클래스에 의해 공유될 수 있기 때문입니다. 가상 기본 테이블 포인터는 실행 시점에 기본 클래스 주소를 동적으로 결정할 수 있게 해줍니다. 중복 인스턴스화 방지: 가상 상속은 기본 클래스가 여러 파생 클래스에 상속되더라도 한 번만 인스턴스화되도록 보장합니다. 가상 기본 테이블 포인터는 이러한 공유 관리를 도와줍니다. 다형성 지원: 가상 기본 테이블 포인터는 다형적 동작을 지원하여 기본 클래스 포인터를 통해 파생 클래스 멤버 함수를 호출할 수 있게 합니다.
9. 상속과 조합
다중 상속은 C++의 결함 중 하나로, 많은 후기 객체 지향 언어(Java 등)에서 다중 상속을 지원하지 않습니다.
여기서 백분율은 결합도, 즉 의존 관계의 강도를 나타냅니다. 한쪽이 변경될 때 다른쪽이 영향을 받는지 여부입니다.
상속은 기본 클래스의 구현을 기반으로 파생 클래스의 구현을 정의할 수 있게 해줍니다. 이러한 파생 클래스 생성을 통한 재사용은 일반적으로 화이트박스 재사용(white-box reuse)이라고 불립니다. "화이트박스"라는 용어는 가시성에 상대적입니다: 상속 방식에서 기본 클래스의 내부 세부사항이 자식 클래스에 보입니다. 상속은 기본 클래스의 캡슐화를 일정 부분 파괴하며, 기본 클래스의 변경이 파생 클래스에 큰 영향을 미칩니다. 파생 클래스와 기본 클래스 간의 의존 관계가 강하며 결합도가 높습니다. 객체 조합은 클래스 상속 외에 다른 재사용 선택지입니다. 더 복잡한 기능은 객체를 조립하거나 조합하여 얻을 수 있습니다. 객체 조합은 조합되는 객체가 잘 정의된 인터페이스를 가지도록 요구합니다. 이러한 재사용 스타일은 블랙박스 재사용(black-box reuse)이라고 불립니다. 왜냐하면 객체의 내부 세부사항은 보이지 않기 때문입니다. 객체는 "블랙박스" 형태로 나타납니다. 조합 클래스 간에는 강한 의존 관계가 없으며 결합도가 낮습니다. 객체 조합을 우선적으로 사용하면 각 클래스가 캡슐화되도록 유지할 수 있습니다. 실제로는 가능한 한 조합을 많이 사용하세요. 조합의 결합도가 낮고 코드 유지보수성이 좋습니다. 그러나 상속도 사용할 곳이 있습니다. 어떤 관계는 상속에 적합하므로 상속을 사용해야 하며, 다형성을 구현하려면 반드시 상속이 필요합니다. 클래스 간의 관계는 상속으로 표현할 수도 있고 조합으로 표현할 수도 있습니다. 가능하다면 조합을 사용하세요.
public 상속은 is-a 관계입니다. 즉, 각 파생 클래스 객체는 기본 클래스 객체입니다. 예를 들어 학생은 사람입니다 조합은 has-a 관계입니다. B가 A를 조합했다고 가정하면, 각 B 객체에는 A 객체가 있습니다. 예를 들어 자동차에는 타이어가 있습니다 객체가 두 가지 관계를 모두 가지고 있다면 조합을 우선 사용하세요.