[C++ Primer Plus] Chapter 14. C++ 코드의 재활용 (1) 컨테인먼트와 private, protected 상속
Table of Contents
C++ 기초 플러스 책을 읽고 공부한 노트입니다.
코드의 재활용성을 높이는 방법 #
public
상속 (이전 chapter에서 본 것)- 컨테인먼트(containment) = 컴포지션(composition) = 레이어링(layering)
private
상속protected
상속- 클래스 템플릿
컨테인먼트 - 객체 멤버를 가지는 클래스 #
valarray
클래스- 수치 값들을 다루는 것이 목표인 클래스이다.
- 배열에 들어 있는 합, 최대값, 최소값을 구하는 것과 같은 동작들을 지원한다.
valarray<int> v1; // int형의 배열
valarray<int> v2(10); // 10개짜리 배열
valarray<int> v3(3, 10); // 3이 10개인 배열
int arr[15] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15 };
valarray<int> v4(arr, 10); // arr의 첫 10개의 원소로 초기화된 배열
cout << v4.sum() << endl; // 55
cout << v4.min() << endl; // 1
cout << v4.max() << endl; // 10
- 학생 클래스 만들기
- 이름과 성적표를 가지고 있는 클래스이다.
- 이것은 has-a 관계이다.
- has-a 관계를 모델링하는 일반적인 방법은 컨테인먼트를 사용하는 것이다.
- 즉, 다른 클래스의 객체들을 멤버로 가지는 클래스를 만드는 것이다.
-
is-a 관계
public
상속에서 처럼, 인터페이스와 구현 모두 상속한다.
-
has-a 관계
- 인터페이스 없이 구현을 획득한다.
Student
객체는string
과valarray
객체를 가지고 있다. (has-a 관계)string
과valarray
객체의 구현을 획득해서 사용할 수는 있지만,- 인터페이스는 상속하지 않는다. 예를 들어,
string operator==()
함수를 사용하는 인터페이스는 갖지 않는다.
class Student
{
private:
string name; // string 객체를 가진다.
valarray<double> scores; // valarray 객체를 가진다.
};
- 학생 클래스 예제
// Student.h
class Studentc
{
private:
typedef valarray<double> ArrayDb; // typedef를 사용해서 표기를 단순화하였다.
string name;
ArrayDb scores;
ostream& PrintScores(ostream& os) const;
public:
Studentc() : name("Null Studentc"), scores() {}
explicit Studentc(const string& s) : name(s), scores() {}
explicit Studentc(int n) : name("Nully"), scores(n) {}
Studentc(const string& s, int n) : name(s), scores(n) {}
Studentc(const string& s, const ArrayDb& a) : name(s), scores(a) {}
Studentc(const char* str, const double* pd, int n) : name(str), scores(pd, n) {}
~Studentc() {}
double Average() const;
const string& Name() const;
double& operator[](int i);
double operator[](int i) const;
friend istream& operator>>(istream& is, Studentc& stu);
friend istream& getline(istream& is, Studentc& stu);
friend ostream& operator<<(ostream& os, const Studentc& stu);
};
// Student.cpp
// scores 출력을 위한 private 멤버 함수
ostream& Studentc::PrintScores(ostream& os) const
{
int i;
int lim = scores.size();
if (lim > 0)
{
for (i = 0; i < lim; i++)
{
os << scores[i] << " ";
if (i % 5 == 4) os << endl; // 5개 출력하고 개행하기 위함
}
if (i % 5 != 0) os << endl; // 혹시 개행이 안 되었을 때 개행하기 위함
}
else os << "빈 배열";
return os;
}
double Studentc::Average() const
{
if (scores.size() > 0) return scores.sum() / scores.size();
else return 0;
}
const string& Studentc::Name() const
{
return name;
}
double& Studentc::operator[](int i)
{
return scores[i];
}
double Studentc::operator[](int i) const
{
return scores[i];
}
istream& operator>>(istream& is, Studentc& stu)
{
is >> stu.name;
return is;
}
istream& getline(istream& is, Studentc& stu)
{
getline(is, stu.name);
return is;
}
ostream& operator<<(ostream& os, const Studentc& stu)
{
os << stu.name << " 학생의 성적표: " << endl;
stu.PrintScores(os);
return os;
}
explicit
사용 이유- 암시적으로 변환하는 것을 막아서 실수를 예방한다.
Student homer("Homer", 10);
homer = 5; // homer[0] = 5; 로 점수를 바꾸려고 했는데 실수로 오타가 났다.
// explicit이 없다면 맞는 문장이다.
// Student(int n) 생성자가 불려서 이름이 Nully가 된다.
- 멤버 초기자 리스트 문법을 사용해서 내포된 객체를 초기화 했다.
- 만약 멤버 초기자 리스트 문법을 사용하지 않았다면,
string
과valarray
객체의 디폴트 생성자를 사용한다. - 순서는 멤버 초기자 리스트 순서가 아니다. 선언된 순서이다.
- 만약 멤버 초기자 리스트 문법을 사용하지 않았다면,
class Student
{
private:
typedef valarray<double> ArrayDb;
string name; // name이 먼저 선언되었다.
ArrayDb scores;
public:
Student(const char* str, const double* pd, int n)
: scores(pd, n), name(str) {}
// scores를 먼저 써도, 선언된 순서가 name 먼저라서 name 먼저 초기화된다.
};
- 클래스 메서드 안에서 내포된 객체의 인터페이스를 사용한다.
double Student::Average() const
{
// valarray의 sum(), size() 사용
if (scores.size() > 0) return scores.sum() / scores.size();
else return 0;
}
ostream& operator<<(ostream& os, const Student& stu)
{
// string의 operator<<()를 사용
os << stu.name << " 학생의 성적표: " << endl;
//...
}
private 상속 #
-
public
상속- 기초 클래스의
public
메서드가 파생 클래스의public
메서드가 된다. - 즉, 기초 클래스의 인터페이스를 상속한다.
- is-a
- 기초 클래스의
-
private
상속- 기초 클래스의
public
메서드가 파생 클래스의private
클래스가 된다. - 즉, 기초 클래스의 인터페이스를 상속하지 않는다.
- has-a
- 디폴트가
private
상속이다.
- 기초 클래스의
- 컨테인먼트와의 차이
- 컨테인먼트는 객체를 이름이 있는 멤버 객체로 클래스에 추가한다.
- 반면에
private
상속은 객체를 이름이 없는 멤버 객체로 클래스에 추가한다.
// Student.h
class Studenti : private string, private valarray<double>
{
private:
typedef valarray<double> ArrayDb;
ostream& PrintScores(ostream& os) const;
public:
Studenti() : string("Null Student"), ArrayDb() {}
explicit Studenti(const string& s) : string(s), ArrayDb() {}
explicit Studenti(int n) : string("Nully"), ArrayDb(n) {}
Studenti(const string& s, int n) : string(s), ArrayDb(n) {}
Studenti(const string& s, const ArrayDb& a) : string(s), ArrayDb(a) {}
Studenti(const char* str, const double* pd, int n) : string(str), ArrayDb(pd, n) {}
~Studenti() {}
double Average() const;
double& operator[](int i);
double operator[](int i) const;
const string& Name() const;
friend istream& operator>>(istream& is, Studenti& stu);
friend istream& getline(istream& is, Studenti& stu);
friend ostream& operator<<(ostream& os, const Studenti& stu);
};
// Student.cpp
ostream& Studenti::PrintScores(ostream& os) const
{
int i;
int lim = ArrayDb::size();
if (lim > 0)
{
for (i = 0; i < lim; i++)
{
os << ArrayDb::operator[](i) << " ";
if (i % 5 == 4) os << endl;
}
if (i % 5 != 0) os << endl;
}
else os << "빈 배열";
return os;
}
double Studenti::Average() const
{
if (ArrayDb::size() > 0) return ArrayDb::sum() / ArrayDb::size();
else return 0;
}
const string& Studenti::Name() const
{
return (const string&)*this;
}
double& Studenti::operator[](int i)
{
return ArrayDb::operator[](i);
}
double Studenti::operator[](int i) const
{
return ArrayDb::operator[](i);
}
istream& operator>>(istream& is, Studenti& stu)
{
is >> (string&)stu;
return is;
}
istream& getline(istream& is, Studenti& stu)
{
getline(is, (string&)stu);
return is;
}
ostream& operator<<(ostream& os, const Studenti& stu)
{
os << (const string&)stu << " 학생의 성적표: " << endl;
stu.PrintScores(os);
return os;
}
- 멤버 초기자 리스트 문법을 사용해서 내포된 객체를 초기화 했다.
- 컨테인먼트는 멤버 이름을 사용했다.
- 하지만
private
상속은 클래스 이름을 사용한다.
// 컨테인먼트 - name과 같은 멤버 이름을 사용함.
Studentc::Studentc() : name("Null Student"), scores() {}
// private 상속 - string과 같은 클래스 이름을 사용함.
Studenti::Studenti() : string("Null Student"), ArrayDb() {}
- 클래스 메서드 안에서 기초 클래스의 인터페이스를 사용한다.
- 컨테인먼트는 멤버의 메서드를 사용한다.
- 하지만
private
상속은 클래스 이름과 사용 범위 결정 연산자를 사용한다.
// 컨테인먼트 - scores.sum() 처럼 멤버의 메서드를 사용한다.
double Studentc::Average() const
{
if (scores.size() > 0) return scores.sum() / scores.size();
else return 0;
}
// private 상속 - ArrayDb::sum() 처럼 클래스 이름과 사용 범위 결정 연산자를 사용한다.
double Studenti::Average() const
{
if (ArrayDb::size() > 0) return ArrayDb::sum() / ArrayDb::size();
else return 0;
}
- 기초 클래스 객체에 접근하기
- 컨테인먼트는 멤버를 사용한다.
- 하지만
private
상속은 데이터형 변환을 사용해서 기초 클래스로 변환한다.
// 컨테인먼트 - 멤버인 name을 사용한다.
const string& Studentc::Name() const
{
return name;
}
// private 상속 - 자기 자신을 기초 클래스 string로 형변환한다.
const string& Studenti::Name() const
{
return (const string&)*this;
}
- 기초 클래스 프렌드에 접근하기
- 컨테인먼트는 멤버를 사용한다.
- 하지만
private
상속은 데이터형 변환을 사용해서 기초 클래스로 변환한다.- 명시적으로 변환해야 한다.
private
상속에서는 명시적인 형변환이 없으면, 파생 클래스에 대한 참조/포인터를 기초 클래스에 대한 참조/포인터에 대입할 수 없다.
// 컨테인먼트
ostream& operator<<(ostream& os, const Student& stu)
{
// string 멤버인 name의 operator<<()를 사용
os << stu.name << " 학생의 성적표: " << endl;
//...
}
ostream& operator<<(ostream& os, const Studenti& stu)
{
// (const string&)stu으로 매개변수를 형변환 하여 string의 operator<<()를 사용
os << (const string&)stu << " 학생의 성적표: " << endl;
//...
}
- has-a를 구현하기 위해서 어떤 것을 사용할까?
- 컨테인먼트 vs
private
상속- 컨테인먼트의 장점
- 사용하기 쉽다.
private
의 경우에 하나 이상의 기초 클래스를 상속하면 문제가 발생할 수 있다.- 같은 클래스를 여러개 내포할 수 있다. (
string
멤버가 여러 개)
private
상속의 장점- 기초 클래스의
protected
멤버에 접근할 수 있다. - 가상 함수를 재정의할 수 있다.
- 기초 클래스의
- 컨테인먼트의 장점
protected 상속 #
- 각 상속의 특성
특성 | public 상속 |
protected 상속 |
private 상속 |
---|---|---|---|
기초 클래스의 public 멤버 |
public |
protected |
private |
기초 클래스의 protected 멤버 |
protected |
protected |
private |
기초 클래스의 private 멤버 |
접근 불가 | 접근 불가 | 접근 불가 |
기초 클래스로 암시적 업캐스팅 |
가능하다 | 파생 클래스 안에서만 가능하다 | 불가능하다 |
- 처음 두 행의 설명
- 예를 들어, 기초 클래스의
public
멤버의 경우public
상속일 때는 파생 클래스 안에서public
멤버가 된다는 말이다.
- 예를 들어, 기초 클래스의
- 기초 클래스의
private
멤버의 경우, 기초 클래스의 인터페이스를 통해서만 접근이 가능하다.
protected
나private
상속 일 때, 기초 클래스의 메서드를 파생 클래스에서public
으로 사용하는 방법- (1) 메서드를 새롭게 하나 만든다.
- (2) using 선언을 사용해서 지정한다.
- 이것은 컨테인먼트일 때는 적용되지 않는다.
class Studenti : private string, private valarray<double>
{
public:
// 방법 2
using valarray<double>::sum;
// 방법 1
double Studenti::max() const
{
return valarray<double>::max();
}
//...
};
int main()
{
Studenti s("Kim", {1, 2, 3, 4, 5});
cout << s.max() << endl; // (O) private 상속이지만 max를 public처럼 사용 가능하다.
}
다중 상속 #
-
다중 상속 (multiple inheritance; MI)
- 직계 인접한 기초 클래스를 하나 이상 가지는 클래스이다.
public
,protected
,private
키워드는 각각의 기초 클래스에 명시해서 제한해야 한다.- 명시하지 않으면 디폴트인
private
상속이 된다.
-
여기서는
public
상속을 중심으로 설명한다.
-
다중 상속은 조심스럽게 절제하여 사용해야 한다.
- 하나의 조상을 공유하는 다중 상속은 위험하기 때문이다.
-
예를 들어, 다음과 같은 클래스 구조가 있을 때 문제가 발생한다.
Worker
↙ ↘
Singer Waiter
↘ ↙
SingingWaiter
SingingWaiter sw;
Worker * ptr = &sw; // (X)
-
이 경우 선택지가 두 개가 존재한다.
- 하나는
Waiter
에 있는Worker
이며, - 다른 하나는
Singer
에 있는Worker
이다.
- 하나는
-
또한
Worker
객체의 복사본을 두 개나 가지게 된다.
- 해결방법: 가상 기초 클래스
- 공통 조상의 유일한 객체를 상속하는 방식으로 객체를 파생시킨다.
virtual
키워드를 사용해서 상속한다.- 그러면,
SingingWaiter
는 하나의Worker
객체를 내포한다.
class Worker {};
class Waiter : public virtual Worker {}; // public과 virtual의 위치는 상관 없다.
class Singer : virtual public Worker {}; // 즉, 이렇게 써도 된다.
class SingingWaiter : public Singer, public Waiter {};
- 왜 가상 기초 클래스가 디폴트가 아닌가?
- (1) 기초 클래스 객체의 여러 개의 복사본을 원할 수 있다.
- (2) 기초 클래스를 가상으로 만드려면 추가적인 작업을 해야한다.
- (3) 중간 클래스를 통해 기초 클래스에게 자동으로 정보를 전달하는 기능을 못하게 만든다.
- (3) 가상 기초 클래스를 사용하면 생성자에서 중간 클래스를 통해 기초 클래스에게 자동으로 정보를 전달하는 기능을 못하게 만든다.
C(int, int, int)
의 호출로B(int, int)
는 호출되지만,- 자동으로
A(int)
까지 호출하지는 않는다. - 따라서
A()
의 생성자가 호출된다.
class A
{
public:
A()
{
cout << "A의 디폴트 생성자" << endl;
}
A(int a)
{
cout << "A" << endl;
}
};
class B : public virtual A
{
public:
B(int a, int b) : A(a) // A(a)가 아니라 A()가 호출된다.
{
cout << "B" << endl;
}
};
class C : public B
{
public:
C(int a, int b, int c) : B(a, b)
{
cout << "C" << endl;
}
};
int main()
{
C c(1, 2, 3);
}
A의 디폴트 생성자
B
C
- 기초 생성자를 명시적으로 호출함으로써 수정가능하다.
class C : public B
{
public:
// 명시적으로 A(int)의 생성자를 호출한다.
C(int a, int b, int c) : A(a), B(a, b)
{
cout << "C" << endl;
}
};
A
B
C
간접적인 가상 기초 클래스를 사용하는 파생 클래스는, 그 파생 클래스의 생성자들이 간접적인 가상 기초 클래스 생성자들을 직접 호출하게 해야 한다.
- (2) 기초 클래스를 가상으로 만드려면 추가적인 작업을 해야한다.
- 어느 메서드를 사용하는 가?
class Worker
{
public:
virtual void Show();
};
class Waiter : virtual public Worker
{
public:
void Show();
};
class Singer : virtual public Worker
{
public:
void Show();
};
class SingingWaiter : public Waiter, public Singer
{ };
int main()
{
SingingWaiter sw;
sw.Show(); // (X) Singer와 Waiter 중에 어느 메서드인지 모호하다.
sw.Singer::Show(); // (O)
}
- 대안
SingingWaiter
에서Show()
를 다시 정의하고, 사용할Show()
의 버전을 지정해 준다.
void SingingWaiter::Show()
{
Singer::Show();
}
- 가상 기초 클래스와 가상이 아닌 기초 클래스의 혼합
- 다음 예시에서
B
와C
에 대해서만A
가 가상 클래스이고,X
와Y
에 대해서는 가상 클래스가 아니라면. M
은 결과적으로 3개의 클래스A
종속 객체를 내포하게 되겠다.
- 다음 예시에서
A
↙ ↙ ↘ ↘
[B] [C] X Y
↘ ↘ ↙ ↙
M
- 가상 기초 클래스와 비교 우위
- 파생 클래스에 있는 이름은 조상 클래스에 있는 동일한 이름보다 비교 우위를 가진다.
- 이것은 가시성과는 상관 없다.
- 아래 예시에서는…
A
의AB()
보다B
의AB()
가 우위를 가진다.B
와C
에 있는BC()
는D()
의 입장에서 모호하다.- 이것은 어느 하나가
private
이든 간에 상관없이 비교 우위를 가지는 것들이다. - 즉,
B
의AB()
가private
이라고 해도A
의AB()
보다 비교 우위를 가진다.
A
↙ ↘
[B] [C]
↘ ↙
D
class A
{
public:
void AB();
};
class B : public virtual A
{
public:
void AB();
void BC();
};
class C : public virtual A
{
public:
void BC();
};
class D : public B, public C
{
public:
void Test()
{
AB(); // B의 AB() 호출
BC(); // (X) 모호하다
}
};