상속은 코드를 물려받는 문법이 아니다
상속을 처음 배우면 보통 이렇게 생각하기 쉽습니다.
"이미 만들어 둔 클래스가 있으니, 그 코드를 물려받아서 조금만 고쳐 쓰면 편하겠네?"
틀린 말은 아닙니다. 상속을 사용하면 기존 클래스의 멤버 함수와 인터페이스를 재사용할 수 있습니다. 하지만 이 생각만으로 상속을 쓰기 시작하면 금방 클래스 구조가 꼬입니다. C++에서 상속은 단순한 복사 붙여넣기 방지 도구가 아니라, 타입 사이의 관계를 표현하는 설계 도구입니다.
핵심 질문은 이것입니다.
"B는 A의 한 종류인가?"
영어로는 흔히 is-a 관계라고 부릅니다. 예를 들어 Circle은 Shape의 한 종류입니다. Rectangle도 Shape의 한 종류입니다. 그래서 Circle is a Shape, Rectangle is a Shape라고 말할 수 있습니다. 이런 관계라면 상속을 고려할 수 있습니다.
반대로 Car와 Engine을 생각해 봅시다. 자동차는 엔진의 한 종류가 아닙니다. 자동차는 엔진을 가지고 있습니다. 이 경우는 Car is an Engine이 아니라 Car has an Engine입니다. 이런 관계는 상속보다 포함 또는 조합으로 설계하는 편이 자연스럽습니다.
상속을 잘 쓰면 코드가 단정해집니다. 상속을 잘못 쓰면 클래스들이 서로의 내부 사정을 너무 많이 알게 되고, 작은 수정 하나가 여러 클래스를 흔드는 구조가 됩니다. 그래서 이 장에서는 문법보다 먼저 "언제 상속을 써야 하는가?"라는 감각을 잡고 시작하겠습니다.

