본문 바로가기

상속의 개념과 구현

기반 클래스의 상태와 동작을 파생 클래스가 확장하는 상속 문법과 접근 수준·생성 순서·재정의를 익힙니다.

개와 고양이를 클래스로 만들면 이름, 나이, 정보를 출력하는 기능처럼 겹치는 부분이 많습니다. 두 클래스에 같은 코드를 복사하면 한쪽만 고치는 실수가 생기기 쉽습니다. 상속(inheritance)은 이미 있는 클래스의 멤버를 물려받아 새 클래스를 만들고, 거기에 필요한 것만 더하거나 바꾸는 방법입니다.

이 장에서는 상속과 다형성을 다룹니다. 이 글은 상속의 문법과 기반 클래스, 파생 클래스의 생성과 소멸 순서, 상속할 때의 접근 지정자, 멤버 함수의 재정의를 다룹니다. 객체 지향 프로그래밍 기초의 클래스, 접근 지정자, 생성자의 초기화 목록을 바탕으로 합니다.

C++ · PUBLIC INHERITANCE

Dog는 Animal의 계약을 이어받아 확장한다

public 상속은 “Dog는 Animal이다”라는 관계를 만듭니다. 기반의 private 상태는 직접 접근하지 않고 공개 함수로 읽습니다.

Animal 계약을 public 상속하여 확장하는 Dog원본과 같은 두 클래스 및 하나의 Dog에서 Animal로 향하는 public 상속 간선이다. 화살표는 빈 삼각형 일반화이며 실행 순서가 아니다. private name과 protected species/age, 가상 displayInfo const/소멸자, Dog의 breed/bark/재정의를 모두 유지한다.Animalprivate: nameprotected: species · agevirtual displayInfo()constvirtual ~Animal()Dogprivate: breeddisplayInfo() constoverridebark() · ~Dog() overridepublic 상속빈 삼각형은 기반을 향함name은 getName으로 읽음
Animal을 확장하는 DogDog가 Animal을 public 상속한다. Animal의 name은 private, species와 age는 protected다. Dog는 displayInfo와 소멸자를 재정의하고 breed와 bark를 추가한다.Animalprivate: nameprotected: species, agevirtual displayInfo() constvirtual ~Animal()Dogprivate: breeddisplayInfo() const overridebark()~Dog() overridepublic 상속Dog is an Animal

Animal · 기반 클래스

상태
name은 private, species와 age는 protected입니다.
가상 함수
displayInfo() const와 ~Animal()

Dog → Animal · public 상속

추가 상태·동작
private breed, bark()
재정의
displayInfo() const override, ~Dog() override

본문 예제: Dog의 displayInfo()는 Animal::displayInfo()로 공통 정보를 출력한 뒤 견종을 덧붙입니다. name은 getName()으로 읽습니다.

상속이란

물려주는 클래스를 기반 클래스(base class), 물려받는 클래스를 파생 클래스(derived class)라고 하며, 부모 클래스와 자식 클래스라고도 부릅니다. 위 그림처럼 Dog가 Animal을 public으로 상속하면 "개는 동물이다"라는 관계가 되고, Dog는 Animal이 할 수 있는 일을 모두 하면서 자기만의 멤버를 더합니다. 이런 관계를 is-a 관계라고 합니다.

파생 클래스의 객체 안에는 기반 클래스 부분이 통째로 들어 있습니다. 기반 클래스의 private 멤버도 객체 안에 있지만, 파생 클래스는 그것을 직접 쓸 수 없고 기반 클래스가 공개한 함수로 다룹니다. 생성자, 소멸자, 대입 연산자는 보통의 멤버 함수처럼 물려받는 것이 아니라, 클래스마다 따로 정해지는 규칙을 따릅니다.

더 깊이: 빈 기반 클래스는 공간을 차지할까

