항상 상속, 상속하지만 정말 어디까지 깊게 가봤을까..?
이번에 비록 공부했던 내용을 정리하면서, 조금 더 알아가고 싶어서 시작해 보자.
* 정리 내용은 C++ 기초 플러스를 기반으로 정리하고 있습니다.
C++에서는 클래스는 더 높은 수준의 재활용성을 제공합니다.
여기서는 C++는 클래스를 확장시키고, 수정하기 위해서 클래스 상속(class inheritance)을 사용하고 있다.
상속이라는 걸 여기에 담고 싶어서 본문의 내용이 엄~청 길어질 수 있다.
다만 여러 차례로 나눠서 하는 것보다 흐름이 끊기지 않게 보는 것이 더 이득이라 판단하여 계속해서 포스팅 예정
상속이 주는 힘이 모길래...
- 기존 클래스에 기능을 추가할 수 있다.
- 클래스가 나타내는 데이터에 다른 것을 더 추가할 수 있다.
- 클래스 메서드가 동작하는 방식을 변경할 수 있다.
즉, 상속은 새 기능을 간단히 추가하는 것으로 모든 것? 이 해결된다고 하는데,
제가 느끼기에는 기존 코드를 건드리지 않고, 기존 코드 + 내가 만든 확장성 기능으로 이미 사용된 것에 ++ 하는 느낌으로 개선된다라고 생각됩니다.
=> 세부 구현의 비밀은 유지하면서, 프로그래머들이 그 클래스에 기능을 추가하여 새로운 클래스를 파생시킬 수 있게
(기존 코드를 맘대로 수정했다가, 생각지도 못한 케이스에서 예외나 버그가 발생할 수 있기 때문에)
이렇듯 상속은 뛰어난 개념이지만, 기본적인 구현은 단순
가볍게 상속을 들어가 보자.
일반적으로 오리지널 클래스를 기초 클래스(Base class)라고 하고,
이걸 상속받는 클래스를 파생 클래스(Derived class)라고 한다.
상속 관련 기본적인 코드를 살펴보자.
#ifndef TABTENN1_H_
#define TABTENN1_H_
#include <string>
using std::string;
class TableTennisPlayer
{
private:
string firstname;
string lastname;
bool hasTable;
public:
TableTennisPlayer(const string& fn = "none", const string& ln = "none", bool ht = false);
void Name() const;
bool HasTable() const { return hasTable; }
void ResetTable(bool v) { hasTable = v; }
};
// 파생 클래스
// 두 클래스는 서로 연계되어 있기 때문에, 클래서 선언들을 함께 묶어두는 것이 더 체계적이라고 한다.
class RatedPlayer : public TableTennisPlayer
{
private:
unsigned int rating;
public:
RatedPlayer(unsigned int r = 0, const string& fn = "none", const string& ln = "none", bool ht = false);
RatedPlayer(unsigned int r, const TableTennisPlayer& tp);
unsigned int Rating() { return rating; }
void ResetRating(unsigned int r) { rating = r; }
};
#endif
#include "tabtenn1.h"
#include <iostream>
TableTennisPlayer::TableTennisPlayer(const string& fn, const string& ln, bool ht)
:firstname(fn), lastname(ln), hasTable(ht) {}
void TableTennisPlayer::Name() const
{
std::cout << lastname << ", " << firstname;
}
RatedPlayer::RatedPlayer(unsigned int r, const string& fn, const string& ln, bool ht)
: TableTennisPlayer(fn, ln, ht)
{
rating = r;
}
RatedPlayer::RatedPlayer(unsigned int r, const TableTennisPlayer& tp)
: TableTennisPlayer(tp), rating(r) {}
파생 클래스 RatedPlayer를 선언하게 되면, 다음과 같은 효과를 얻을 수 있다.
- 파생 클래스형의 객체 안에는 기초 클래스형의 데이터 멤버들이 저장된다. (상속받았으니까)
- 파생 클래스형의 객체는 기초 클래스형의 메서드들을 사용할 수 있다.
상속받은 기능에 어떤 것들을 추가해야 할까?
- 파생 클래스는 자기 자신의 생성자를 필요로 한다.
- 파생 클래스는 부가적인 데이터 멤버들과 멤버 함수들을 필요한 만큼 추가할 수 있다.
* 생성자
파생클래스는 기초 클래스의 private 멤버에 직접 접근이 불가능하다. (public, protected만 가능)
파생클래스 객체를 생성할 때,
기초 클래스의 객체를 생성하게 된다.
RatedPlayer::RatedPlayer(unsigned int r, const string& fn, const string& ln, bool ht)
: TableTennisPlayer(fn, ln, ht)
{
rating = r;
}
즉, 파생 클래스 생성자의 몸체 안으로 들어가기 전에, 기초 클래스 객체가 먼저 생성되어야 하기 때문에
멤버 초기자 리스트 문법을 사용해서, 기초 클래스의 생성자에게 매개 변수를 전달한다.
만약 멤버 초기자 리스트를 생략한다면? 기초 클래스 생성자가 생성되는 것은 확실하고 다만 디폴트 기초 클래스 생성자를 사용하게 된다.
디폴트 기초 생성자는 프로그래머가 아무것도 정의하지 않을 때에만 컴파일러가 자동적으로 생성해 주는 것이며, 만약에 이를 사용하기를 원치 않으면 직접 생성자를 구현해 주면 된다.
* 복사 생성자
RatedPlayer::RatedPlayer(unsigned int r, const TableTennisPlayer& tp)
: TableTennisPlayer(tp), rating(r) {}
TableTennisPlayer(tp)
tp는 const TableTennisPlayer& 형이므로 이 호출은 기초 클래스의 복사 생성자를 호출하게 된다.
마찬가지로 복사 생성자를 정의하지 않았다면 컴파일러가 복사 생성자를 자동으로 생성하게 되는데
이게 좋은 점과 나쁜 점이 있다.
보통 기본 복사 생성자는 멤버별 복사가 이뤄지는데 (primitive type)이라면 상관없지만,
생성자 안에서 new를 이용한 동적 메모리 대입이 이뤄진다면? 문제가 된다.
여기서는 동적 메모리 대입이 없기 때문에 암시적 복사 생성자로 충분하다.
여기까지 정리하면,
파생 클래스 생성자의 요점은
- 기초 클래스 객체가 먼저 생성된다.
- 파생 클래스 생성자는 멤버 초기자 리스트를 통해 기초 클래스 생성자에게 정보를 제공해야 한다.
- 파생 클래스 생성자에서는 새로 추가된 데이터 멤버들을 초기화해야 한다. (생성자의 의도는 초기화니까)
- 객체의 생성은 기초 클래스 -> 파생 클래스 순이지만 파괴는 파생 클래스 -> 기초 클래스 순으로 이뤄진다.
파생 클래스와 기초 클래스의 특별한 관계?
기초 클래스 메서드들이 private가 아니라면, 파생 클래스 객체는 기초 클래스의 메서드를 사용할 수 있다.
이건 당연하다.
기초 클래스 포인터는 명시적 데이터형 변환 없이도 파생 클래스 객체를 지시할 수 있다.
기초 클래스 참조는 명시적 데이터형 변환 없이도 파생 클래스 객체를 참조할 수 있다.
RatedPlayer rplayer1(1140, "mallory", "Duck", true);
TableTennisPlayer * pt = &rplayer1;
TableTennisPlayer & rt = rplayer1;
pt->Name(); // 포인터
rt.Name(); // 참조
다만, 기초 클래스 포인터나 참조는 기초 클래스 메서드만 호출할 수 있다.
(파생 클래스의 메서드를 사용할 수 없다는 것이다. 처음에는 이게 이해하기 어려웠다.)
일반적으로 C++은 참조형과 포인터형이 대입되는 데이터형과 일치할 것을 요구한다.
그러나 상속에서는 이 규칙이 완화 적용되는데
파생 클래스의 참조와 포인터에 기초 클래스의 객체와 주소를 대입할 순 없지만 반대는 된다.
기초 클래스 참조와 포인터를 매개변수로 사용하는 함수는 기초 클래스 or 파생 클래스 모두 넘겨줄 수 있다.
즉, 메서드 하나만 이렇게 구현하면 파생된 모든 클래스를 담을 수 있다는 얘기다.
참조 호환성은
기초 클래스 객체를 파생 클래스 객체로 초기화도 간접적으로 허용한다.
RatedPlayer olaf1(...);
TableTennisPlayer olfa2(olaf1);
// 암시적 복사 생성자
TableTennisPlayer(const TableTennisPlayer&);
형식 매개변수를 보면 기초 클래스형에 대한 참조로 되어있다. 그래서 파생 클래스가 객체도 담을 수 있는 것이다.
비슷한 코드이지만 대입이라는 게 생성자와는 다르다.
RatedPlayer olaf1(...);
TableTennisPlayer winner;
winner = olaf1; // 파생 클래스 객체를 기초 클래스 객체에 대입
// 암시적인 오버로딩 대입 연산자
TableTennisPlayer& operator=(const TableTennisPlayer&) const;
암시적인 복사 생성자, 암시적인 오버로딩 대입 연산자.
편하지만, new라는 동적이 들어가면 명시적으로 구현해줘야 하는 게 핵심이다.
상속 is-a 관계
is-a 관계,
파생 클래스 객체가 기초 클래스 객체이기도 하다는 것을 뜻한다.
기초 클래스 객체를 대상으로 수행할 수 있는 모든 작업을, 파생 클래스 객체에서도 수행이 가능하다는 것이다.
has-a 관계도 존재하는데,
이거는 클래스가 다른 클래스를 가졌다는 의미이지 ~가 ~일 수는 없는 개념이므로 상속과는 다르다.
다만 서로 다른 객체 간의 통신(소통)을 다루기 위해서는 프렌드 함수나 클래스를 만드는 것은 가능하다.
public 다형 상속
호출하는 객체에 따라서 메서드의 행동이 달라질 수 있다는 것이 요점이다.
이렇게 복잡한 행동을 다형(polymorhpic)이라고 한다.
public 다형 상속으로는
- 기초 클래스 메서드를 파생 클래스에서 다시 정의하는 것
- 가상 메서드를 사용하는 것(virtual)
예시 코드를 살펴보자.
(사실 코드의 내용도 중요하지만, 전체적인 흐름 위주로 공부하는 것이 오히려 더 도움이 되는 듯하다.)
(즉, 외우는 것보다는 전체적인 그림과 왜 이렇게 선언했는지를 중점으로 보자.)
#ifndef BRASS_H_
#define BRASS_H_
#include <string>
class Brass
{
private:
std::string fullName;
long acctNum;
double balance;
public:
Brass(const std::string& s = "Nullbody", long an = -1, double bal = 0.0);
void Deposit(double amt);
virtual void Withdraw(double amt);
double Balance() const;
virtual void ViewAcct() const; // 가상 함수(가상 메서드)
virtual ~Brass() {}
};
// Brass::ViewAcct()
// BrassPlus::ViewAcct()
// 호출한 객체가 어떤 클래스형이냐에 따라 어느 버전을 사용할 것인지를 결정
// Withdraw도 동일
// Deposit(), Balance()
// 두 클래스에 대해 똑같이 행동하는 메서드들은 기초 클래스에 단 한 번만 선언된다.
class BrassPlus : public Brass
{
private:
double maxLoan;
double rate;
double owesBank;
public:
BrassPlus(const std::string& s = "Nullbody", long an = -1, double bal = 0.0, double m1 = 500, double r = 0.11125);
BrassPlus(const Brass& ba, double m1 = 500, double r = 0.11125);
virtual void ViewAcct() const;
virtual void Withdraw(double amt);
void ResetMax(double m) { maxLoan = m; }
void ResetRate(double r) { rate = r; }
void ResetOwes() { owesBank = 0; }
};
#endif
헤더 파일에서만 살펴보면,
기초는 Brass, 파생은 BrassPlus
파생 클래스에서는 3개의 새로운 데이터 멤버와 멤버 함수를 추가하였다.
그런데, 기초/파생 모두 ViewAcct()와 Withdraw() 메서드를 선언하고 있는데
이는 각 객체에 대해 하는 행동이 다른 메서드들이라는 뜻이다.
그래서 virtual 키워드로 가상 메서드로 선언하고 있다.
Brass::ViewAcct() // BrassPlus::ViewAcct() 다른 내용...
프로그램은 호출한 객체가 어떤 클래스형이냐에 따라서 어느 버전을 사용할지 결정하게 된다.
Deposit과 Balance 메서드는 한 번만 기초 클래스에서 선언되었는데, 이는 두 클래스에 대해 똑같이 행동하는 메서드들이기 때문이다.
또 파괴자에서도 virtual 키워드를 사용하는데,
아무 일도 하지 않지만 가상 파괴자를 선언하고 있다.
#include "brass.h"
#include <iostream>
using std::cout;
using std::endl;
using std::string;
typedef std::ios_base::fmtflags format;
typedef std::streamsize precis;
format setFormat();
void restore(format f, precis p);
Brass::Brass(const string& s, long an, double bal)
{
fullName = s;
acctNum = an;
balance = bal;
}
void Brass::Deposit(double amt)
{
if (amt < 0)
cout << "마이너스 입금은 허용되지 않습니다.\n그래서 입금을 취소되었습니다.\n";
else
balance += amt;
}
void Brass::Withdraw(double amt)
{
// ###.## 형식으로 설정
format initialState = setFormat();
precis prec = cout.precision(2);
if (amt < 0)
cout << "마이너스 인출은 허용되지 않습니다.\n그래서 인출이 취소되었습니다.\n";
else if (amt <= balance)
balance -= amt;
else
cout << "인출을 요구한 금액 $" << amt
<< "가 현재 잔액을 초과합니다.\n"
<< "그래서 인출이 취소되었습니다.\n";
restore(initialState, prec);
}
double Brass::Balance() const
{
return balance;
}
void Brass::ViewAcct() const
{
format initialState = setFormat();
precis prec = cout.precision(2);
cout << "고객 이름: " << fullName << endl;
cout << "계좌 번호: " << acctNum << endl;
cout << "현재 잔액: $" << balance << endl;
restore(initialState, prec); // 원래 형식으로 복원
}
BrassPlus::BrassPlus(const string& s, long an, double bal, double m1, double r)
: Brass(s, an, bal)
{
maxLoan = m1;
owesBank = 0.0;
rate = r;
}
BrassPlus::BrassPlus(const Brass& ba, double m1, double r)
: Brass(ba) // 암시적 복사 생성자를 사용한다.
{
maxLoan = m1;
owesBank = 0.0;
rate = r;
}
void BrassPlus::ViewAcct() const
{
format initialState = setFormat();
precis prec = cout.precision(2);
Brass::ViewAcct(); // 기초 부분 출력
cout << "당좌 대월 한도액: $" << maxLoan << endl;
cout << "상환할 원리금: $" << owesBank << endl;
cout.precision(3);
cout << "당좌 대월 이자율: " << 100 * rate << "%\n";
restore(initialState, prec);
}
void BrassPlus::Withdraw(double amt)
{
format initialState = setFormat();
precis prec = cout.precision(2);
// Balance() 메서드
// 파생 클래스에서 다시 정의되지 않기 때문에,
// 사용 범위 결정 연산자(::)를 사용할 필요가 없다.
double bal = Balance();
if (amt <= bal)
Brass::Withdraw(amt);
else if (amt <= bal + maxLoan - owesBank)
{
double advance = amt - bal;
owesBank += advance * (1.0 + rate);
cout << "당좌 대월 금액: $" << advance << endl;
cout << "당좌 대월 이자: $" << advance * rate << endl;
Deposit(advance);
Brass::Withdraw(amt);
}
else
{
cout << "당좌 대월 한도가 초과되어 거래가 취소되었습니다.\n";
}
restore(initialState, prec);
}
format setFormat()
{
return cout.setf(std::ios_base::fixed, std::ios_base::floatfield);
}
void restore(format f, precis p)
{
cout.setf(f, std::ios_base::floatfield);
cout.precision(p);
}
중점적으로 봐야 하는 건, virtual 키워드를 사용한 녀석인데
- 메서드가 객체에 의해서 호출되지 않고 참조나 포인터에 의해 호출되었을 때에는 어느 메서드를 사용할 것인지를 결정한다.
- virtual 키워드를 사용하지 않을 경우에는 프로그램은 참조형이나 포인터형에 기초하여 메서드를 선택한다.
ViewAcct()가 가상이 아닐 때에는
참조형 혹은 포인터형에 기초하여 메서드가 선택된다.
# 가상 메서드가 아닌 케이스
Brass dom(...);
Brass dot(...);
Brass& b1 = dom;
Brass& b2 = dot;
b1.ViewAcct(); // Brass::ViewAcct() 호출
b2.ViewAcct(); // Brass::ViewAcct() 호출
참조형이나 포인터형에 기초하여 메서드를 선택하게 된다.
# 가상 메서드인 경우에
Brass dom(...);
BrassPlus dot(...);
Brass& b1 = dom;
Brass& b2 = dot;
b1.ViewAcct(); // Brass::ViewAcct();
b2.ViewAcct(); // BrassPlus::ViewAcct();
둘다 참조 변수 Brass형이지만?
b2는 BrassPlus의 객체를 참조한다.
파생 클래스에서 다시 정의되는 메서드들은, 기초 클래스에서 가상으로 선언하는 것이 일반적인 관행이라고 한다.
어떤 메서드가 기초 클래스에서 가상으로 선언되었을 때?
파생 클래스에서 자동으로 가상 메서드가 된다.
그러나 파생 클래스 선언에서도 virtual 키워드를 사용함으로써, 어떤 함수들이 가상인지 표시해 두는 것이 바람직하다.
프로그램이 거대해질수록 이런 사소한 키워드를 놓칠 수 있기 때문에 (휴먼 에러)
* 기초 클래스가 가상 파괴자를 선언한다는 것은
파생 클래스 객체가 파괴될 때, 파괴자들이 올바른 순서로 호출되도록 해준다.
* 참고로 virtual 키워드는 메서드 정의부가 아니라, 클래서 선언부에 메서드 원형에만 사용하면 된다.
* 다시 한번 리마인드
파생 클래스는 기초 클래스의 private 데이터에 직접적으로 접근하지 못하는데,
파생 클래스 생성자들이 기초 클래스의 private 데이터를 초기화하기 위해서는 멤버 초기자 리스트 문법을 사용하는 것이다.
BrassPlus::BrassPlus(const string& s, long an, double bal, double m1, double r)
: Brass(s, an, bal)
{
maxLoan = m1;
owesBank = 0.0;
rate = r;
}
BrassPlus::BrassPlus(const Brass& ba, double m1, double r)
: Brass(ba) // 암시적 복사 생성자를 사용한다.
{
maxLoan = m1;
owesBank = 0.0;
rate = r;
}
* 멤버 초기자 리스트
생성자가 아니면, 멤버 초기자 리스트 문법을 사용할 수 없음에 주의하자.
* (::) 키워드
사용 범위 결정 연산자
어느 클래스의 어느 걸 사용할 건지를 알려주는 아주 중요한 녀석이다.
예를 들면 기초 클래스에도 있고, 파생 클래스에도 있을 때,
혹은 같은 파일에 정의하고 구현할 때, 등등
가상 메서드의 행동
객체에 의한 호출은 가상 함수의 기능을 온전히 사용하지 못한다.
Brass와 BrassPlus 객체를 둘 다 지시할 수 있도록 배열을 구성한다면? 이 역시 다형으로 인정된다.
이때, 그 배열에서 아래와 같은 코드를 작성한다면, 가상 메서드의 기능을 발휘할 수 있다.
for (int i = 0; i < CLIMITS; ++i)
{
p_clients[i]->ViewAcct();
cout << endl;
}
배열 원소의 포인터가 BrassPlus 객체를 지시하고 있다면 BrassPlus::ViewAcct()가 호출되고,
Brass 객체를 지시하고 있다면 Brass::ViewAcct()가 호출되게 된다.
이것이 다형이다.
가상 파괴자는 왜 필요할까
보통 파괴자는 new에 의해 대입된 객체들을 해제하기 위해서 delete를 이쪽에 구현하는데,
파괴자들이 가상이 아니라면, 포인터형에 해당하는 파괴자만 호출될 것이다.
BrassPlus 객체를 지시하는 경우에도 포인터형은 Brass이기 때문에, Brass 파괴자만 호출된다.
하지만 파괴자가 가상 파괴자면? 파괴자들이 올바른 순서로 호출되도록 만든다.
정적 결합과 동적 결합
소스 코드에 있는 함수 호출을 특정 블록에 있는 함수 코드를 실행하라는 뜻으로 함수 이름을 결합(Binding)이라고 한다.
C언어에서는 각각의 함수가 별개의 함수에 해당하기 때문에, 이 작업이 간단했었다.
C++에서는 함수 오버로딩 때문에 이 작업이 조금 복잡하다고 한다.
C or C++ 컴파일러는 결합을 컴파일하는 동안에 수행할 수 있는데,
컴파일하는 동안에 일어나는 결합을 정적 결합(Static binding),
가상 함수들은 이 작업을 조금 더 어렵게 만든다.
프로그램을 실행할 때, 사용자가 객체를 결정하기 때문에, 컴파일하는 동안에는 그 객체가 어떤 종류인지 알 수 없게 된다.
따라서 어떤 함수를 사용할 것인지 컴파일 시에 결정할 수 없기 때문에
프로그램을 실행할 때 올바른 가상 메서드가 선택되도록 하는 코드를 만들어내야 한다.
이 작업을 동적 결합(dynamic binding)라고 한다.
포인터와 참조형의 호환
일반적으로 C++은 한 데이터형의 주소를 다른 데이터형의 포인터에 대입하는 것을 허용하지 않는다.
또한 한 데이터형에 대한 참조가 다른 데이터형을 참조하는 것도 허용하지 않는다.
다만 기초 클래스를 지시하는 포인터나 참조는 명시적인 강제 데이터형 변환 없이도
파생 클래스 객체를 참조할 수 있다.
파생 클래스 참조나 포인터를 기초 클래스 참조나 포인터로 변환하는 것을 업캐스팅(upcasting)이라고 한다.
public 상속에서는 명시적인 데이터형 변환이 없이도 업캐스팅이 언제든지 가능하다. (is-a관계)
BrassPlus 객체는 Brass객체의 모든 데이터 멤버와 멤버 함수들을 상속받는다는 의미에서 Brass 객체다.
그래서 Brass 객체를 대상으로 할 수 있는 모든 작업을 BrassPlus 객체를 대상으로 할 수 있게 된다.
참고로 업캐스팅은 전이된다. 파생 클래스를 끊임없이 만들어도 Brass 포인터는 파생된 모든 객체를 모두 참조할 수 있게 된다.
다만,
기초 클래스 포인터나 참조를 파생 클래스 참조나 포인터로 변환하는 것을 다운캐스팅(downcasting)이라 하는데
다운캐스팅은 명시적인 데이터형 변환 없이는 허용되지 않는다.
이는 is-a 관계가 대칭적이지 않기 때문(일방통행)
업캐스팅의 힘은 기초 클래스에 대한 참조 또는 포인터를 매개변수로 가지는 함수 호출에서 유용하다.
void fr(Brass& rb);
void fp(Brass* pb);
void fv(Brass b);
Brass b(...);
BrassPlus bp(...);
fr(b); // Brass::ViewAcct();
fr(bp); // BrassPlus::ViewAcct();
fp(b); // Brass::ViewAcct();
fp(bp); // BrassPlus::ViewAcct();
fv(b); // Brass::ViewAcct();
fv(bp); // Brass::ViewAcct();
참조나 포인터에 의해 발생하는 암시적 업캐스팅은 fr(), fp() 메서드가 Brass 객체에 대해서는 Brass::ViewAcct()로
BrassPlus 객체에 대해서는 BrassPlus::ViewAcct()를 사용하게 한다.
하지만,
값 전달 방식은 Brass 컴포넌트만 함수로 전달하기 때문에 모두 Brass::ViewAcct()를 호출하게 된다.
암시적 업캐스팅은 기초 클래스 포인터나 참조가 기초 클래스 객체나 파생 클래스 객체를 참조하는 것을 가능하게 만든다.
이래서 동적 결합이 필요한 이유이며, 가상 멤버 함수는 이러한 필요성에 대한 C++의 해답으로 생겨놨다.
가상 멤버 함수와 동적 결합
컴파일러는 가상이 아닌 함수들에 대해서는 정적 결합을 사용한다.
그러나 기초 클래스에 가상으로 선언한다면?, 프로그램이 실행될 때 결정될 수 있게 변경된다. (동적 결합)
대부분의 경우에는 동적 결합이 더 좋다.
특별한 클래스형에 맞게 설계된 메서드를 프로그램이 선택하도록 허용하기 때문이다.
그런데, 왜 정적 결합이 있는 걸까?
효율성과 개념 모델이라는 2가지 이유가 있기 때문이다.
정적 결합이 더 효율적이기 때문에 C++에서 그것이 디폴트로 설정되어 있다.
(메모리 사용이나 처리 시간 등의) 부담을 떠안지 않는 것이 C++의 기본 철학
가상 함수는 프로그램 설계상 꼭 필요한 경우에만 사용해야 한다.
가상이 아닌 함수로 만듦으로써 2가지가 이뤄지는데
1. 효율이 좋아지고
2. 이 함수가 다시 정의되면 안 된다는 의도를 들어냄
기초 클래스에 있는 메서드를 파생 클래스에서 다시 정의할 예정이라면,
그것을 가상 함수로 만들어야 한다는 것이다.
가상함수는 어떻게 동작하는가?
C++은 가상 함수들이 어떻게 동작해야 하는지를 규정하고 있다. 이것을 사용해서 구현하는 것은 프로그래머의 몫이다.
다만 가상 함수의 개념을 조금 더 잘 이해하고 사용한다면 더 올바른 사용으로 이뤄진다.
컴파일러가 가상 함수를 다루는 일반적인 방법은, 각각의 객체에 숨겨진 멤버를 하나씩 추가하는 것이다.
숨겨진 멤버는 함수의 주소들로 이루어진 배열을 지시하는 포인터를 저장하는 것인데
일반적으로 그 배열을 가상 함수 테이블(virtual function table)이라고 한다.
vtbl는 그 클래스의 객체들을 위해 선언된 가상 함수들의 주소가 저장되어 있다.
기초 클래스의 한 객체는 그 클래스를 위한 모든 가상 함수들의 주소로 이루어진 테이블을 지시하는 포인터를 가진다.
파생 클래스의 한 객체는 가상 함수들의 주소로 이루어진 별개의 테이블을 지시하는 포인터를 가진다.
파생 클래스가 가상 함수에 대해 새로운 정의를 제공한다면, vtbl은 새로 정의된 함수의 주소를 저장하게 된다.
파생 클래스가 가상 함수를 다시 정의하지 않으면, vtbl은 그 함수의 오리지널 버전의 주소를 저장한다.
파생 클래스가 새로운 함수를 정의하고 그 함수를 가상으로 선언한다면 그 주소가 vtbl에 추가된다.
가상 함수를 1개를 정의하든 10개를 정의하든 간에, 주소 멤버는 한 객체에 하나만 추가되며
정의하는 가상 함수의 개수에 따라 테이블의 크기만 변하게 된다.
가상 함수를 호출하면, 프로그램은 객체의 vtbl 주소가 저장되어 있다는 것을 알게 되고,
함수 주소들로 이루어진 해당 테이블에 접근하게 된다.
사용하는 함수가 클래스 선언에 정의된 첫 번 째 가상 함수라면, 프로그램은 그 배열에 있는 첫 번째 주소를 사용하고,
그 주소에 있는 함수를 실행시킨다.
정리하면, 가상 함수를 사용하면 메모리와 실행 속도 면에서 약간의 부담이 따르게 되는데
- 각 객체의 크기가 주소 하나를 저장하는 데 필요한 양만큼 커진다.
- 각각의 클래스에 대해, 컴파일러는 가상 함수들의 주소로 이루어진 하나의 테이블(배열)을 만든다.
- 각각의 함수 호출에 대해, 실행할 함수의 주소를 얻기 위해 테이블에 접근하는 가외의 단계가 더 필요하다.
가상이 아닌 함수들은 가상 함수보다 조금 더 효율적이지만, 동적 결합을 제공하지 않는다.
class Scientist
{
...
char name[40];
public:
virtual void show_name();
virtual void show_all();
...
};
class Physicist : public Scientist
{
...
char field[40];
public:
void show_all(); // 다시 정의된 함수
virtual void show_field(); // 새로 정의된 함수
...
};
# Scientist 가상 함수 테이블을 가리키는 테이블 주소 2008
4064(show_name), 6400(show_all)
# Physicist 가상 함수 테이블을 가리키는 테이블 주소 2096
4064(show_name), 6820(show_all, 다시 정의된 함수), 7280(show_field)
Physicist adam(...);
Scientist * psd = &adam;
psc->show_all();
1. psc->vptr의 값을 얻는다.
2. 2096의 테이블로 이동한다.
3. 그 테이블에 있는 두 번째 함수의 주소(6820)을 얻는다.
4. 그 주소(6820)로 가서 거기에 있는 함수를 실행한다.
Scientist 가상 함수 테이블을 지시하는 숨겨진 포인터 멤버 vptr를 가짐
Physict 가상 함수 테이블을 지시하는 숨겨진 포인터 멤버 vptr를 가짐
가상 메서드에 대해 알아야 할 사항들
- 기초 클래스에서 클래스 메서드를 선언할 때, virtual로 시작하면, 그 함수는 기초 클래스, 기초 클래스에 파생된 클래스, 파생 클래스에서 다시 파생된 클래스 등 모든 클래스에 대해 가상이 된다.
- 객체에 대한 참조를 사용하여 또는 포인터를 사용하여 가상 메서드가 호출되면, 프로그램은 그 참조나 포인터형을 위해 정의된 메서드를 사용하지 않고, 객체형을 위해 정의된 메서드를 사용한다. (동적 결합)
- 상속을 위해 기초 클래스로 사용할 클래스를 정의할 때, 파생 클래스에서 다시 정의해야 하는 클래스 메서드들은 가상 함수로 선언해야 한다.
# 생성자
생성자는 가상으로 선언할 수 없다.
파생 클래스 객체의 생성은 기초 클래스 생성자가 아니라 파생 클래스 생성자를 호출한다.
그러고 나서 파생 클래스 생성자가 기초 클래스 생성자를 호출한다.
# 파괴자
클래스가 기초 클래스로 사용된다면, 파괴자는 가상으로 선언해야 한다.
파생 클래스의 수명이 다했을 때, 파생 클래스의 파괴자가 호출되어 그 메모리를 해제하는 것이 매우 중요하기 때문이다.
만약에 디폴트 정적 결합이 적용된다면, 기초 클래스의 파괴자만 호출될 것이다.
하지만 가상 파괴자라면 파생 클래스의 파괴자도 호출된다. (파생 클래스에서 new를 사용했다면)
기초 클래스가 명시적 파괴자의 서비스를 요구하지 않더라도 디폴트 파괴자에 의존하면 안 된다.
아무 일도 하지 않는 가상 파괴자를 반드시 제공해 주자.
어떤 클래스가 가상 파괴자를 가지는 것은 에러가 아닌, 효율성의 문제다.
즉, 파괴자가 필요 없는 기초 클래스라 하더라도 가상 파괴자를 제공해야 한다.
# 프렌드
프렌드는 가상 함수가 될 수 없다.
멤버 함수만 가상 함수가 될 수 있는데, 프렌드는 클래스 멤버가 아니기 때문이다.
# 가상 함수를 다시 정의하지 않는다면
파생 클래스가 (가상이든 아니든) 함수를 다시 정의하지 않는다면, 파생 클래스는 그 함수의 기초 클래스 버전을 사용한다.
# 가상 함수를 다시 정의하면 메서드가 은닉된다.
어떤 함수를 파생 클래스에서 다시 정의하면, 동일한 함수 시그내처를 가지고 있는 기초 클래스 선언만 가리는 것이 아니라, 매개변수 시그내처와는 상관없이 같은 이름을 가진 모든 기초 클래스 메서드들을 가리게 된다.
기초 클래스에서도 virtual void print() const;
파생 클래스에서도 virtual void print() const;
기초 메서드
virtual Dwelling& build(int n);
파생 메서드
virtual Hovel& build(int n); // 동일한 함수 시그내처
상속된 메서드를 재정의할 경우에는 오리지널 원형과 정확히 일치시킬 필요가 있다.
이 규칙에 비교적 새로운 예외가 있다면, 리턴형이 기초 클래스에 대한 참조나 포인터인 경우
파생 클래스에 대한 참조나 포인터로 대체될 수 있다는 것이다.
리턴형이 클래스형과 병행하여 바뀌는 것을 허용하기 때문에,
이 기능을 리턴형의 공변(covariance)이라고 한다.
기초 클래스 선언이 오버로딩되어 있다면, 파생 클래스에서 모든 기초 클래스 버전들을 재정의해야 한다.
// 기초
virtual void showperks(int a) const;
virtual void showperks(double x) const;
virtual void showperks() const;
// 파생
virtual void showperks(int a) const;
virtual void showperks(double x) const;
virtual void showperks() const;
한 가지 버전만 재정의하면, 나머지 두 버전은 가려져 파생 클래스 객체가 그들을 사용할 수 없게 된다.
변경이 필요 없다면, 재정의는 단순히 기초 클래스 버전을 호출할 것
접근 제어: protected
가능하다면 protected 접근 제어보다는 private 접근 제어를 사용하여 클래스 데이터 멤버들에 접근해야 한다.
파생 클래스가 기초 클래스 데이터에 접근하게 하려면 기초 클래스 메서드를 사용해야 한다.
추상화 기초 클래스 (abstract base class = ABC)
길고 길게 왔다.
사실 지금까지는 복잡도가 낮은 간단한 상속과 다형 상속에 대해서 알았다면, 이제는 복잡도가 더 높아진 추상화 기초 클래스에 대해서 알아보자.
상속 관련해서 is-a 규칙을 적용하는 것이 생각처럼 쉽지 않을 때가 있는데,
공통적인 것들을 추출해서 그것들을 추상화 기초 클래스에 넣을 수 있다.
다만 추상화 기초 클래스는 필요한 데이터 멤버를 가지고 있지 않아서, 공통 메서드를 구현할 수 없는데
C++는 구현되지 않는 이러한 함수를 위해 순수 가상 함수(pure virtual function)를 제공하고 있다.
순수 가상 함수는 함수 선언 뒤에 = 0을 가지게 된다.
클래스 선언에 순수 가상 함수가 들어있다면, 그 클래스는 객체를 생성할 수 없다.
어떤 클래스가 진짜 추상화 기초 클래스가 되려면, 적어도 하나의 순수 가상 함수를 가져야 한다.
C++는 순수 가상 함수가 정의를 가지는 것을 허용한다.
void Move(int nx, int ny) = 0;
ABC는 적어도 하나의 순수 가상 함수를 사용하는 인터페이스를 서술한다.
ABC로부터 파생된 클래스들은 일반 가상 함수를 사용하여, 그 특별한 파생 클래스의 속성을 가지고 그 인터페이스를 구현한다.
아래 코드는 추상화에 관한 코드다.
#ifndef ACCTABC_H_
#define ACCTABC_H_
#include <iostream>
#include <string>
// 추상화 기초 클래스
class AcctABC
{
private:
std::string fullName;
long acctNum;
double balance;
protected:
struct Formatting
{
std::ios_base::fmtflags flag;
std::streamsize pr;
};
const std::string& FullName() const { return fullName; }
long AcctNum() const { return acctNum; }
Formatting SetFormat() const;
void Restore(Formatting& f) const;
public:
AcctABC(const std::string& s = "Nullbody", long an = -1, double bal = 0.0);
void Deposit(double amt);
virtual void Withdraw(double amt) = 0; // 순수 가상 함수
double Balance() const { return balance; }
virtual void ViewAcct() const = 0; // 순수 가상 함수
virtual ~AcctABC() {}
};
class Brass : public AcctABC
{
public:
Brass(const std::string& s = "Nullbody", long an = -1, double bal = 0.0) : AcctABC(s, an, bal) {}
virtual void Withdraw(double amt);
virtual void ViewAcct() const;
virtual ~Brass() {}
};
class BrassPlus : public AcctABC
{
private:
double maxLoan;
double rate;
double owesBank;
public:
BrassPlus(const std::string& s = "Nullbody", long an = -1, double bal = 0.0, double m1 = 500, double r = 0.10);
BrassPlus(const Brass& ba, double m1 = 500, double r = 0.10);
virtual void ViewAcct() const;
virtual void Withdraw(double amt);
void ResetMax(double m) { maxLoan = m; }
void ResetRate(double r) { rate = r; }
void ResetOwes() { owesBank = 0; }
};
#endif
#include <iostream>
#include <string>
#include "acctabc.h"
using std::cout;
using std::ios_base;
using std::endl;
using std::string;
AcctABC::AcctABC(const string& s, long an, double bal)
{
fullName = s;
acctNum = an;
balance = bal;
}
void AcctABC::Deposit(double amt)
{
if (amt < 0)
cout << "마이너스 입금은 허용되지 않습니다.\n그래서 입금이 취소되었습니다.\n";
else
balance += amt;
}
void AcctABC::Withdraw(double amt)
{
balance -= amt;
}
AcctABC::Formatting AcctABC::SetFormat() const
{
Formatting f;
f.flag = cout.setf(ios_base::fixed, ios_base::floatfield);
f.pr = cout.precision(2);
return f;
}
void AcctABC::Restore(Formatting& f) const
{
cout.setf(f.flag, ios_base::floatfield);
cout.precision(f.pr);
}
void Brass::Withdraw(double amt)
{
if (amt < 0)
cout << "마이너스 입금은 허용되지 않습니다.\n그래서 인출이 취소되었습니다.\n";
else if (amt <= Balance())
AcctABC::Withdraw(amt);
else
cout << "인출을 요구한 금액 $" << amt << "가 현재 잔액을 초과합니다.\n그래서 인출이 취소되었습니다.\n";
}
void Brass::ViewAcct() const
{
Formatting f = SetFormat();
cout << "Brass 고객: " << FullName() << endl;
cout << "계좌 번호: " << AcctNum() << endl;
cout << "현재 잔액: $" << Balance() << endl;
Restore(f);
}
BrassPlus::BrassPlus(const string& s, long an, double bal, double m1, double r) : AcctABC(s, an, bal)
{
maxLoan = m1;
owesBank = 0.0;
rate = r;
}
BrassPlus::BrassPlus(const Brass& ba, double m1, double r) : AcctABC(ba) // 암시적 복사 생성자를 사용된다.
{
maxLoan = m1;
owesBank = 0.0;
rate = r;
}
void BrassPlus::ViewAcct() const
{
Formatting f = SetFormat();
cout << "BrassPlus 고객: " << FullName() << endl;
cout << "계좌 번호: " << AcctNum() << endl;
cout << "현재 잔액: $" << Balance() << endl;
cout << "당좌 대월 한도: $" << maxLoan << endl;
cout << "상환할 원리금: $" << owesBank << endl;
cout.precision(3);
cout << "당좌 대월 이자율: " << 100 * rate << "%\n";
Restore(f);
}
void BrassPlus::Withdraw(double amt)
{
Formatting f = SetFormat();
double bal = Balance();
if (amt <= bal)
AcctABC::Withdraw(amt);
else if (amt <= bal + maxLoan - owesBank)
{
double advance = amt - bal;
owesBank += advance * (1.0 + rate);
cout << "당좌 대월 금액: $" << advance << endl;
cout << "당좌 대월 이자: $" << advance * rate << endl;
Deposit(advance);
AcctABC::Withdraw(amt);
}
else
cout << "당좌 대월 한도가 초과되어 거래가 취소되었습니다.\n";
Restore(f);
}
추상화 기초 클래스의 사용은,
컴포넌트 설계자가 인터페이스 약정을 만드는 것을 허용한다.
이 인터페이스 약정은 ABC로부터 파생되는 모든 컴포넌트들이 최소한 ABC가 규정하는 공통 기능을 지원한다는 것을 보장한다.
상속과 동적 메모리 대입
2가지 경우에 대해서 조금 더 자세하게 보면,
파생 클래스가 new를 사용하지 않는다.
class baseDMA
{
private:
char * label;
int rating;
public:
baseDMA(const char * l = "null", int r = 0);
baseDMA(const baseDMA & rs);
virtual ~baseDMA();
baseDMA & operator=(const baseDMA & rs);
};
생성자가 new를 사용할 때,
요구되는 특별한 메서드들은 파괴자 / 복사 생성자 / 오버로딩 대입 연산자를 가지고 있다.
class lacksDMA : public baseDMA
{
private:
char color[40];
public:
...
};
lacksDMA 클래스를 위해 명시적 파괴자, 복사 생성자, 대입 연산자를 정의해야 할까? No
lacksDMA 멤버들은 어떤 특별한 동작도 요구하지 않는다고 가정했기 때문에, 디폴트 파괴자만 있으면 충분하다.
디폴트 복사 생성자는 멤버별 복사를 하는데, 멤버별 복사는 동적 메모리 대입에서는 사용할 수 없다.
그렇지만 lacksDMA 멤버에 대해서는 멤버별 복사를 사용할 수 있다.
멤버별 복사는 데이터형에 맞게 정의된 복사 형식을 사용한다.
그러나 클래스 멤버 또는 상속받은 클래스 성분을 복사하는 것은, 그 클래스의 복사 생성자를 통하여 이뤄지므로
lacksDMA 클래스의 디폴트 복사 생성자는 lacksDMA 객체의 baseDMA 성분을 복사하기 위해
명시적인 baseDMA 복사 생성자를 사용한다.
lacksDMA 클래스의 복사 생성자는 새로운 lacksDMA 멤버에 대해 사용할 수 있고, 상속받은 baseDMA 객체에 대해서도 사용할 수 있다.
대입에 대해서도 동일한 논리가 적용된다.
마찬가지로 디폴트 대입 연산자만 있으면 충분하다.
흔히 자주 쓰이는 string 객체는 표준 string 클래스가 동적 메모리 대입을 사용한다.
이것이 문제가 일어나지 않는 이유는 대입 연산자가 string 대입 연산자를 사용하여 객체의 ~ 멤버를 대입하고,
string을 쓰는 클래스는 자동적으로 string 파괴자를 호출하게 되기 때문이다.
파생 클래스가 new를 사용한다.
class hasDMA : public baseDMA
{
private:
char * style; // 생성자에서 new 사용한다면
public:
...
};
이 경우에서는 파생 클래스를 위한 명시적 파괴자, 복사 생성자, 대입 연산자를 당연히 정의해야 한다.
파생 클래스의 파괴자는 기초 클래스 파괴자를 자동으로 호출한다.
hasDMA 파괴자는 style 포인터에 의해 관리되는 메모리를 해제해야 누수가 발생하지 않는다.
그리고 hasDMA 파괴자는 label 포인터에 의해 관리되는 메모리를 해제하기 위해서 baseDMA 파괴자에 의존할 수 있다.
baseDMA::~baseDMA() // baseDMA 관련 처리
{
delete[] label;
}
hasDMA::~hasDMA() // hasDMA 관련 처리
{
delete[] style;
}
복사 생성자에 대해서는
strlen() 사용해서 string의 필요한 길이를 알아내고 충분한 메모리를 대입한다.
baseDMA::baseDMA(const baseDMA& rs)
{
label = new char[std::strlen(rs.label) + 1]
std::strcpy(label, rs.label);
rating = rs.rating;
}
hasDMA 복사 생성자만이 hasDMA 데이터에 접근할 수 있어서,
hasDMA 생성자는 그 데이터의 baseDMA 성분을 다루기 위해서는 baseDMA 복사 생성자를 호출해야 한다.
hasDMA::hasDMA(const hasDMA& hs)
: baseDMA(hs) // 기초 클래스 복사 생성자를 호출한다.
{
style = new char[std::strlen(hs.style) + 1];
std::strcpy(style, hs.style);
}
멤버 초기자 리스트가 baseDMA 생성자에 hasDMA 참조를 전달하는 것을 확인할 수 있다.
그런데 hasDMA 참조형을 매개변수로 사용하는 baseDMA 생성자가 없다면??
문제없음.
baseDMA 복사 생성자가 baseDMA 참조형의 매개변수를 사용하고 있고,
기초 클래스 참조가 파생형을 참조할 수 있기 때문에
그래서 baseDMA 복사 생성자가 hasDMA 매개변수의 baseDMA 부분을 사용해서 새 객체의 baseDMA 부분을 생성하게 된다.
대입 연산자의 고려도 중요한데
대입할 때에는 자기 자신인지 체크하고,
기존에 메모리를 점유하고 있던 부분들은 해제하고 다시 만들어서 복사해야 한다.
baseDMA& baseDMA::operator=(const baseDMA& rs)
{
if (this == &rs)
return *this;
delete[] label;
label = new char[std::strlen(rs.label) + 1];
std::strcpy(label, rs.label);
rating = rs.rating;
return *this;
}
hasDMA& hasDMA::operator=(const hasDMA& rs)
{
if (this == &rs)
return *this;
// 파생 클래스의 명시적 대입 연산자는
// 상속받은 baseDMA 기초 클래스 객체를 위한 대입을 처리할 수 있어야 한다.
baseDMA::operator=(rs); // 기초 클래스 부분을 복사한다.
delete[] style;
style = new char[std::strlen(rs.style) + 1];
std::strcpy(style, rs.style);
return *this;
}
hasDMA 역시 동적 메모리 대입을 사용하기 때문에, 명시적인 대입 연산자를 구현해야 한다.
하지만, hasDMA 메서드이므로 baseDMA에 접근할 수 없는데
파생 클래스의 명시적 대입 연산자는, 상속받은 baseDMA 기초 클래스 객체를 위한 대입을 처리할 수 있어야 한다.
baseDMA::operator=(rs);
기초 클래스 부분을 복사한다.
동적 메모리 대입과 프렌드를 사용하는 부분
파생 클래스들이 기초 클래스의 프렌드에 접근할 수 있는 테크닉
강제 데이터형 변환을 사용해서 원형을 일치시키고 올바른 함수를 선택할 수 있도록 하는 것이다.
baseDMA 프렌드 함수
friend std::ostream& operator<<(std::ostream& os, const baseDMA& rs); // 선언부
std::ostream& operator<<(std::ostream& os, const baseDMA& rs) // 구현부
{
os << "상표: " << rs.label << std::endl;
os << "등급: " << rs.rating << std::endl;
return os;
}
hasDMA 프렌드 함수
friend std::ostream& operator<<(std::ostream& os, const hasDMA& rs); // 선언부
std::ostream& operator<<(std::ostream& os, const hasDMA& rs) // 구현부
{
// hasDMA 클래스의 프랜드이기 때문에 style 접근 ok
// baseDMA 클래스에 대해서는 프랜드가 아니기 때문에 접근할 수 없으나
// 강제 데이터형 변환을 사용해서 원형 일치로 프랜드 함수를 호출해서 접근할 수 있다.
// operator<<(ostream&, const baseDMA&)
os << (const baseDMA&)rs;
os << "스타일: " << rs.style << std::endl;
return os;
}
어느 정도 상속에 대한 개념이 잡힌 것 같다.
꾸준한 반복으로 익숙해져 보자.