상속이 필요한 순간
다음과 같은 상황을 생각해 봅시다. 원, 사각형, 삼각형을 다루는 프로그램을 만들고 있습니다. 각각의 도형은 면적을 구할 수 있고, 둘레를 구할 수 있으며, 이름을 출력할 수 있습니다.
처음에는 이렇게 클래스를 따로 만들 수 있습니다.
class Circle {
public:
double area() const;
double perimeter() const;
};
class Rectangle {
public:
double area() const;
double perimeter() const;
};겉보기에는 별문제가 없어 보입니다. 하지만 도형을 출력하는 함수를 만들고 싶다면 어떨까요?
void printCircle(const Circle& circle);
void printRectangle(const Rectangle& rectangle);도형 종류가 늘어날수록 함수도 계속 늘어납니다. Triangle, Polygon, Ellipse가 추가되면 출력 함수도 종류별로 만들어야 합니다. 이때 우리가 진짜 원하는 것은 "원 출력 함수", "사각형 출력 함수"가 아니라 어떤 도형이든 출력할 수 있는 함수입니다.
void printShape(const Shape& shape);이런 함수가 가능하려면 Circle과 Rectangle을 공통된 타입인 Shape로 바라볼 수 있어야 합니다. 바로 이 지점에서 상속이 등장합니다.
상속은 여러 타입에 공통 개념을 부여합니다. 원과 사각형은 서로 다르게 생겼지만, 둘 다 도형입니다. 그래서 Shape라는 기반 클래스를 만들고, Circle과 Rectangle이 그것을 상속하게 할 수 있습니다.
class Shape {
public:
double area() const;
double perimeter() const;
};
class Circle : public Shape {
};
class Rectangle : public Shape {
};아직 이 코드는 완성된 설계가 아닙니다. 하지만 중요한 감각은 드러납니다. Circle과 Rectangle은 Shape의 한 종류입니다. 따라서 Shape가 제공하는 공통 인터페이스를 공유할 수 있습니다.
is-a와 has-a 구분하기
상속을 배우는 순간부터 개발자는 유혹을 받습니다. "어? 이 클래스도 저 클래스 기능이 필요하네. 상속하면 되겠다!" 하지만 기능이 필요하다는 이유만으로 상속하면 위험합니다.
다음 문장을 입으로 읽어 보면 판단이 쉬워집니다.
| 관계 | 자연스러운 설계 | 예 |
|---|---|---|
| A는 B의 한 종류다 | 상속 후보 | Circle은 Shape다 |
| A는 B를 가지고 있다 | 포함 또는 조합 | Car는 Engine을 가지고 있다 |
| A는 B를 사용한다 | 함수 매개변수 또는 의존 관계 | Printer는 Document를 출력한다 |
예를 들어 게임 캐릭터를 설계한다고 해 봅시다. Player와 Monster는 둘 다 체력과 공격력을 가질 수 있습니다. 둘 다 게임 세계에 존재하는 캐릭터입니다. 따라서 공통 기반 클래스 Character를 고려할 수 있습니다.
class Character {
public:
void takeDamage(int amount);
bool isAlive() const;
};
class Player : public Character {
};
class Monster : public Character {
};여기서 Player is a Character, Monster is a Character는 자연스럽습니다.
하지만 Player가 무기를 가진다고 해서 Player가 Weapon을 상속하면 이상합니다.
// 좋지 않은 설계 예
class Player : public Weapon {
};플레이어는 무기의 한 종류가 아닙니다. 플레이어는 무기를 가질 뿐입니다. 이 경우는 다음처럼 멤버로 포함하는 편이 낫습니다.
class Weapon {
public:
int damage() const;
};
class Player : public Character {
private:
Weapon weapon_;
};이 차이를 구분하는 것이 상속 설계의 출발점입니다. 상속은 "기능을 훔쳐 쓰는 문법"이 아니라 "이 타입은 저 타입의 한 종류다"라고 선언하는 일입니다.
기반 클래스와 파생 클래스
상속에서 부모 역할을 하는 클래스를 기반 클래스라고 부릅니다. 영어로는 base class입니다. 기반 클래스를 상속받아 만들어지는 클래스를 파생 클래스라고 부릅니다. 영어로는 derived class입니다.
C++에서는 다음과 같이 상속을 표현합니다.
class Derived : public Base {
};: 뒤에 상속받을 클래스를 쓰고, 앞에 public을 붙입니다. 입문 단계에서는 대부분 public 상속을 사용한다고 생각하면 됩니다. public 상속은 "파생 클래스는 기반 클래스의 한 종류다"라는 의미를 가장 직접적으로 표현합니다.
간단한 예제를 보겠습니다.
#include <iostream>
#include <string>
class Character {
public:
Character(const std::string& name, int hp)
: name_(name), hp_(hp) {
}
void introduce() const {
std::cout << "I am " << name_ << ", HP: " << hp_ << 'n';
}
bool isAlive() const {
return hp_ > 0;
}
private:
std::string name_;
int hp_;
};
class Player : public Character {
public:
Player(const std::string& name, int hp, int level)
: Character(name, hp), level_(level) {
}
void showLevel() const {
std::cout << "Level: " << level_ << 'n';
}
private:
int level_;
};
int main() {
Player player("Knight", 100, 5);
player.introduce();
player.showLevel();
}Player 클래스 안에는 introduce() 함수를 직접 작성하지 않았습니다. 그런데도 player.introduce()를 호출할 수 있습니다. Player가 Character를 public으로 상속했기 때문입니다.
여기서 중요한 부분은 생성자입니다.
Player(const std::string& name, int hp, int level)
: Character(name, hp), level_(level) {
}파생 클래스 객체가 만들어질 때는 기반 클래스 부분도 함께 만들어져야 합니다. Player는 Character의 한 종류이므로, Player 객체 안에는 Character에 해당하는 부분이 들어 있습니다. 그래서 Player 생성자의 초기화 리스트에서 Character(name, hp)를 호출해 기반 클래스 부분을 먼저 초기화합니다.
객체 생성 순서는 항상 기반 클래스가 먼저이고, 그다음 파생 클래스입니다. 반대로 소멸될 때는 파생 클래스가 먼저 정리되고, 그다음 기반 클래스가 정리됩니다.
파생 클래스는 기반 클래스로 바라볼 수 있다
상속이 강력한 이유는 파생 클래스 객체를 기반 클래스 타입으로 다룰 수 있기 때문입니다.
void printCharacter(const Character& character) {
character.introduce();
}
int main() {
Player player("Knight", 100, 5);
printCharacter(player);
}printCharacter() 함수는 Character 참조를 매개변수로 받습니다. 그런데 Player 객체를 넘길 수 있습니다. Player는 Character의 한 종류이기 때문입니다.
이 변환을 흔히 업캐스팅이라고 부릅니다. 파생 클래스에서 기반 클래스 방향으로 올라간다고 해서 붙은 이름입니다. C++에서는 public 상속 관계에서 이런 변환이 자연스럽게 일어납니다.
하지만 반대 방향은 조심해야 합니다. 모든 Player는 Character이지만, 모든 Character가 Player는 아닙니다. 현실에서도 모든 강아지는 동물이지만, 모든 동물이 강아지는 아닌 것과 같습니다.
이 감각은 뒤에서 다형성을 이해할 때 매우 중요합니다. 우리는 Shape& 하나로 Circle도, Rectangle도 받을 수 있게 됩니다. 함수는 "도형"만 알면 되고, 실제로 어떤 도형인지는 실행 중에 달라질 수 있습니다.
public, protected, private 다시 보기
클래스의 접근 제어에는 public, protected, private이 있습니다. 앞 장들에서는 주로 public과 private을 사용했습니다. 상속을 배우면 protected의 의미가 드러납니다.
간단히 정리하면 다음과 같습니다.
| 접근 수준 | 외부 코드에서 접근 | 파생 클래스에서 접근 | 의미 |
|---|---|---|---|
public | 가능 | 가능 | 클래스의 공개 인터페이스 |
protected | 불가능 | 가능 | 파생 클래스에만 열어 둔 내부 요소 |
private | 불가능 | 불가능 | 해당 클래스만 직접 다루는 내부 구현 |
예제로 보겠습니다.
#include <iostream>
#include <string>
class Character {
public:
Character(const std::string& name, int hp)
: name_(name), hp_(hp) {
}
void introduce() const {
std::cout << name_ << " has " << hp_ << " HPn";
}
protected:
void reduceHp(int amount) {
hp_ -= amount;
if (hp_ < 0) {
hp_ = 0;
}
}
private:
std::string name_;
int hp_;
};
class Monster : public Character {
public:
Monster(const std::string& name, int hp)
: Character(name, hp) {
}
void hitByTrap() {
reduceHp(10); // protected 멤버 함수이므로 파생 클래스에서 호출 가능
}
};
int main() {
Monster monster("Slime", 30);
monster.introduce();
monster.hitByTrap();
monster.introduce();
// monster.reduceHp(10); // 오류: 외부에서는 protected 멤버에 접근할 수 없음
}reduceHp()는 외부에 공개하고 싶지 않은 함수입니다. 아무 코드에서나 체력을 줄일 수 있게 하면 객체의 규칙이 깨질 수 있습니다. 하지만 파생 클래스인 Monster 내부에서는 체력 감소 동작이 필요할 수 있습니다. 이런 경우 protected가 어울립니다.
그렇다고 protected를 마구 사용하면 안 됩니다. protected는 private보다 조금 열린 문입니다. 파생 클래스가 기반 클래스의 내부 사정에 의존하게 만들 수 있습니다. 기반 클래스의 protected 멤버를 바꾸면 파생 클래스들이 함께 영향을 받습니다.
그래서 기본 원칙은 여전히 같습니다.
외부에서 사용해야 하는 기능은
public에 둡니다.객체의 핵심 데이터는 가능하면
private에 둡니다.파생 클래스가 꼭 사용해야 하는 보조 기능만 신중하게
protected에 둡니다.
특히 멤버 변수를 곧바로 protected로 열어 두는 방식은 편해 보이지만 위험합니다.
class Character {
protected:
int hp_; // 파생 클래스가 마음대로 바꿀 수 있음
};이렇게 하면 파생 클래스에서 hp_ = -999; 같은 코드를 막기 어렵습니다. 체력은 0보다 작아질 수 없다는 불변식을 지키고 싶다면, 변수 자체를 열어 두기보다 protected 함수로 제한된 동작만 제공하는 편이 좋습니다.
class Character {
protected:
void setHp(int hp) {
if (hp < 0) {
hp = 0;
}
hp_ = hp;
}
private:
int hp_ = 0;
};상속을 쓰더라도 캡슐화는 사라지지 않습니다. 오히려 상속 구조가 깊어질수록 캡슐화가 더 중요해집니다.
기반 클래스의 함수 재사용하기
파생 클래스는 기반 클래스의 public 멤버 함수를 사용할 수 있습니다. 그리고 필요하다면 파생 클래스만의 함수를 추가할 수도 있습니다.
class Character {
public:
void move(int x, int y) {
x_ += x;
y_ += y;
}
void printPosition() const {
std::cout << "(" << x_ << ", " << y_ << ")n";
}
private:
int x_ = 0;
int y_ = 0;
};
class Player : public Character {
public:
void usePotion() {
std::cout << "Player uses a potion.n";
}
};
class Monster : public Character {
public:
void roar() const {
std::cout << "Monster roars!n";
}
};이제 Player와 Monster는 모두 이동할 수 있습니다.
Player player;
Monster monster;
player.move(1, 0);
monster.move(-1, 0);
player.printPosition();
monster.printPosition();공통 동작은 Character에 두고, 각 타입만의 동작은 파생 클래스에 둡니다. 이것이 상속의 기본적인 재사용 방식입니다.
하지만 여기까지는 단순히 함수를 물려받는 수준입니다. 상속이 진짜 흥미로워지는 순간은 "같은 이름의 함수를 호출했는데 실제 타입에 따라 다른 동작이 실행되는 경우"입니다. 이것을 위해 C++에는 가상 함수가 있습니다.
오버라이딩: 파생 클래스에서 동작 바꾸기
파생 클래스는 기반 클래스의 함수를 다시 정의할 수 있습니다. 이를 오버라이딩이라고 부릅니다. 단, C++에서 우리가 흔히 원하는 실행 시점의 동작 변경을 얻으려면 기반 클래스 함수에 virtual을 붙여야 합니다.
먼저 virtual이 없는 예를 보겠습니다.
#include <iostream>
class Animal {
public:
void speak() const {
std::cout << "Some soundn";
}
};
class Dog : public Animal {
public:
void speak() const {
std::cout << "Woof!n";
}
};
void makeSound(const Animal& animal) {
animal.speak();
}
int main() {
Dog dog;
dog.speak(); // Woof!
makeSound(dog); // Some sound
}조금 이상합니다. dog.speak()는 Dog의 함수가 호출됩니다. 그런데 makeSound(dog)에서는 Animal의 함수가 호출됩니다. 왜 그럴까요?
makeSound()의 매개변수 타입이 const Animal&이기 때문입니다. 함수 안에서 컴파일러는 animal을 Animal로 바라봅니다. speak()가 일반 함수라면, 호출할 함수가 컴파일 시점에 Animal::speak()로 결정됩니다.
이제 기반 클래스 함수에 virtual을 붙여 보겠습니다.
#include <iostream>
class Animal {
public:
virtual void speak() const {
std::cout << "Some soundn";
}
};
class Dog : public Animal {
public:
void speak() const override {
std::cout << "Woof!n";
}
};
void makeSound(const Animal& animal) {
animal.speak();
}
int main() {
Dog dog;
dog.speak(); // Woof!
makeSound(dog); // Woof!
}이번에는 makeSound(dog)에서도 Dog의 speak()가 호출됩니다. Animal&로 받고 있지만, 실제 객체가 Dog이기 때문입니다.
virtual은 "이 함수는 파생 클래스에서 다르게 동작할 수 있다"는 표시입니다. 그리고 override는 "나는 기반 클래스의 가상 함수를 재정의하고 있다"는 표시입니다.
override는 꼭 붙이는 습관을 들이는 것이 좋습니다. 실수를 컴파일러가 잡아 주기 때문입니다.
class Dog : public Animal {
public:
void speak() override { // 오류 가능: 기반 함수는 const인데 여기는 const가 없음
std::cout << "Woof!n";
}
};기반 클래스의 함수가 void speak() const인데 파생 클래스에서 const를 빼먹으면, 사실은 오버라이딩이 아니라 전혀 다른 함수를 만든 것이 됩니다. override를 붙이면 컴파일러가 "이건 오버라이딩이 아닌데요?"라고 알려 줍니다.
C++에서는 이런 작은 표시 하나가 큰 버그를 막아 줍니다.
가상 함수와 실행 시점 선택
가상 함수의 핵심은 호출할 함수가 실행 시점에 결정될 수 있다는 점입니다. 이 특성을 런타임 다형성이라고 부릅니다. 다형성은 "여러 형태를 가질 수 있다"는 뜻입니다.
Animal&라는 하나의 인터페이스로 Dog, Cat, Bird를 다룰 수 있다면 코드는 훨씬 유연해집니다.
class Cat : public Animal {
public:
void speak() const override {
std::cout << "Meow!n";
}
};
void makeSound(const Animal& animal) {
animal.speak();
}
int main() {
Dog dog;
Cat cat;
makeSound(dog); // Woof!
makeSound(cat); // Meow!
}makeSound() 함수는 개와 고양이를 구체적으로 알지 못합니다. 그저 Animal이면 된다고 말합니다. 실제 소리는 객체의 진짜 타입이 결정합니다.
이 구조는 매우 강력합니다. 새로운 동물 타입 Bird를 추가해도 makeSound() 함수를 수정할 필요가 없습니다. Bird가 Animal을 상속하고 speak()를 오버라이딩하면 됩니다.
class Bird : public Animal {
public:
void speak() const override {
std::cout << "Tweet!n";
}
};좋은 객체지향 설계에서는 "새 타입을 추가할 때 기존 코드를 덜 고치게 만드는 것"이 중요합니다. 가상 함수와 오버라이딩은 그 기반을 제공합니다.
도형 클래스 설계하기
이제 이 장의 중심 예제인 도형 클래스를 설계해 보겠습니다. 목표는 간단합니다.
원과 사각형은 서로 다른 방식으로 면적과 둘레를 계산합니다. 하지만 둘 다 도형입니다. 그러므로 공통 기반 클래스 Shape를 만들고, Circle과 Rectangle이 이를 상속하게 하겠습니다.
먼저 기반 클래스입니다.
#include <iostream>
#include <string>
class Shape {
public:
Shape(const std::string& name)
: name_(name) {
}
virtual ~Shape() = default;
const std::string& name() const {
return name_;
}
virtual double area() const {
return 0.0;
}
virtual double perimeter() const {
return 0.0;
}
private:
std::string name_;
};Shape는 이름을 가지고 있고, area()와 perimeter()를 제공합니다. 이 두 함수에는 virtual이 붙어 있습니다. 원과 사각형이 각자 다른 계산 방법을 가져야 하기 때문입니다.
또 하나 눈여겨볼 부분은 소멸자입니다.
virtual ~Shape() = default;기반 클래스를 가상 함수와 함께 사용할 때는 소멸자도 virtual로 만드는 습관이 좋습니다. 나중에 기반 클래스 포인터로 파생 클래스 객체를 다루는 상황에서 올바른 소멸이 필요하기 때문입니다. 지금은 "다형적으로 사용할 기반 클래스에는 가상 소멸자를 둔다" 정도로 기억해 두면 충분합니다.
이제 원을 만들어 보겠습니다.
class Circle : public Shape {
public:
Circle(double radius)
: Shape("Circle"), radius_(radius) {
}
double area() const override {
return pi_ * radius_ * radius_;
}
double perimeter() const override {
return 2 * pi_ * radius_;
}
private:
static constexpr double pi_ = 3.14159265358979323846;
double radius_;
};Circle은 Shape를 public으로 상속합니다. 생성자에서는 Shape("Circle")을 호출해 기반 클래스 부분을 초기화합니다. 그리고 원의 면적과 둘레 계산 방법에 맞게 area()와 perimeter()를 오버라이딩합니다.
사각형도 만들어 보겠습니다.
class Rectangle : public Shape {
public:
Rectangle(double width, double height)
: Shape("Rectangle"), width_(width), height_(height) {
}
double area() const override {
return width_ * height_;
}
double perimeter() const override {
return 2 * (width_ + height_);
}
private:
double width_;
double height_;
};이제 도형을 출력하는 함수를 만들 수 있습니다.
void printShape(const Shape& shape) {
std::cout << shape.name() << 'n';
std::cout << "Area: " << shape.area() << 'n';
std::cout << "Perimeter: " << shape.perimeter() << "nn";
}printShape()는 Circle도, Rectangle도 모릅니다. 오직 Shape만 압니다. 하지만 area()와 perimeter()가 가상 함수이므로 실제 객체 타입에 맞는 함수가 호출됩니다.
전체 코드는 다음과 같습니다.
#include <iostream>
#include <string>
class Shape {
public:
Shape(const std::string& name)
: name_(name) {
}
virtual ~Shape() = default;
const std::string& name() const {
return name_;
}
virtual double area() const {
return 0.0;
}
virtual double perimeter() const {
return 0.0;
}
private:
std::string name_;
};
class Circle : public Shape {
public:
Circle(double radius)
: Shape("Circle"), radius_(radius) {
}
double area() const override {
return pi_ * radius_ * radius_;
}
double perimeter() const override {
return 2 * pi_ * radius_;
}
private:
static constexpr double pi_ = 3.14159265358979323846;
double radius_;
};
class Rectangle : public Shape {
public:
Rectangle(double width, double height)
: Shape("Rectangle"), width_(width), height_(height) {
}
double area() const override {
return width_ * height_;
}
double perimeter() const override {
return 2 * (width_ + height_);
}
private:
double width_;
double height_;
};
void printShape(const Shape& shape) {
std::cout << shape.name() << 'n';
std::cout << "Area: " << shape.area() << 'n';
std::cout << "Perimeter: " << shape.perimeter() << "nn";
}
int main() {
Circle circle(3.0);
Rectangle rectangle(4.0, 5.0);
printShape(circle);
printShape(rectangle);
}실행하면 원과 사각형이 같은 printShape() 함수를 통과하지만, 계산은 각자의 방식으로 이루어집니다.
이것이 상속과 가상 함수가 만났을 때 생기는 힘입니다. 코드는 공통 인터페이스를 바라보고, 구체적인 동작은 실제 타입이 결정합니다.
기반 클래스 값으로 받으면 잘릴 수 있다
상속을 사용할 때 초보자가 자주 만나는 함정이 있습니다. 파생 클래스 객체를 기반 클래스 값으로 복사하는 경우입니다.
void printShapeBad(Shape shape) {
std::cout << shape.area() << 'n';
}이 함수는 Shape를 참조가 아니라 값으로 받습니다. 여기에 Circle을 넘기면 어떻게 될까요?
Circle circle(3.0);
printShapeBad(circle);Circle 전체가 들어가는 것이 아니라, Shape에 해당하는 부분만 복사됩니다. Circle의 radius_ 같은 파생 클래스 부분은 잘려 나갑니다. 이런 현상을 객체 슬라이싱이라고 부릅니다. 말 그대로 객체가 얇게 잘려 버리는 것입니다.
다형성을 사용하려면 기반 클래스 타입을 값으로 받지 말고 참조나 포인터로 받아야 합니다.
void printShapeGood(const Shape& shape) {
std::cout << shape.area() << 'n';
}이 함수는 객체를 복사하지 않습니다. 원래 객체를 참조합니다. 따라서 실제 객체가 Circle이면 Circle::area()가 호출되고, Rectangle이면 Rectangle::area()가 호출됩니다.
입문 단계에서는 다음 규칙을 기억하면 좋습니다.
상속 구조에서 다형성을 원하면 기반 클래스 타입을 값으로 복사하지 않습니다.
읽기만 한다면
const Base&를 사용합니다.객체가 없을 수도 있음을 표현해야 한다면 포인터를 고려합니다.
포인터와 소유권이 얽히면 이야기가 조금 더 복잡해집니다. 그 부분은 메모리 관리와 스마트 포인터를 배울 때 더 안전한 방식으로 다시 다루겠습니다.
상속을 남용하면 생기는 문제
상속은 강력하지만, 강력한 도구일수록 사용 이유가 분명해야 합니다. 상속을 남용하면 다음과 같은 문제가 생깁니다.
첫째, 클래스 관계가 딱딱하게 굳습니다. 한번 Player : public Character라고 선언하면 Player는 Character의 공개 인터페이스를 약속하게 됩니다. 기반 클래스의 설계가 바뀌면 파생 클래스도 영향을 받습니다.
둘째, 깊은 상속 계층은 이해하기 어렵습니다. BossMonster가 Monster를 상속하고, Monster가 Character를 상속하고, Character가 Entity를 상속하고, Entity가 Object를 상속한다면 어떨까요? 어떤 함수가 어디서 왔는지 찾기 어려워집니다.
셋째, 내부 구현에 의존하기 쉽습니다. 특히 protected 멤버 변수가 많으면 파생 클래스가 기반 클래스의 속을 너무 많이 알게 됩니다. 기반 클래스의 내부 표현을 바꾸는 순간 여러 파생 클래스가 깨질 수 있습니다.
그래서 현대 C++에서는 무조건 상속을 선호하지 않습니다. 오히려 많은 경우 상속보다 조합을 먼저 생각하라는 조언을 합니다.
class Position {
public:
void move(int dx, int dy);
private:
int x_ = 0;
int y_ = 0;
};
class Character {
private:
Position position_;
};Character가 Position을 상속하는 것이 아니라, Position을 멤버로 가지고 있습니다. 캐릭터는 위치의 한 종류가 아니기 때문입니다. 캐릭터는 위치를 가질 뿐입니다.
상속은 "이 타입은 저 타입의 한 종류다"라고 말할 수 있을 때 사용합니다. 그 외에는 멤버로 포함하거나, 필요한 기능을 작은 클래스로 나누어 조합하는 설계를 먼저 고려하세요.
final: 더 이상 오버라이딩하지 못하게 하기
C++에는 final이라는 키워드도 있습니다. final은 파생 클래스가 더 이상 특정 가상 함수를 오버라이딩하지 못하게 하거나, 클래스를 더 이상 상속하지 못하게 할 때 사용합니다.
class Dog : public Animal {
public:
void speak() const override final {
std::cout << "Woof!n";
}
};이렇게 하면 Dog를 다시 상속한 클래스에서 speak()를 오버라이딩할 수 없습니다.
클래스 전체에 붙일 수도 있습니다.
class FinalMonster final : public Character {
};이 경우 FinalMonster를 기반 클래스로 삼는 파생 클래스를 만들 수 없습니다.
입문 단계에서 final을 자주 쓸 필요는 없습니다. 하지만 라이브러리나 큰 프로젝트에서는 "이 지점에서 상속 확장은 막겠다"는 의도를 표현하는 데 사용할 수 있습니다. override가 "나는 재정의한다"는 표시라면, final은 "여기서 재정의는 끝이다"라는 표시입니다.
상속 설계 체크리스트
상속을 쓰기 전에 잠깐 멈추고 다음 질문을 해 보면 좋습니다.
파생 클래스는 정말 기반 클래스의 한 종류인가?
기반 클래스의 public 함수가 파생 클래스에도 자연스럽게 적용되는가?
단순히 코드를 재사용하려는 목적이라면 조합이 더 낫지 않은가?
기반 클래스의 소멸자는 다형적 사용을 고려해
virtual인가?파생 클래스에서 오버라이딩하는 함수에
override를 붙였는가?protected 멤버를 너무 많이 열어 두지는 않았는가?
이 질문들에 자연스럽게 답할 수 있다면 상속을 사용할 준비가 된 것입니다. 답이 애매하다면 상속보다 포함, 조합, 일반 함수, 템플릿 같은 다른 방법이 더 나을 수 있습니다.
정리
상속은 C++ 객체지향 프로그래밍의 핵심 도구입니다. 하지만 상속의 진짜 목적은 코드를 아끼는 데 있지 않습니다. 타입 사이의 관계를 표현하고, 공통 인터페이스를 통해 여러 객체를 같은 방식으로 다루게 하는 데 있습니다.
이번 장에서 배운 내용을 정리하면 다음과 같습니다.
상속은
is-a관계를 표현할 때 사용합니다.has-a관계라면 상속보다 포함이나 조합이 자연스럽습니다.기반 클래스는 공통 인터페이스를 제공하고, 파생 클래스는 이를 확장하거나 재정의합니다.
protected는 파생 클래스에는 열려 있지만 외부에는 숨겨진 접근 수준입니다.virtual함수는 실제 객체 타입에 따라 실행 시점에 호출될 함수를 선택하게 합니다.오버라이딩할 때는
override를 붙여 실수를 줄입니다.다형성을 사용하려면 기반 클래스 값을 복사하지 말고 참조나 포인터로 다룹니다.
상속은 마치 무대의 배역표와 같습니다. 관객은 "도형"이라는 역할만 알아도 됩니다. 무대에 오른 배우가 원인지 사각형인지는 실행 중에 드러납니다. 같은 대사 area()를 말해도, 원은 원의 방식으로, 사각형은 사각형의 방식으로 답합니다.
다음 장에서는 이 흐름을 더 밀고 나가 추상 클래스와 인터페이스를 다룹니다. 그때부터 기반 클래스는 단순한 부모 클래스가 아니라, 파생 클래스들이 반드시 지켜야 할 약속의 형태로 바뀌기 시작합니다.
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.