멤버 변수가 하나도 없는 빈 클래스도 sizeof는 적어도 1입니다. 같은 타입의 서로 다른 객체는 주소가 달라야 하기 때문입니다. 하지만 기반 클래스 부분에는 이 제한이 없어서, 빈 클래스를 상속하면 컴파일러가 그 부분이 공간을 전혀 차지하지 않게 배치할 수 있습니다. 이를 빈 기반 최적화(EBO, empty base optimization)라고 하며, 표준 배치(standard layout) 타입에서는 반드시 해야 합니다. 다만 빈 기반 클래스가 첫 멤버 변수의 타입이거나 그 타입의 기반이면, 같은 타입의 두 부분 객체는 주소가 달라야 하므로 이 최적화가 금지됩니다.

C++20은 멤버에도 같은 일을 할 수 있게 하는 [[no_unique_address]] 속성을 더했습니다. 상태가 없는 할당자나 비교 함수 객체처럼 빈 타입의 멤버에 이 속성을 붙이면 그 멤버가 공간을 차지하지 않게 배치될 수 있어, 상속을 쓰지 않고도 같은 효과를 얻습니다. 그런데 cppreference의 설명에 따르면 MSVC는 C++20 모드에서도 이 속성을 무시하고 [[msvc::no_unique_address]]를 따로 제공하며, 빈 기반 최적화 설명에 따르면 MSVC의 빈 기반 최적화도 표준의 요구를 모두 따르지는 않습니다. 그래서 메모리 배치에 기대는 코드는 컴파일러마다 sizeof를 확인합니다.

기본 형식과 예제

상속은 클래스 이름 뒤에 콜론과 상속 접근 지정자, 기반 클래스 이름을 적어 나타냅니다. 아래 형식에서 public 자리에는 protected나 private도 올 수 있는데, is-a 관계를 나타낼 때는 거의 언제나 public을 씁니다. 세 지정자의 차이는 뒤의 상속 접근 지정자 절에서 다루고, 중괄호 안에는 파생 클래스가 새로 더하거나 바꿀 멤버만 적습니다.

상속 형식
class 파생클래스 : public 기반클래스 {
    // 추가하거나 재정의할 멤버
};

다음 예제는 세 파일로 나누어 같은 폴더에 저장합니다. 함수 정의가 모두 헤더 안에 있으므로 따로 컴파일할 소스는 main.cpp 하나입니다.

Animal.h
#pragma once
#include <iostream>
#include <string>

class Animal {
private:
    std::string name;

protected:
    std::string species;
    int age;

public:
    Animal(std::string n, std::string s, int a) : name(n), species(s), age(a) {
        std::cout << "Animal constructor: " << name << " (" << species << ")\n";
    }

    virtual ~Animal() {
        std::cout << "Animal destructor: " << name << '\n';
    }

    virtual void displayInfo() const {
        std::cout << "name " << name << ", species " << species
                  << ", age " << age << '\n';
    }

    std::string getName() const { return name; }
    int getAge() const { return age; }
};
Dog.h
#pragma once
#include "Animal.h"

class Dog : public Animal {
private:
    std::string breed;

public:
    Dog(std::string n, int a, std::string b) : Animal(n, "dog", a), breed(b) {
        std::cout << "Dog constructor: " << getName() << " (" << breed << ")\n";
    }

    ~Dog() override {
        std::cout << "Dog destructor: " << getName() << '\n';
    }

    void bark() const {
        std::cout << "Woof!\n";
    }

    void displayInfo() const override {
        Animal::displayInfo();
        std::cout << "breed " << breed << '\n';
    }
};
main.cpp
#include "Dog.h"

int main() {
    Dog myDog("Max", 3, "Jindo");
    myDog.displayInfo();
    myDog.bark();
    std::cout << myDog.getName() << " is " << myDog.getAge() << " years old.\n";
}
g++ -std=c++20 -Wall -Wextra main.cpp -o dog_demo.exe
.\dog_demo.exe
Animal constructor: Max (dog)
Dog constructor: Max (Jindo)
name Max, species dog, age 3
breed Jindo
Woof!
Max is 3 years old.
Dog destructor: Max
Animal destructor: Max

myDog는 Dog가 새로 정의한 bark와, Animal에서 물려받은 getName, getAge를 모두 쓸 수 있습니다. Dog의 생성자가 name을 직접 읽지 않고 getName()을 부르는 것은 name이 Animal의 private 멤버이기 때문입니다. 반면 species와 age는 protected이므로 Dog의 멤버 함수에서는 쓸 수 있지만, main에서 myDog.age로 읽을 수는 없습니다.

