간단한 기초 클래스부터 시작하자
생성자: 접근에 대하여
멤버 초기자 리스트
파생 클래스 생성자는, 기초 클래스 생성자에 값들을 전달하기 위해 멤버 초기자 리스트 문법을 사용할 수 있다.
child::child(int age, int cuteScore) : parent(age) // 초기자 리스트
{
cuteScore = cuteScore;
}
여기서, child는 파생 클래스이고 parent는 기초 클래스이다. age는 기초 클래스 생성자가 사용하는 변수들이다.
기초 클래스 생성자를 제공하지 않는다면, 프로그램은 디폴트 기초 클래스 생성자를 사용한다.
멤버 초기자 리스트는 생성자에만 사용할 수 있다.
파생 클래스와 기초 클래스의 특별한 관계
첫쨰, 파생 클래스 객체는 기초 클래스 메서드들이 private이 아니면 그것들을 사용할 수 있다.
둘째, 기초 클래스 포인터는 명시적 데이터형 변환 없이도 파생 클래스 객체를 지시할 수 있다. (업스케일링)
상속 is-a 관계
기초 클래스와 파생 클래스의 관계를 is-a-kind-of로 표시하는 것이 정확하지만, 흔히 is-a 관계라는 말을 사용한다.
예를 들어 과일 is-a 바나나는 가능하기 때문에 상속이 되지만, '변호사가 상어과 같다'라고 해서 변호사 is-a 상어 는 될 수없으니 이는 상속으로 정의하면 안된다.
또한, has-a 관계도 부적절하다. 부모가 가지고 있어도 자식이 가지고 있지 않을 수 있기 때문이다. (ex. 주민등록증)
public 다형 상속
상속 받은 클래스는 처한 상황에 따라 메서드가 여러 가지 다른 행동을 할 수 있기 때문에, 여러 가지 형식을 가지고 있다는 의미에서 그러한 복잡한 행동을 다형(polymorhphic)이라 부른다. 그러한 public 다형 상속을 구현하는 두 가지 중요한 방법이 있다.
- 기초 클래스 메서드를 파생 클래스에서 다시 정의한다.
- 가상 메서드를 사용한다.
가상 메소드의 virtual 키워드는, 메서드 정의가 아니라, 클래스 선언에 있는 메서드 원형에만 사용된다는 사실을 주목하라.
◆가상 파괴자는 왜 필요할까?
파괴자들이 가상이 아니라면, 포인터형에 해당하는 파괴자만 호출될 것이다. 즉, 이것은 다형을 위반한다. 따라서 가상 파괴자를 선언함으로 파생 클래스 파괴자 다음 기초 클래스 파괴자를 실행시키도록 구현해주어야 한다.
정적 결합과 동적 결합
정적 결합은 컴파일하는 동안에 컴파일러가 수행할 수 있다. 이를 정적 결합(static binding) 또는 초기 결합(early binding)이라 한다. 그러나, 가상 함수들은 필요에 의해 이 작업을 좀 더 어렵게 만드는데, 이 작업을 동적 결합(dynamic binding) 또는 말기 결합(lately binding)이라 한다.
★포인터와 참조형의 호환
C++에서 동적 결합은 포인터와 참조에 의해 호출되는 메서드와 관련되어 있다. 그리고 이것은 부분적으로 상속 과정에 의해 지배를 받는다.
C++는 한 데이터 형의 주소를 다른 데이터형의 포인터에 대입하는 것을 허용하지 않는다. 또한 한 데이터형에 대한 참조가 다른 데이터형을 참조하는 것도 허용하지 않는다.
double x = 1.0;
int * ptr_i = &x; // 포인터형이 불일치하므로 대입이 허용되지 않는다.
long & ref_i = x; // 참조형이 불일치하므로 대입이 허용되지 않는다.
그러나, 기초 클래스를 지시하는 포인터나 참조는 명시적인 강제 데이터형 변환 없이도 파생 클래스 객체를 참조할 수 있다. 파생 클래스 참조나 포인터를 기초 클래스 참조나 포인터로 변환하는 것을 업 캐스팅(upcastng)이라 한다. public 상속에서는 명시적인 데이터형 변환이 없어도 업캐스팅이 언제든지 허용된다. 즉, 기초 클래스 포인터는 기초, 파생, 파생의 파생 클래스 객체들을 참조할 수 있다.
그 반대 과정을 다운캐스팅(downcasting)이라 한다. 다운캐스팅은 명시적인 데이터형 변환 없이는 허용되지 않는다.
◆업캐스팅의 예시
void fr(Brass & rb); // rb.ViewAcct(); , pass by reference
void fp(Brass * rb); // pb->ViewAcct(); , pass by reference
void fv(Brass b); // b.ViewAcct(); , pass by value
int main(){
Brass b("choi", 12, 10.0);
BrassPlus bp("choiPlus", 99, 99.0);
fr(b); // Brass::ViewAcct()를 사용한다.
fr(bp); // BrassPlus::ViewAcct()를 사용한다.
fp(b); // Brass::ViewAcct()를 사용한다.
fp(bp); // BrassPlus::ViewAcct()를 사용한다.
fv(b); // Brass::ViewAcct()를 사용한다.
fv(bp); // Brass::ViewAcct()를 사용한다.
}
- 값 전달 방식은 암시적 업캐스팅을 하지 못하였다.
암시적 업캐스팅은 기초 클래스 포인터나 참조가 기초 클래스 객체나 파생 클래스 객체를 참조하는 것을 가능하게 만든다. 이는 동적 결합(dynamic binding)이 필요한 이유이며, 가상 멤버 함수는 이러한 필요성에 대한 C++의 해답으로 생겨났다.
가상 멤버 함수와 동적 결합
기초 클래스의 메소드를 가상으로 선언하지 않으면, 포인터의 클래스의 기초하여 호출한다. (정적 결합을 사용한다.)
그러나 메소드를 가상 함수로 선언하면, 포인터가 가르키는 객체에 기초하여 메소드를 호출한다. 일반적으로 그 객체형은 프로그램이 실행될 때 결정될 수 있다. 컴파일러는 가상함수들에 대해서 동적 결합을 사용한다.
// TODO why
- 왜 두 종류의 결합이 필요한가?
- 동적 결합이 좋으면서, 왜 그것이 디폴트가 아닌가?
- 동적 결합은 어떻게 동작하는가?
◆가상 함수 테이블(virtual function table:vtbl)
컴파일러가 가상 함수를 다루는 일반적인 방법은, 각각의 객체에 숨겨진 멤버를 하나씩 추가하는 것이다. 숨겨진 멤버는 함수의 주소들로 이루어진 배열을 지시하는 포인터를 저장한다. 일반적으로 그 배열을 가상 함수 테이블(virtual function table:vtbl)이라고 한다.
vtbl에는 그 클래스의 객체들을 위해 선언된 가상 함수들의 주소가 저장되어 있다.
파생 클래스가 가상 함수를 다시 정의하지 않으면, vtbl은 그 함수의 오리지널 버전의 주소를 저장한다. 새로운 가상 함수를 정의하면, 그 주소가 vtbl에 추가된다.
사용하는 함수가 클래스 선언에 정의된 첫 번째 가상 함수라면, 프로그램은 그 배열에 있는 첫 번째 주소를 사용하고, 그 주소에 있는 함수를 실행시킨다.
다시 말해서, 가상 함수를 사용하면 메모리와 실행 속도 면에서 다음과 같은 약간의 부담이 따른다.
- 각 객체의 크기가 주소 하나를 저장하는 데 필요한 양만큼 커진다.
- 각각의 클래스에 대해, 컴파일러는 가상 함수들의 주소로 이루어진 하나의 테이블(배열)을 만든다.
- 각각의 함수 호출에 대해, 실행할 함수의 주소를 얻기 위해 테이블에 접근하는 가외의 단계가 더 필요하다.
가상이 아닌 함수들은 가상 함수보다 조금 더 효율적이지만, 동적 결합을 제공하지 않는다.
가상 메서드에 대해 알아야 할 사항
- 기초 클래스에서 클래스 메서드를 선언할 때 키워드 virtual로 시작하면 그함수는 기초 클래스, 기초 클래스에서 파생된 클래스, 파생 클래스에서 다시 파생된 클래스 등 모든 클래스에 대해 가상이 된다.
- 객체에 대한 참조를 사용하여 또는 객체를 지시하는 포인터를 사용하여 가상 메서드가 호출되면, 프로그램은 그 참조나 포인터형을 위해 정의된 메서드를 사용하지 않고, 객체형을 위해 정의된 메서드를 사용한다. 이것을 동적 결합이라 한다. 기초 클래스 포인터나 탐조가 파생 클래스 객체를 지시하는 것은 항상 가능하기 때눔에 이와 같은 행동은 중요하다.
- 상속을 위해 기초 클래스로 사용할 클래스를 정의할 때, 파생 클래스에서 다시 정의해야 하는 클래스 메서드들은 가상 함수로 선언해야 한다.
생성자
생성자는 상속하지 않으므로 가상으로 만들 이유가 없다.
파괴자
일반적으로, 파괴자가 필요 없는 기초 클래스라 하더라도 가상 파괴자를 제공해야 한다.
프렌드
멤버 함수만 가상 함수가 될 수 있기 때문에, 멤버 함수가 아닌 프렌드는 가상 함수가 될 수 없다. 이것 때문에 문제가 있다면 그 프렌드 함수가 내부적으로 가상 멤버 함수를 사용하게 되어 문제를 해결할 수 있다.
가상 함수를 다시 정의하지 않으면
그 함수의 가장 최근에 정의된 버전을 사용한다. 다만, 기초 클래스 버전이 은닉되어 있는 경우에는 예외이다.
가상 함수를 다시 정의하면 은닉된다.
어떤 함수를 파생 클래스에서 다시 정의하면, 동일한 함수 시그내쳐를 가지고 있는 기초 클래스 선언만 가리는 것이 아니라, 매개변수 시그내처와는 상관 없이 같은 이름을 가진 모든 기초 클래스 메서드들을 가린다.
이와 같은 사실 때문에 가상 함수에 두 가지 규칙이 성립된다.
첫쨰, 상속된 메서드를 재정의할 경우에는 오리지널 원형과 정확히 일치시킬 필요가 있다.
이 규칙에 비교적 새로운 예외가 하나 있다면, 리턴형이 기초 클래스에 대한 참조나 포인터인 경우에 파생 클래스에 대한 참조나 포인터로 대체될 수 있다는 것이다. 리턴형이 클래스형과 병행하여 바뀌는 것을 허용하기 때문에, 이 기능을 리턴형의 공변(covariance)이라 한다.
또한, 이 예외는 매개변수가 아니라 리턴값에만 적용된다.
둘째, 기초 클래스 선언이 오버로딩되어 있다면, 파생 클래스에서 모든 기초 클래스 버전들을 재정의해야 한다.
한 가지 버전암 재정의하면, 나머지 두 버전은 가려져 파생 클래스 객체가 그들을 사용할 수 없게된다. 변경이 필요 없다면, 이와 같은 재정의는 단순히 기초 클래스 버전을 호출할 것이다.
void Hovel::showperks() const {Dwelling::showperks();}
접근제어 : protected
protected로 정의된 기초 클래스를 파생 클래스에서 접근할 수 있다. 파생 클래스가 아닌 경우는 안된다.
가능하다면 protected 접근 제어보다 private 접근 제어를 사용하여 클래스 데이터 멤버들에 접근해야 한다. 그리고 파생 클래스가 기초 클래스 데이터에 접근하게 하려면 기초 클래스 메서드를 사용해야 한다.
추상화 기초 클래스
ABC 개념의 적용
원이 타원에 속한다 할지라도, is-a 관계는 어색하다. 이럴때 공통되는 속성만 을 Abstract Class를 통해 해결한다.
ABC에 메서드를 정의 할때, ABC는 필요한 데이터 멤버를 가지고 있지 않기 때문에, 메서드를 ABC에 구현할 수 없을 수 있다. C++는 구현되지 않는 이러한 함수를 위해 순수 가상 함수(pure virtual function)라는 것을 사용한다. 다음과 같은 추상화 기초 클래스에서 Area() 메서드와 같이, 순수 가상 함수는 함수 선언 뒤에 = 0 을 가진다.
class BaseEllipse // 추상화 기초 클래스
{
private:
...
public:
BaseEllipe(){}
virtual ~BaseEllipse(){}
virtual double Area() const = 0 ; // 순수 가상 함수
...
}
클래스 선언에 순수 가상 함수가 들어 있으면, 그 클래스의 객체를 생성할 수 없다. 그 이유는 순수 가상 함수를 가지고 있는 클래스는 기초 클래스의 역할을 하기 위해서만 존재하기 때문이다.
C++는 순수 가상 함수가 정의를 가지는 것을 허용한다.
요약하자면, 원형에 =0 가 있으면, 그 클래스가 추상화 기초 클래스이며, 그 클래스가 함수를 반드시 정의할 필요는 없다는 것을 나타낸다.
추상화 기초 클래스는 객체를 가질 수 없지만, 포인터를 통해 파생 클래스를 가르킬 수 있다.
요약하자면, ABC는 적어도 하나의 순수 가상 함수를 사용하는 인터페이스를 서술한다. ABC로부터 파생된 클래스들은 일반 가상 함수를 사용하여, 그 특별한 파생 클래스의 속성을 가지고 그 인터페이스를 구현한다.
상속과 동적 메모리 대입
경우 1 - 파생 클래스가 new를 사용하지 않는다.
명시적 파괴자, 복사 생성자, 대입 연산자를 정의하지 않아도 된다.
복사 생성자의 경우 멤버별 복사를 한다. 멤버별 복사는 동적 메모리 대입에는 사용할 수 없다. 우리가 알아야 할 것은, 멤버별 복사는 데이터형에 맞게 정의된 복사 형식을 사용한다는 것이다. 클래스 멤버 또는 상속받은 클래스 성분을 복사하는 것은, 그 클래스의 복사 생성자를 통하여 이루어 진다.
대입에 대해서도 같은 논리가 적용된다. 어떤 클래스의 디폴트 대입 연산자는, 기초 클래스 성분의 대입을 위해 기초 클래스의 대입 연산자를 자동적으로 사용한다. 그럴므로 이 경우에 디폴트 대입 연산자만 있으면 충분하다.
경우 2 - 파생 클래스가 new를 상요한다.
명시적 파괴자, 복사 생성자, 대입 연산자를 정의해야한다.
다음과 같이 기초 클래스 대입 연산자를 명시적으로 호출함으로써 이것을 처리할 수 있다.
hasDMA & hasDMA::operator=(const hasDMA & hs)
{
if(this == &hs)
return;
baseDMA::operator=(hs); // 기초 클래스 부분을 복사한다.
delete [] style; // 새로운 스타일을 준비하라
style = new char[std::strlen(hs.style)+1];
std::strcpy(style, hs.style);
return *this;
}
여기서 다음과 같은 구문을 낯설어 보일 수 있다.
baseDMA::operator=(hs);
// 는 아래와 같은 효과를 가져온다.
*this = hs;
동적 메모리 대입과 프렌드를 사용하는 상속 예제
아래 함수는 hasDMA 클래스의 프렌드이기 때문에, style 멤버에 접근할 있다.
friend std::ostream & operator<<(std::ostream & os, const hasDMA & hs);
그러나 문제가 있다. 이 함수는 baseDMA 클래스에 대해서는 프렌드가 아니다. 이 문제에 대한 해결책은 baseDMA 클래스에 대해 프렌드인 operator<<() 함수를 사용하는 것이다. 그러나 또 하나의 문제는, 프렌드는 멤버 함수가 아니기 때문에, 어느 함수를 사용할 것인지를 나타내기 위해 사용 범위 결정 연산자를 사용할 수 없다는 것이다. 이 문제에 대한 해결책은, 강제 데이터형 변환을 사용하여 원형 일치 를 통해 올바른 함수를 선택할 수 있도록 하는 것이다. 그래서, 다음과 같은 코드는 const hasDMA & 매개변수를 const baseDMA & 매개변수로 데이터형을 강제로 변환하고 있다.
std::ostream & operator<<(std::ostream & os, const hasDMA & hs)
{
// operator<<(ostream &, const baseDMA &)를 위해 강제 데이터형 변환
os << (const baseDMA &) hs;
os << "스타일 : " << hs.style << std::endl;
return os;
}
클래스 설계 복습
컴파일러가 자동으로 생성하는 멤버 함수들
디폴트 생성자
디폴트 생성자는 매개변수를 전혀 사용하지 않거나, 모든 매개변수가 디폴트 매개변수를 사용하는 생성자이다. 사용자가 생성자를 하나도 정의하지 않으면, 사용자를 대신하여 컴파일러가 디폴트 생성자를 정의한다. 디플트 생성자의 존재는 사용자가 객체를 생성하는 것을 허용한다.
자동 디폴트 생성자가 수행하는 또 한 가지 일은, 모든 기초 클래스와 다른 클래스의 객체인 모든 멤버에 대한 디폴트 생성자를 호출하는 것이다.
또한, 멤버 초기자 리스트를 사용하여 기초 클래스 생성자를 명시적으로 호출하지 않고 파생 클래스 생성자를 작성하면, 컴파일러는 새 객체의 기초 클래스 부분을 생성하기 위하여 기초 클래스 디폴트 생성자를 사용한다.
어떤 종류이든 간에 사용자가 생성자를 정의하면, 컴파일러는 디폴트 생성자를 정의하지 않는다.
복사 생성자
어떤 클래스를 위한 복사 생성자는, 그 클래스 형의 객체를 매개변수로 사용하는 생성자이다. 일반적으로, 그와 같이 선언된 매개변수는 그 클래스형에 대한 const 참조이다.
복사 생성자는 다음과 같은 상황에서 사용된다.
- 새 객체를 동일한 클래스의 다른 객체로 초기화할 때
- 객체가 함수에 값으로 전달될 때
- 함수가 객체를 값으로 리턴할 때
- 컴파일러가 임시 객체를 생성할 때
프로그램이 복사 생성자를 사용하지 않으면, 컴파일러는 함수 정의가 아니라 원형만 제공한다. 그렇지 않다면, 르로그램은 멤버별 초기화를 수행하는 복사 생성자를 정의한다. 즉, 새 객체의 각 멤버들은 오리지널 객체의 해장하는 멤버의 값으로 각각 초기화 된다. 만약 어떤 멤버가 그 자체로 클래스 객체일 경우, 멤버간 초기화는 해당 클래스에 정의된 복사 생성자를 사용한다.
어떤 경우에는 멤버별 초기화가 좋지 않을 수도 있다. 예를 들어, new에 의해 초기화되는 멤버 포인터들은, 일반덕으로 깊은 복사를 수행할 것을 요구한다. 앒에서 살펴본 baseDMA 클래스의 예제가 바로 그러한 경우이다. 또는 변경이 이루어어져야 하는 static 변수를 사용할 때가 있다. 그러한 경우에 사용자가 복사 생성자를 직접 정의할 필요가 있다.
대입 연산자
디폴트 대입 연산자는 어떤 객체를 동일한 클래스의 다른 객체에 대입하는 것을 처리한다, 대입과 초기화를 혼동하면 안 된다. 구문이 새 객체를 생성한다면, 그럿은 초기화를 사용하는 것이다. 구문이 기존 객체의 값을 변경한다면, 그것은 대입이다.
디폴트 대입은 멤버간 대입을 사용한다. 어떤 멤버가 그 자체로 클래스 객체일 경ㅇ, 디폴트 멤버간 대입은 해당 클래스에 정의된 대입 연산자를 사용한다.
복사 생성자를 명시적으로 정의할 필요가 있다면, 같은 이유 때문에 대입 연산자도 명시적으로 정의할 필요가 있다.
컴파일러는 하나의 데이터형을 다른 데이터형에 대입하는 대입 연산자는 생성하지 않는다. 문자열을 Star 객체에 대입할 수 있게 되기를 원한다고 가정하자. 첫 번째 접근 방식은 그러한 연산자를 명시적으로 정의하는 것이다.
두 번째 접근 방식은, 문자열을 Star 객체로 변환하는 변환 함수와, Star를 Strar에 대입하는 대입연산자 함수를 사용하는 것이다. 첫 번째 접근 방식이 좀 더 빠르게 실행되지만 더 많은 코드르 요구한다. 변환 함수를 사용하는 두 번째 접근 방식은, 컴파일러가 혼동을 일으키는 상황을 가져올 수 있다.
클래스 메서드에 관련된 그 밖의 고려 사항
생성자 관련 사항
생성자들은 새로운 객체를 생성한다는 점에서 클래스의 다른 메서드들과 구별된다. 생성자가 아닌 다른 메서드들은 기존 객체에 의해서 호출된다. 이것이 생성자들이 상속되지 않는 한 가지 이유이다. 상속은, 파생 클래스 객체가 기초 클래스 메서드를 사용할 수 있다는 것을 의미한다. 그러나, 생성자의 경우에, 그 객체는 생성자가 자신의 일을 끝낸 후까지 존재하지 않는다.
파괴자 관련 사항
클래스 생성자에서 new에 의해 대입되는 멤모리를 해제하고, 클래스 객체의 파괴에 따르는 특별한 정리 작업을 처리하기 위해, 명시적 파괴자를 반드시 정의해야 한다는 것을 기억할 필요가 있다. 그 클래스를 기초 클래스로 사용할 예정이라면, 그 클래스가 파괴자를 요구하지 않을지라도 가상 파괴자를 제공해야 한다.
변환 관련 사항
정확히 하나의 매개변수를 사용하여 호출할 수 있는 생성자는, 그 매배변수와 데이터형을 클래스형으로 변환하는 것을 정의한다.
하나의 매개변수를 사용하는 생성자의 원형에 explicit 를 사용하면, 암시적 변환을 사용할 수 없게 된다. 그러나 명시적 변환은 계속 허용된다.
클래스 객체를 다른 데이터형으로 변환하려면, 변환 함수를 정의해야 한다. 변환 함수는 매개변수를 사용하지 않고, 변환 결과 데이터형의 이름을 리턴형으로 선언하지 않는 클래스 멤버 함수이다. 리턴형을 선언하지 않는데도 불구하고, 그 함수는 원하는 변환값을 리턴해야 한다. 변환 함수는 이치에 맞을 경우에만 신중하게 사용하는 것이 현명하다. 또한 어떤 클래스 설계에서는 변환 함수를 사용하면 모호한 코드를 작성할 가능성이 높아진다.
C++는 변환 함수와 함께 explicit 키워드를 사용하는 옵션을 제공한다. 생성자에 explicit 키워드가 사용된 경우, 형 변환을 통한 명시적 변환은 허용하지만 암시적 변환은 허용하지 않는다.
값으로 전달과 참조로 전달
일반적으로, 객체를 매개변수로 사용하는 함수를 작성하려면, 그 객체를 값으로 전달하지 않고 참조로 전달해야 한다. 이렇게 하는 이유 중의 하나는 효율성 떄문이다. 객체를 값으로 전달한면 임시 복사본이 생성된다. 이것은 복사 생성자를 호출하고, 나중에 파괴자를 호출한다는 것을 의미한다. 이러한 함수들의 호출이 시간을 잡아먹으며, 큰 객체의 복사는 참조로 전달할 때보다 속도가 많이 느려질 수 있다. 그러므로 함수가 객체를 변경하지 않는다면, 그 함수의 매개변수를 const 참조로 선언해야 한다.
객체를 참조로 전달하는 또 하나의 이유가 있다. 가상 함수를 사용하는 상속의 경우에, 기초 클래스 참조 매개변수를 받아들이도록 정의된 함수는, 이 장의 앞부분에서 살펴보았듯이, 파생 클래스에 대해서도 성공적으로 사용할 수 있다.
객체 리턴과 참조 리턴
어떤 클래스 메서드들은 객체를 리턴한다. 이들 중에 일부 멤버는 객체를 직접 리턴하고, 다른 것들은 참조를 리턴한다. 어떤 경우에 메서드는 반드시 객체를 리턴해야한다. 그러나 꼭 그래야 할 필요가 없는 경우에는, 참조를 리턴하는 것이 더 좋다.
참조를 리턴하면 시간과 메모리가 절약된다.
함수는 그 함수 안에서 생성된 임시 객체에 대한 참조를 리턴하면 안 된다.
const의 사용
const를 사용할 기회가 있을 때 신중해질 필요가 있다. 메서드가 매개변수를 변경하지 않는다는 확신이 있을 때 그것을 사용할 수 있다.
메서드가 그것을 호출한 객체를 변경하지 않는다는 확신이 있을 때 const를 사용할 수 있다.
함수의 매개변수를 const에 대한 참조나 const를 지시하느 포인터로 선언하면, 매개변수를 변경하지 않는다는 보장이 없는 경우에 그 매개변수를 다른 함수에 전달할 수 없다.
public 상속에 관련된 그 밖의 고려 사항
is-a 관계
명시적 데이터형 변환 없이 기초 클래스 포인터가 파생 클래스 객체를 지시할 수 있고, 기초 클래스 참조가 파생 클래스 객체를 참조할 수 있다는 것이 is-a 관계의 한 특성이다. 역은 성립하지 않는다. 그래서 명시적인 데이터형 변환 없이는 파생 클래스 포인터나 참조가 기초 킆래스 객체를 참조할 수 없다.
상속되지 않는것
생성자는 상속되지 않는다. 즉, 파생 클래스 객체를 생성하는 것은, 파생 클래스 생성자의 호출을 요구한다. 그러나 일반적으로 파생 클래스 생성자는, 파생 클래스 객체의 기초 클래스 부분을 생성하기 위해, 멤버 초기자 리스트 문법을 사용하여 기초 클래스 생성자를 호출한다.
파생 클래스 생성자가 멤버 초기자 리스트 문법을 사용하여 기초 클래스 생성자를 명시적으로 호출하지 않는다면, 기초 클래스의 디폴트 생성자를 사용한다. 상속의 연쇄 사슬에서, 각 클래스는 인접한 직계 상위 기초 클래스에 정보를 다시 전달하기 위해, 멤버 초기자 리스트를 사용할 수 있다. C++은 생성자의 상속을 가능하게 하는 매커니즘에 대하여 추가적으로 언급하고 있다. 그러나 디폴트 행위는 여전히 생성자가 상속되지 않는 것이다.
파괴자는 상속되지 않는다. 그러나 어떤 객체가 파괴될 때, 프로그램은 먼저 파생 파괴자를 호출하고 나서 기초 파괴자를 호출한다. 디폴트 기초 클래스 파괴자가 있다면, 컴파일러는 디폴트 파생 클래스 파괴자를 생성한다. 일반적으로, 어떤 클래스가 기초 클래스의 역할을 한다면, 그 파괴자는 가상이어야 한다.
대입 연산자는 상속되지 않는다. 기 이유는 간단하다. 상속받은 메서드는 기초 클래스와 파생 클래스에서 동일한 함수 시그내처를 사용한다. 그러나 대입 연산자는, 그 클래스 형의 형식 매개변수를 가직 때문에, 각 클래스 마다 드른 함수 시그태처를 가진다. 대입 연산자는 다음에 살펴볼 몇 가지 흥미로운 특성을 가지고 있다.
대입 연산자 관련 사항
한 객체를 동일한 클래스의 다른 객체에 대입한다는 것을 컴파일러가 발견하면, 컴파일러는 그 클래스에 대해 자동으로 대입 연산자를 제공한다. 대입 연산자의 암시적 디폴트 버전은 멤버별 대입을 사용한다. 멤버별 대입은 타깃 객체의 각 멤버에 소스 객체의 해당하는 멤버의 값이 대입된다. 객체가 파생 클래스에 속한다면, 컴파일러는 기초 클래스 대입 연산자를 사용하여 파생 클래스 객체의 기초 클래스 부분에 대한 대입을 처리한다.
기초 클래스를 위한 대입 연산자를 면시적으로 제공하지 않으면, 그 연산자가 사용된다. 같은 논리로, 어떤 클래스가 다른 클래스의 객체를 멤버로 가지고 있을 때, 그 멤버의 대입을 위해서 그 멤버 클래스의 대입 연산자가 사용된다.
C++는 파생 클래스 객체의 기초 부분을 위해 기초 클래스 대입 연산자를 사용하기 때문에, 특별한 주의를 요하는 데이터 멤버를 추가하지 않는 한, 파생 클래스를 위해 대입 연산자를 다시 정의할 필요는 없다.
파생 클래스가 new를 사용하고 있고, 명시적 대입 연산자를 제공해야 한다고 가정하자. 그 대입 연산자는 새로운 멤버만 제공하는 것이 아니라 그 클래스의 모든 멤버들을 제공해야 한다.
"기초 클래스 객체를 파생 클래스 객체에 대입할 수 있는가?"에 대한 대답은, "주어지는 조건에 따라 대입할 수도 있고 못할 수도 있다"이다. 기초 클래스 객체를 파생 클래스 객체로 변환하는 것을 정의해 놓은 생정자를 파생 클래스가 가지고 있다면, 기초 클래스 객체를 파생 클래스 객체에 대입할 수 있다. 그리고 기초 클래스 객체를 파생 클래스 객에체 대입하는 대입 연산자를 파생 클래스가 저으이하고 있다면, 기초 클래스 객체를 파생 클래스 객체에 대입할 수 있다. 이 두 가지 조건 중에 어느 하나도 만족히키지 못한다면, 명시적 데이터형 캐스트를 사용하지 않는 한, 기초 클래스 객체를 파생 클래스 객체에 대입 할 수 없다.
private 멤버와 protected 멤버
protected 멤버는 파생 클래스에 대해 public 멤버와 같은 역할을 하지만, 바깥 세상에 대해서는 private 멤버처럼 행동한다. 파생 클래스는 기초 클래스의 protected 멤버에 직접 접근할 수 있다.
가상 메서드
기초 클래스를 설계할 때, 클래스 메서드들을 가상으로 나들 것인지 결정해야 한다. 파생 클래스에서 메서드를 다시 정의하려면, 기초 클래스에서 그 메서드를 가상으로 정의하라, 이것은 말기 결합(동적 결합)을 가능하게 한다. 파생 클래스에서 메서드를 다시 정의하지 않는다면, 그것을 가상으로 만들지 마라. 이것은 다른 사람이 그 메서드를 다시 정의하는 것을 막지는 않느다. 프로그래머 자신이 다시 정의하는 것을 원하지 않는다는 뜻으로 해석해야 한다.
부적절한 코드는 동적 결합을 불가능하게 만들 수 있으므로 주의한다.
파괴자 관련 사항
기초 클래스 파괴자는 가상이어야 한다. 그러한 경우에, 객체에 대한 기초 클래스 포인터나 참조를 통해 파생 클래스 객체를 파괴할 때, 르로그램은 기초 클래스 파괴자만 사용하는 것이 아니라, 파생 클래스 파괴자를 먼저 사용하고, 뒤이어 기초 클래스 파괴자를 사용한다.
프렌드 관련 사항
프렌드 함수는 클래스 멤버가 아니기 때문에 상속되지 않는다. 그러나, 파생 클래스의 프렌드가 기초 클래스의 프렌드를 사용하도록 할 수도 있다.
이것을 가능하게 하는 방법은, 파생 클래스 참조나 포린터를 기초 클래스에서 그에 대응하는 것으로 데이터형 캐스트를 실시하고, 그 데이터형 캐스트 참조나 포인터를 사용하여 기초 클래스 프렌드를 호출하는 것이다.
또한 데이터형 캐스트를 위해 논의하는 dynamic_cast<> 연산자를 사용할 수 있다.
기초 클래스 메서드의 사용
Publlic 파생 클래스 객체는 기초 클래스 메서드를 여러 방법으로 사용할 수 있다.
- 파생 클래스 객체는, 그 파생 클래스가 메서드들을 다시 정의하지 않는다면, 상속된 기초 클래스 메서드들을 자동으로 사용한다.
- 파생 클래스 파괴자는 기초 클래스 생성자를 자동으로 호출한다.
- 파생 클래스 생성자는, 멤버 초기자 리스트에서 다른 생성자를 지정하지 않는다면 기초 클래스 디폴트 생성자를 자동으로 호출한다.
- 파생 클래스 생성자는 멤버 초기자 리스트에 지정된 기초 클래스 생성자를 명시적으로 호출한다.
- 파생 클래스 메서드들은 public과 protected 기초 클래스 메서드들을 호출하기 위해 사용 범위 결정 연산자를 사용할 수 있다.
- 파생 클래스의 프렌드는, 파생 클래스 참조나 포인터를 기초 클래스 참조나 포인터로 데이터형 캐스트를 실시하고, 그렇게 변환된 참조나 포인터를 사용하여 기초 클래스의 프렌드를 호출할 수 있다.
클래스 함수 요약
C++의 클래스 함수는 종류가 다양하다. 그중 어떤 것은 상속 할 수 있고, 어떤 것은 상속할 수 없다. 어떤 연산자 함수는 멤버 함수가 되거나 프렌드가 될 수 있지만, 어떤 연산자 한수는 멤버 함수만 될 수 있다.
op=라는 표기는 *=, +=와 같은 대입 연산자들을 나타낸다. op= 연산자들의 특성은 '기타 연산자'의 특성과 다르지 않다 그런데도 op=를 따로 나열한 이유는, 이 연한자들이 = 연산자처럼 동작하지 않는 다슨 사실을 나타내기 위해서이다.
멤버 함수의 특성
함수 | 상속 | 멤버 또는 프렌드 | 디폴트로 생성 | 가상으로 선언 | 리턴형 |
생성자 | X | 멤버 | O | X | X |
파괴자 | X | 멤버 | O | O | X |
= | X | 멤버 | O | O | O |
& | O | 둘 중 하나 | O | O | O |
변환 | O | 멤버 | X | O | X |
{} | O | 멤버 | X | O | O |
[] | O | 멤버 | X | O | O |
-> | O | 멤버 | X | O | O |
op= | O | 둘 중 하나 | X | O | O |
new | O | static 멤버 | X | X | void * |
delete | O | static 멤버 | X | X | void |
기타 연산자 | O | 둘 중 하나 | X | O | O |
기타 멤버 | O | 멤버 | X | O | O |
프렌드 | X | 프렌드 | X | X | O |
'C++ > C++' 카테고리의 다른 글
14장 C++ 코드의 재활용 (미완성) (1) | 2023.10.04 |
---|---|
16. String 클래스와 표준 템플릿 라이브러리 (미완, 일반화 프로그래밍) (1) | 2023.09.18 |
[C++] Rule of three (0) | 2023.08.27 |
Overloading (0) | 2023.07.28 |
12. 클래스와 동적 메모리 대입, +추가 필요 (0) | 2023.07.25 |