12. 클래스의 상속(1) - 기초 클래스로 연습(상속)

WanJu Kim·2022년 12월 13일
0

C++

목록 보기
49/81

객체 지향 프로그래밍의 목적 중 하나는 재활용 할 수 있는 코드를 쓰는 것이다. 새로운 프로젝트를 개발할 때 입증이 된 이전에 쓰인 코드를 쓰면 버그도 줄일 수 있고, 개발 시간을 크게 절약할 수 있다.
또한 라이브러리에는 매우 많은 함수 혹은 클래스가 있다. 하지만 그런 코드들이 모든 프로그래머들한테 딱 알맞게 작성되지는 않았다. 가끔 우리는 그런 코드들을 우리의 입맞에 맞게 수정하고 싶을 때가 있다. 그럴 때 '상속'을 사용한다. 무일푼으로 인생을 시작하는 것보다 재산을 상속받고 시작하는 게 좀 더 편한 것처럼, 상속을 통해 클래스를 파생시키는 것이 새로운 클래스를 설계하는 것보다 훨씬 편하다. 그 기능을 알아보겠다.

상속

어떤 클래스를 다른 클래스로 상속할 때, 오리지널 클래스를 기초 클래스(base class)라 하고, 상속 받는 클래스를 파생 클래스(derived class)라 한다. 간단한 기초 클래스를 만들어 보자.

Tabtenn0.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; }
};

Tabtenn0.cpp
#include "Tabtenn0.h"
#include <iostream>

TableTennisPlayer::TableTennisPlayer(const string& fn = "none",
	const string& ln = "none", bool ht = false)
	: firstname(fn), lastname(ln), hasTable(ht)
{
}

void TableTennisPlayer::Name() const
{
	std::cout << lastname << ", " << firstname;
}

매우 간단한 클래스다. 이제 이 기초 클래스로부터 파생된 파생 클래스를 만들어보겠다.

class RatedPlayer : public TableTennisPlayer
{
	...
};

단순한 클래스 선언에 ': public 기초 클래스명'이 붙었다. 이 의미는, '왼쪽의 RatedPlayer 클래스가 TableTennisPlayer 클래스를 기초로 두고 있다.'라는 의미이다. 특별히 public을 썼으므로, 'TableTennisPlayer가 public 기초 클래스다.', 'public 파생'이라고 말할 수 있다. public 파생에서는 기초 클래스의 public 멤버들이 파생 클래스의 public 멤버가 된다. 기초 클래스의 private 부분도 접근할 수 있는데, 이는 나중에 더 자세히 다루겠다.

그래서 이제 뭘 할 수 있다는 건가? 파생 클래스 객체를 선언하면, 다음과 같은 일을 할 수 있다.
① 파생 클래스형의 객체 안에는 기초 클래스형의 데이터 멤버들이 저장된다.(파생 클래스는 기초 클래스의 구현들을 상속받는다.)
② 파생 클래스형의 객체는 기초 클래스형의 메서드들을 사용할 수 있다. (파생 클래스는 기초 클래스의 인터페이스를 상속받는다.)

파생 클래스에는 뭘 추가할 수 있을까?
① 자기 자신의 생성자.
② 부가적인 데이터 멤버들과 멤버 함수.

위의 말대로 몇 코드를 추가해보겠다.

#pragma once
#include "Tabtenn0.h"	// 기초 클래스 파일 포함.
class RatedPlayer : public TableTennisPlayer
{
private:
	unsigned int rating;
public:
	RatedPlayer(unsigned int r = 0, const char* 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; }	// 추가 메서드.
};

주의할 점은, 파생된 클래스의 생성자에는 자기 자신의 멤버 변수와 기초 클래스의 멤버 변수가 다 들어간다는 점이다. 자 이제 생성자로 변수 초기화를 해보자. 근데 기초 클래스의 private 변수에 접근을 어떻게 하나? 바로 기초 클래스의 생성자를 이용하면 된다.

RatedPlayer::RatedPlayer(unsigned int r, const char* fn,
	const string& ln, bool ht)
	: rating(r), TableTennisPlayer(fn, ln, ht)
{

}

RatedPlayer::RatedPlayer(unsigned int r, const TableTennisPlayer& tp)
	: rating(r), TableTennisPlayer(tp)
{

}

이런식으로 초기화 하면 된다. 만약 기초 클래스 생성자를 안 적으면 어떻게 되는가? 기초 클래스의 디폴트 생성자가 실행된다.