생성과 소멸의 순서

아래 그림처럼 파생 클래스의 객체를 만들면 기반 클래스 부분이 먼저 만들어지고, 그다음 파생 클래스의 멤버와 생성자 본문이 실행됩니다. 객체가 사라질 때는 정확히 반대로, 파생 클래스의 소멸자가 먼저 실행되고 기반 클래스의 소멸자가 나중에 실행됩니다. 출력에서 Animal constructor가 Dog constructor보다 먼저, Dog destructor가 Animal destructor보다 먼저 나오는 이유입니다.

기반 부분을 먼저 만들고 파생 부분부터 해체한다

실제 Dog/Animal 출력과 같은 Base 생성→Derived 생성→사용→역순 소멸이며 완성 전후의 가상 호출 의미를 구분해 읽을 수 있다.

C++ · OBJECT LIFETIME

생성은 기반부터, 소멸은 파생부터

Derived 객체 생성은 Base 부분을 먼저 초기화한 뒤 Derived 부분을 초기화한다. 수명이 끝나면 파괴 순서는 정확히 반대다.

기반·파생 객체 수명의 네 역할과 메시지원 desktop의 main/Base/Derived/블록종료 네 역할과 다섯 방향 메시지를 보존한다. 정상 생성 main→Base,Base→Derived 뒤 main→Derived의 사용을 한다. 블록 종료→Derived소멸 후 Derived→Base소멸이다. 점선은 역할별 시간축이며 기반/파생 관계 화살표가 아니다. 생성자나 소멸자 내부 가상 호출을 완성된 객체의 호출과 섞지 않는다.main기반파생종료기반: Base · 파생: DerivedBase 생성Derived 생성완성된 객체의 가상 호출Derived 소멸Base 소멸위에서 아래로 시간 진행메시지 방향과 시간축 구분
기반 부분을 먼저 만들고 파생 부분부터 해체한다 main이 Derived 객체를 만들 때 Base 생성, Derived 생성, 사용, Derived 소멸, Base 소멸이 순서대로 일어나는 시퀀스. mainDerived 객체 선언Base기반 부분Derived파생 부분블록 종료자동 수명Base 생성Derived 생성가상 호출Derived 소멸Base 소멸
  1. Base 초기화

    명시한 기반 생성자 또는 사용 가능한 기본 생성자가 먼저 실행됩니다.

  2. Derived 초기화

    기반 부분이 완성된 뒤 파생 멤버와 생성자 본문이 실행됩니다.

  3. 객체 사용

    가상 호출은 완전히 생성된 객체의 final overrider를 선택합니다.

  4. 역순 파괴

    Derived 소멸자와 멤버를 먼저 정리한 뒤 Base 소멸자가 실행됩니다.

생성자·소멸자 실행 순서와 실제 객체 메모리 배치를 동일한 보장으로 오해하지 않는다.

이 예의 조건

  • 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
  • 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
원 도표의 제목·노드·관계·조건·주석
범위원문
관계·조건 설명원래 가로 그림의 main/Base/Derived/블록 종료 네 역할과 다섯 방향 메시지를 보존한다. 정상 생성은 main→Base, Base→Derived 순서이며 이후 main→Derived로 객체를 사용한다. 블록 종료→Derived 소멸 후 Derived→Base 소멸이다. 점선은 역할별 시간축이며 기반/파생 관계 화살표가 아니다. 생성자나 소멸자 내부 가상 호출을 완성된 객체의 호출과 섞지 않는다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
기반 생성

Derived보다 먼저 Base 부분이 준비된다.

  • 완료한 부분: Base
  • 미완료 부분: Derived
  • 기반 하위 객체를 먼저 초기화해야 파생 객체를 구성할 수 있다.
파생 생성과 사용

Derived까지 완성된 객체를 사용한다.

  • 완료한 부분: Base + Derived
  • 호출: 완성된 객체의 가상 호출
  • 파생 생성자가 끝난 뒤 완성된 객체의 동작을 사용할 수 있다.
역순 정리

Derived를 정리한 뒤 Base를 정리한다.

  • 소멸 순서: Derived → Base
  • 상태: 수명 종료
  • 구성한 하위 객체를 역순으로 해체한다.

기반 부분을 먼저 만들고 파생 부분부터 해체한다

실제 Dog/Animal 출력과 같은 Base 생성→Derived 생성→사용→역순 소멸이며 완성 전후의 가상 호출 의미를 구분해 읽을 수 있다.

C++ · OBJECT LIFETIME

생성은 기반부터, 소멸은 파생부터

Derived 객체 생성은 Base 부분을 먼저 초기화한 뒤 Derived 부분을 초기화한다. 수명이 끝나면 파괴 순서는 정확히 반대다.

기반·파생 객체 수명의 네 역할과 메시지원 desktop의 main/Base/Derived/블록종료 네 역할과 다섯 방향 메시지를 보존한다. 정상 생성 main→Base,Base→Derived 뒤 main→Derived의 사용을 한다. 블록 종료→Derived소멸 후 Derived→Base소멸이다. 점선은 역할별 시간축이며 기반/파생 관계 화살표가 아니다. 생성자나 소멸자 내부 가상 호출을 완성된 객체의 호출과 섞지 않는다.main기반파생종료기반: Base · 파생: DerivedBase 생성Derived 생성완성된 객체의 가상 호출Derived 소멸Base 소멸위에서 아래로 시간 진행메시지 방향과 시간축 구분
기반 부분을 먼저 만들고 파생 부분부터 해체한다 main이 Derived 객체를 만들 때 Base 생성, Derived 생성, 사용, Derived 소멸, Base 소멸이 순서대로 일어나는 시퀀스. mainDerived 객체 선언Base기반 부분Derived파생 부분블록 종료자동 수명Base 생성Derived 생성가상 호출Derived 소멸Base 소멸
  1. Base 초기화

    명시한 기반 생성자 또는 사용 가능한 기본 생성자가 먼저 실행됩니다.

  2. Derived 초기화

    기반 부분이 완성된 뒤 파생 멤버와 생성자 본문이 실행됩니다.

  3. 객체 사용

    가상 호출은 완전히 생성된 객체의 final overrider를 선택합니다.

  4. 역순 파괴

    Derived 소멸자와 멤버를 먼저 정리한 뒤 Base 소멸자가 실행됩니다.

생성자·소멸자 실행 순서와 실제 객체 메모리 배치를 동일한 보장으로 오해하지 않는다.

이 예의 조건

  • 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
  • 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.

관계도의 보충 설명

원 도표의 제목·노드·관계·조건·주석
범위원문
관계·조건 설명원래 가로 그림의 main/Base/Derived/블록 종료 네 역할과 다섯 방향 메시지를 보존한다. 정상 생성은 main→Base, Base→Derived 순서이며 이후 main→Derived로 객체를 사용한다. 블록 종료→Derived 소멸 후 Derived→Base 소멸이다. 점선은 역할별 시간축이며 기반/파생 관계 화살표가 아니다. 생성자나 소멸자 내부 가상 호출을 완성된 객체의 호출과 섞지 않는다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
기반 생성

Derived보다 먼저 Base 부분이 준비된다.

  • 완료한 부분: Base
  • 미완료 부분: Derived
  • 기반 하위 객체를 먼저 초기화해야 파생 객체를 구성할 수 있다.
파생 생성과 사용

Derived까지 완성된 객체를 사용한다.

  • 완료한 부분: Base + Derived
  • 호출: 완성된 객체의 가상 호출
  • 파생 생성자가 끝난 뒤 완성된 객체의 동작을 사용할 수 있다.
역순 정리

Derived를 정리한 뒤 Base를 정리한다.

  • 소멸 순서: Derived → Base
  • 상태: 수명 종료
  • 구성한 하위 객체를 역순으로 해체한다.