파생 클래스를 생성하면 기초 클래스의 생성자가 먼저 생성된다. 이와 반대로 파괴자는 파생 클래스 → 기초 클래스 순서로 일어난다. 이유가 궁금했는데 딱히 없다. 그냥 문법이다.

기초 클래스와 파생 클래스는 특별한 관계를 가진다.
① 이미 봤듯이, 파생 클래스 객체는 기초 클래스 메서드들이 private이 아니면 그것을 사용할 수 있다.
② 기초 클래스 포인터는 명시적 데이터형 변환 없이도 파생 클래스 객체를 지시할 수 있다.
③ 기초 클래스 참조는 명시적 데이터형 변환 없이도 파생 클래스 객체를 참조할 수 있다.
④ 기초 클래스 포인터나 참조는 기초 클래스 메서드만 호출할 수 있다.

RatedPlayer rplayer1(1140, "Mallory", "Duck", true);
TableTennisPlayer & rt = rplayer1;
TableTennisPlayer * pt = &rplayer1;
rt.Name();	// 참조 활용하여 기초 클래스 메서드 호출.
pt->Name();	// 포인터 활용하여 기초 클래스 메서드 호출.

⑤ 반대로 파생 클래스의 참조나 포인터에 기초 클래스의 객체와 주소를 대입 할 수 없다.

TableTennisPlayer player("Besty", "Bloop", true);
RatedPlayer & rr = player;	// 불가능.
RatedPlayer * pr = player;	// 불가능.

왜 그럴까? 만약 이게 된다고 생각해보자. 파생 클래스 참조로 파생 클래스 메서드를 호출하면, 기초 클래스 객체가 파생 클래스의 멤버를 가지고 있지 않기 때문에 불가능하다.

기초 클래스 포인터 / 참조가 파생 클래스의 객체를 지시 / 참조 해서 뭘 할 수 있을까? 기초 클래스 참조와 포인터를 매개 변수로 사용하는 함수는, 기초 클래스 객체, 파생 클래스 객체 둘 다 사용이 가능하다. 예를 들어 다음과 같은 함수가 있다.

void Show(const TableTennisPlayer & rt)
{
	...
}

void Wohs(const TableTennisPlayer * rt)
{
	...
}

rt는 기초 클래스에 대한 참조 / 포인터다. 따라서 rt는 기초 클래스 객체도 참조할 수 있고, 파생 클래스 객체도 참조할 수 있다. 주소도 마찬가지이다.

파생 클래스를 기초 클래스의 참조나 포인터로 변환하는 것을 '업캐스팅'이라고 부른다. 반대의 경우는 '다운캐스팅'이라고 부른다.

솔직히 좀 헷갈린다. 어떻게 생각하면 좀 쉬울까? 흔히 상속을 부모 자식간의 관계로 비유한다. 부모 - 기초 클래스, 자식 - 파생 클래스로 말이다.그럼 서로 다른 객체를 대입하는 건 어떻게 비유할까? 이렇게 생각해보자. 부모 안 받아주는 자식은 있지만, 자식 안 받아주는 부모는 없다. 이로써 부모 객체에다 자식 객체 대입은 가능하고, 그 반대는 불가능하다는 걸 좀 쉽게 외울 수 있다.👍👍

초기화도 서로 참조할 수 있다. 예를 들어 다음과 같은 코드가 가능하다.

RatedPlayer olaf1(1840, "Olaf", "Loaf", true);
TableTennisPlayer olaf2(olaf1);

위 코드에서는 암시적인 복사 생성자가 실행된다.

TableTennisPlayer(const TableTennisPlayer &)

매개 변수가 부모다. 부모는 자식 다 받아준다고 했다. 그래서 가능한 것이다. 자식의 생성자에는 부모 생성자가 있지 않은가? 그걸로만 부모 생성자를 초기화 한다. 마찬가지로 대입 연산자도 가능하다.

RatedPlayer olaf1(1840, "Olaf", "Loaf", true);
TableTennisPlayer winner;
winner = olaf1;

이런 경우에는 아마 다음과 같은 암시적인 대입 연산자 함수가 있을 것이다.

TableTennisPlayer& operator=(const TableTennisPlayer &) const;

이것도 마찬가지로 매개 변수가 부모 객체다. 부모는 다 받아준다. 하지만 반대는 불가능하다.(자식 왜 키우는 거임?)

profile
Question, Think, Select

0개의 댓글