기반 클래스의 어느 생성자를 쓸지는 파생 클래스 생성자의 초기화 목록에 Animal(n, "dog", a)처럼 적어 정합니다. 적지 않으면 기반 클래스의 기본 생성자가 쓰이는데, Animal에는 기본 생성자가 없으므로 Dog는 반드시 기반 생성자를 지정해야 합니다. 기반 부분이 먼저 준비되기 때문에 Dog의 생성자 본문에서 getName()을 쓸 수 있습니다.

기반 클래스의 생성자를 그대로 쓰고 싶다면 C++11의 using Animal::Animal;로 생성자를 물려받을 수도 있습니다. 다만 이렇게 하면 파생 클래스가 새로 더한 멤버는 기본값으로만 초기화되므로, breed처럼 꼭 정해야 하는 멤버가 있다면 생성자를 직접 정의합니다.

상속 접근 지정자

상속할 때 적는 public, protected, private은 기반 클래스의 멤버가 파생 클래스에서 어떤 접근 수준이 되는지를 정합니다.

기반 클래스의 멤버public 상속protected 상속private 상속
publicpublicprotectedprivate
protectedprotectedprotectedprivate
private접근 불가접근 불가접근 불가
inheritance_access.cpp
class Base {
public:
    int publicVar;
protected:
    int protectedVar;
private:
    int privateVar;
};

class PublicDerived : public Base {
public:
    void touch() {
        publicVar = 10;
        protectedVar = 20;
        // privateVar = 30; // 컴파일 오류
    }
};

class ProtectedDerived : protected Base {
public:
    void touch() {
        publicVar = 10;    // 이 클래스 안에서는 protected
        protectedVar = 20;
    }
};

int main() {
    PublicDerived pd1;
    pd1.publicVar = 100;    // 가능

    ProtectedDerived pd2;
    // pd2.publicVar = 100; // 컴파일 오류: 밖에서는 protected
}

어느 방식이든 기반 클래스의 private 멤버는 파생 클래스에서 쓸 수 없고, 바뀌는 것은 public과 protected 멤버가 밖에서 보이는 수준입니다. public 상속은 기반 클래스의 공개 인터페이스를 그대로 유지하므로 is-a 관계에 쓰이며, 대부분의 상속이 이 방식입니다. protected와 private 상속은 기반 클래스의 기능을 안에서만 쓰고 밖에는 감출 때 쓰는 특수한 방식이며, 이런 경우에는 뒤에서 볼 합성이 더 단순한 경우가 많습니다.

재정의와 이름 숨김

파생 클래스는 기반 클래스의 가상 함수(virtual function)를 같은 이름과 매개변수, 같은 const 여부로 다시 정의해 동작을 바꿀 수 있으며, 이것을 재정의(override)라고 합니다. Dog::displayInfo는 Animal::displayInfo()로 기반 클래스의 구현을 먼저 부른 뒤 견종을 더 출력합니다. 가상 함수가 실제로 어떤 효과를 내는지는 다음 글에서 자세히 봅니다.

재정의하는 함수에 C++11의 override를 붙이면, 컴파일러가 정말 기반 클래스의 가상 함수를 재정의하는지 검사합니다. 예를 들어 Dog의 displayInfo에서 const를 빠뜨리면 기반의 const 함수와 맞지 않아 재정의가 아니게 되는데, override가 있으면 이를 컴파일 오류로 알려 줍니다. override 없이 같은 실수를 하면 새 함수가 하나 더 생길 뿐이어서 찾기 어려운 버그가 됩니다.

파생 클래스에 기반 클래스와 같은 이름의 함수를 선언하면, 매개변수가 달라도 기반 클래스의 같은 이름 함수가 모두 가려집니다. 이것을 이름 숨김(name hiding)이라고 합니다.

name_hiding.cpp
#include <iostream>

class Printer {
public:
    void print() const { std::cout << "base\n"; }
    void print(int n) const { std::cout << "base " << n << '\n'; }
};

class FancyPrinter : public Printer {
public:
    using Printer::print;
    void print() const { std::cout << "fancy\n"; }
};

int main() {
    FancyPrinter p;
    p.print();
    p.print(5);
}
fancy
base 5

FancyPrinter가 print()를 선언하는 순간 Printer의 print(int)도 가려지므로, using Printer::print;가 없으면 p.print(5)는 컴파일 오류입니다. using 선언은 기반 클래스의 같은 이름 함수들을 파생 클래스의 범위로 다시 불러와, 새로 정의한 print()와 함께 오버로드 후보가 되게 합니다.

상속보다 합성이 나은 경우

상속은 코드를 다시 쓰는 방법이기도 하지만, 기반 클래스와 파생 클래스를 강하게 묶습니다. 기반 클래스의 호출 조건이나 동작을 바꾸면 모든 파생 클래스가 영향을 받으므로, 함께 다시 컴파일하고 동작을 확인해야 합니다. 그래서 "B는 A이다"가 자연스러운 경우에만 public 상속을 씁니다.

단지 다른 클래스의 기능을 쓰고 싶은 것이라면, 그 클래스의 객체를 멤버로 두는 합성(composition)이 더 단순합니다. 예를 들어 Car가 Engine의 기능을 쓴다면 Car가 Engine을 상속하기보다 Engine engine;을 멤버로 가지는 편이 "자동차는 엔진을 가진다"는 관계에 맞습니다. 한 클래스가 여러 기반 클래스를 상속하는 다중 상속은 다중 상속과 가상 상속에서 다룹니다.

정리

  • 상속은 기반 클래스의 멤버를 물려받아 파생 클래스를 만드는 것이며, public 상속은 is-a 관계를 나타냅니다.
  • 파생 객체 안에는 기반 클래스 부분이 있고, 기반의 private 멤버는 있지만 직접 쓸 수 없습니다.
  • 생성은 기반 클래스부터, 소멸은 파생 클래스부터 이루어지며, 기반 생성자는 초기화 목록에서 지정합니다.
  • 상속 접근 지정자는 기반의 public, protected 멤버가 파생 클래스에서 갖는 접근 수준을 정합니다.
  • 가상 함수를 재정의할 때는 override를 붙여 서명이 맞는지 검사하게 합니다.
  • 같은 이름을 선언하면 기반의 오버로드가 모두 가려지므로, 필요하면 using으로 다시 불러옵니다.

연습 문제

1. 인자 바꾸기와 const 빼기

세 파일은 그대로 두고 main의 객체만 Dog myDog("Coco", 4, "Poodle");로 바꾸면 출력은 어떻게 달라지나요? 따로, Dog::displayInfo의 const만 지우면 어떻게 되나요?

해설 보기

이름, 나이, 견종이 Coco, 4, Poodle로 바뀌고 종은 여전히 dog입니다. 생성, 정보, 짖기, 나이, 소멸 메시지의 순서는 그대로입니다. const를 지우면 Animal의 const 가상 함수와 서명이 맞지 않아 재정의가 아니게 되고, override가 붙어 있으므로 컴파일 오류가 납니다.

2. 가려진 오버로드

name_hiding.cpp에서 using Printer::print;를 지우면 p.print(5)는 어떻게 되나요? p.Printer::print(5)는 어떤가요?

해설 보기

FancyPrinter의 print()가 기반의 print를 모두 가리므로, p.print(5)는 int를 받는 후보가 없어 컴파일 오류입니다. p.Printer::print(5)는 기반 클래스의 이름을 직접 지정해 호출하므로 컴파일되어 base 5를 출력합니다.

3. protected 상속

inheritance_access.cpp에서 ProtectedDerived의 touch() 안의 publicVar = 10;과, main의 pd2.publicVar = 100;은 각각 컴파일되나요?

해설 보기

touch() 안의 대입은 컴파일됩니다. protected 상속으로 publicVar는 ProtectedDerived 안에서 protected 멤버가 되었고, 자기 멤버 함수에서는 쓸 수 있기 때문입니다. main의 pd2.publicVar는 밖에서 protected 멤버를 쓰려는 것이어서 컴파일 오류입니다.

다음 글 다형성과 가상 함수에서는 기반 클래스의 포인터나 참조로 파생 클래스의 객체를 다룰 때, 가상 함수가 실제 객체에 맞는 함수를 고르는 방법을 다룹니다.