나만의 작은 도서관

[C++][Class] 오버라이딩과 가상 함수 본문

C++/문법 및 메소드(STL)

[C++][Class] 오버라이딩과 가상 함수

pledge24 2025. 3. 26. 01:33

오버라이딩(overriding)이란?

오버라이딩 예시

  • 오버라이딩은 기반 클래스에 정의된 함수를 파생 클래스에서 “재정의”하는 것을 의미한다.
  • 오버라이딩한 함수는 부모 클래스의 함수와 다른 함수로 취급한다.
    • 시그니처가 같아도 다른 클래스에 정의되어 있으면 서로 다른 함수로 취급하기 때문.
  • 객체는 같은 시그니처를 가진 함수가 여러 개 정의되어 있는 경우, 가장 가까운 함수를 호출한다.
    • 즉, 오버라이딩한 파생 클래스의 함수를 우선적으로 호출한다.

 

오버라이딩 하는 법

  • 오버라이딩하는 방법은 간단하다. 기반 클래스에 정의한 함수의 시그니처와 일치하는 함수를 파생 클래스에 재정의하면 된다.
#include <iostream>

using namespace std;

class Base {
public:
    void ShowString() {cout << str << '\\n';}

private:
    string str = "Base"; // 같은 이름의 변수
};

class Derived : public Base {
public:
    /* 함수 재정의(overriding) */
    void ShowString() {cout << str << '\\n';}

private:
    string str = "Derived"; // 같은 이름의 변수
};

int main(void){
    Derived derived;
    derived.ShowString(); // 실행 결과: Derived
    return 0;
}

 

 

위 방식의 문제점 : 다형성 지원 X

  • 위 예시처럼 오버라이딩 하면 다형성이 지원되지 않는 문제가 있다.
💡 다형성(polymorphism) 하나의 메서드를 호출했음에도 불구하고 여러 가지 다른 작업들을 하는 것을 말합니다.
  • 다형성이 지원되지 않는 경우, 여러 종류의 파생 클래스들을 업캐스팅하여 하나의 자료 구조로 관리할 때(vector <Base>와 같이) 파생 클래스의 멤버 함수를 호출하는 것이 굉장히 까다로워진다.
// 다형성 지원 O
vector<Animal*> animals = {new Dog(), new Cat(), ...};
for(Animal* animal : animals)
	animal.speak(); // Dog::speak(), Cat::speak() 등 파생 클래스의 멤버 함수 호출
	
// 다형성 지원 X
vector<Animal*> animals = {new Dog(), new Cat(), ...};
for(Animal* animal : animals){
	/* animal.speak()는 Animal::speak()만 호출함 */
	// 해결 방법)
	// 1. animal이 원래 어떤 파생 클래스였는지 파악
	// 2. 해당 클래스 멤버 함수 호출
}

 


가상 함수(virtual Function)

virtual void func1(); // 가상 함수 선언
  • 가상 함수는 다형성을 지원하는 함수로, 호출 시 해당 객체의 기존 클래스 타입에 오버라이딩한 함수를 실행한다.
    • 기존 클래스에 오버라이딩 하지 않았다면 기존 클래스 기준으로 “가장 가까운 위치에 정의된 함수”를 실행한다.
  • 가상 함수 호출 시 실행될 함수는 "런타임 시점"에 결정된다.
    • 이와 같이 실행될 내용이 컴파일 시점이 아닌 런타임 시점에 결정되는 것“동적 바인딩(dynamic binding)”이라고 부른다. (반대로, 일반 함수처럼 컴파일 시점에서 실행될 내용이 결정되는 것“정적 바인딩(static binding)”이라고 부른다.)
  • 가상 함수는 멤버 함수 앞에 virtual 키워드를 붙여 정의한다.
    • 참고로, 가상 함수의 virtual은 "가상 세계에서는 상황에 따라 다른 모습을 가질 수 있다"는 개념을 가져왔을 뿐 실제로 존재하지 않는 함수를 의미하는 것이 아니다. 멀쩡히 존재하는 함수이며, 실행도 문제없이 가능하다.
  • 가상 함수를 파생 클래스에서 오버라이딩한 함수는 자동으로 가상 함수로 취급된다.
    • 즉, 파생 클래스에서 virtual 키워드를 생략해도 함수는 여전히 가상 함수이다.
class Derived : public Base
{
/* 함수 재정의(overriding) */
/* virtual이 생략되어 있을뿐 가상 함수인건 똑같다*/
/*virtual*/ void func1();
}

 

 

가상 함수 예시

#include <iostream>
#include <vector>

using namespace std;

class Animal {
public:
    virtual void show() {cout << "Animal" << '\\n';}
};

class Dog : public Animal {
public:
    virtual void show() {cout << "Dog" << '\\n';}
};

class Cat : public Animal {
public:
    virtual void show() {cout << "Cat" << '\\n';}
};

int main(void){
    vector<Animal*> animals = { new Dog(), new Cat() };

    for (Animal* animal : animals)
        animal->show();  // 동적 바인딩 적용

    return 0;
}

 

 

실행 결과

Dog
Cat

 

 

다형성을 위해 모든 함수를 가상 함수로 선언한다면?

  • 딱히 문제 될 건 없다고 한다. 실제로 자바는 모든 함수가 기본값으로 가상 함수가 선언된다.
  • 그럼에도 불구하고 C++ 이 가상 함수를 사용자가 “직접” 선언하도록 한 이유는 가상 함수 선언 시 약간의 오버헤드가 발생하기 때문이다. (C++은 성능을 중요시하기 때문에 오버헤드 발생은 최대한 줄이려고 한다.)

가상 함수 테이블(vtable)

  • 가상 함수 테이블은 가상 함수가 하나라도 정의된 클래스에 대해, 컴파일러가 자동으로 생성하는 테이블이다.
  • 가상 함수 테이블은 가상 함수 호출 시 실행할 함수를 결정할 때 사용한다.

 

가상 함수 테이블을 이용한 가상 함수 구현 원리

  1. vtable 생성 (컴파일 타임에 실행)
    출처. https://cosyp.tistory.com/228
    • 가상 함수가 하나라도 정의된 클래스에 대해, 컴파일러가 자동으로 vtable을 생성한다.
    • vtable은 현재 클래스에서 사용가능한 모든 가상 함수(기반 또는 현재 클래스에 정의한 가상 함수)와 가상 함수를 호출했을 때 실행할 함수 주소를 매핑한 데이터를 저장한다. vtable을 만드는 과정은 다음과 같다.
      • 기반 클래스의 vtable 생성 과정
        • 현재 클래스에 정의한 가상 함수에 대한 매핑 정보를 vtable에 추가한다.
      • 파생 클래스의 vtable 생성 과정
        • 기반 클래스의 vtable을 그대로 복사해 온다.
        • 파생 클래스에서 오버라이딩했다면, 오버라이딩한 함수가 실행되도록 vtable에서 해당 함수의 매핑 정보를 갱신한다.
        • 파생 클래스에서 새로운 가상함수를 정의했다면, 해당 함수의 매핑 정보를 vtable 마지막 부분에 추가한다.
  2. vptr(가상 함수 테이블 포인터) 추가 (런타임에 실행)
    • 객체 생성 시 vptr(가상 함수 테이블 포인터)을 추가한다. vptr은 해당 객체 타입의 vtable을 가리킨다.
  3. 동적 바인딩 (런타임에 실행)
    출처. https://cosyp.tistory.com/228

    • 객체의 멤버 함수를 호출했을 경우, 호출한 함수의 타입에 따라 다음과 같이 처리한다.
      • 일반 함수 호출 시: 추가 과정 없이 현재 객체 타입에 정의된 함수를 호출한다.
      • 가상 함수 호출 시: vptr을 따라 vtable에 매핑된 함수 주소를 찾아 실행한다.

 

 

추가 내용 : vptr은 항상 메모리 할당 시 객체 타입의 vtable을 가리킨다.

  • 동적 할당은 상관없지만, 정적 할당의 경우 static_cast <Base>(derived)와 같이 업캐스팅을 해도 임시 객체의 타입이 Base이므로 임시 객체의 vptr은 Base의 vtable을 가리킨다.
    • 참고로, 업캐스팅으로 자식 클래스의 추가 정보가 사라지는 것을 "객체 잘림(Object Slicing)"이라고 부른다.
#include <iostream>

using namespace std;

class Base {
public:
    virtual void show() {cout << "Base" << '\\n';}
};

class Derived : public Base {
public:
    void show() override {cout << "Derived" << '\\n';}  
};

int main(void){
    Derived derived;
    static_cast<Base>(derived).show(); // 실행 결과 : Base

    return 0;
}

 


override 키워드(C++11)

virtual void func1() override;
  • override 키워드는 기반 클래스의 가상 함수를 파생 클래스에서 오버라이딩할 때 사용하는 키워드이다.
  • override 키워드는 반드시 적어줘야 하는 키워드는 아니다. 하지만 아래 2가지 역할을 하므로 최대한 적어주는 것이 좋다.
    1. 실수 방지
      • override 키워드를 적어주면 기반 클래스에 정의한 가상 함수 시그니처와 다를 경우 컴파일 오류를 발생시킨다. 이는 사용자가 오버라이딩을 의도했지만 시그니처 실수로 일반 가상 함수를 정의하는 상황을 방지할 수 있다.
    2. 가독성 향상
      • override 키워드를 적어주면 “해당 함수는 오버라이딩 함수임”을 명시적으로 표현할 수 있다.
class Base {
public:
    virtual void show(int x) {}  // 가상 함수 정의
};

class Derived : public Base {
public:
		/* 함수 재정의(overriding) */
    void show() override {} // 컴파일 오류 : 기반 클래스와 함수 시그니처 불일치.
};

 

 

final 키워드(C++11)

virtual void func1() final;
  • final 키워드는 해당 가상 함수가 더 이상 파생된 클래스에서 오버라이딩될 수 없도록 금지할 때 사용한다.
  • final 키워드는 override 키워드와 비슷하게 1) 실수를 방지하고, 2) 가독성을 높이는 역할을 한다.
class Base {
public:
		/* 오버라이딩 금지 선언*/
    virtual void show() const final { std::cout << "Base Final Message\\n"; }
};

class Derived : public Base {
public:
    void show() const override { // 컴파일 오류 : final 함수는 재정의 불가.
        std::cout << "Derived Message\\n";
    }
};

 


가상 소멸자

일반 소멸자의 문제점

  • 객체를 소멸시킬 경우, 소멸자는 현재 객체 타입을 기준으로 소멸자 → 기반 소멸자 → 기반의 기반 소멸자 → … 순으로 호출된다.
  • 여기서 유의해야 할 점은 소멸자가 “현재 객체 타입을 기준으로” 호출된다는 것이다.
    • 즉, 동적 할당한 객체를 업캐스팅한 상태에서 소멸시킬 경우, 기존 객체 타입의 소멸자가 호출되지 않아 동적 할당한 메모리가 해제되지 않는, 즉, 메모리 누수가 발생한다. (동적 할당한 객체는 소멸자가 호출되어야 해당 객체의 메모리가 해제된다.)

+) 참고로 정적 할당한 객체는 위 문제와 관련 없다. 왜냐하면 정적 할당한 메모리들은 리턴 시 전부 자동으로 해제되기 때문. (소멸자도 문제없이 호출된다.)

 

 

일반 소멸자 문제점 예시

#include <iostream>

using namespace std;

class Base {
public:
    Base() { std::cout << "Base 생성\\n"; }
    ~Base() { std::cout << "Base 소멸\\n"; }
};

class Derived : public Base {
public:
    Derived() { std::cout << "Derived 생성\\n"; }
    ~Derived() { std::cout << "Derived 소멸\\n"; }
};

int main() {
    Base* derived = new Derived();  // 업캐스팅
    delete derived;  // 동적 메모리 해제
}

 

 

실행 결과

Base 생성
Derived 생성
Base 소멸

 

 

메모리 누수 문제를 해결하는 가상 소멸자

  • 가상 소멸자는 위와 같이 소멸자가 제대로 호출되지 않는 문제를 해결하기 위해 사용한다.
  • 가상 소멸자는 소멸자 앞에 virtual 키워드를 붙여 사용하며, 최상위 클래스 한 곳에만 적용하면 된다.

가상 소멸자 예시

#include <iostream>

using namespace std;

class Base {
public:
    Base() { std::cout << "Base 생성\\n"; }
    virtual ~Base() { std::cout << "Base 소멸\\n"; }
};

class Derived : public Base {
public:
    Derived() { std::cout << "Derived 생성\\n"; }
    ~Derived() { std::cout << "Derived 소멸\\n"; }
};

int main() {
    Base* derived = new Derived();  // 업캐스팅
    delete derived;  // 동적 메모리 해제
}

 

 

실행 결과

Base 생성
Derived 생성
Derived 소멸
Base 소멸

 

 

가상 소멸자 동작 과정

  1. 업캐스팅된 객체를 delete로 삭제
    • delete에 의해 객체의 소멸자가 호출된다.
  2. 동적 바인딩(가상 소멸자인 경우)
    • 소멸자가 가상 소멸자이므로, 현재 객체의 vtpr이 가리키는 vtable에 가서 매핑된 함수를 실행한다.
      • vptr은 객체의 원래 타입의 vtable을 가리키므로, 실행할 함수는 객체의 원래 타입 소멸자가 된다.
  3. 소멸자 호출 시 현재 객체가 관리하는 메모리를 정리한 다음, 기반 클래스의 소멸자를 호출한다.
  4. 최상위 기반 클래스가 호출될 때까지 3번 과정을 반복한다.

순수 가상 함수

virtual void func2() = 0; // 순수 가상 함수 선언
  • 순수 가상 함수는 “무엇을 하는지 정의하지 않은 가상 함수”를 의미한다. 정의가 없는 가상 함수이므로, 상속 시 반드시 오버라이딩 해야 하는 함수이다.
  • 순수 가상 함수는 기존 가상 함수 뒤에 ‘ = 0’을 붙여 선언한다.
  • 순수 가상 함수는 인터페이스 역할을 하는 것이 주목적이다. 클래스는 순수 가상 함수를 통해 파생 클래스들에게 인터페이스를 넘겨줌으로써 구현을 강제한다.

 

추상 클래스(Abstract Class)란?

추상 클래스 예시

  • 추상 클래스는 순수 가상 함수를 1개 이상 포함하고 있는 클래스를 의미한다.
  • 상 클래스의 경우, 해당 클래스 타입으로 객체를 만들 수 없다(C++ 자체에서 금지). 만약 사용자가 추상 클래스 객체를 생성하려 한다면 컴파일 오류가 발생한다.
class A
{
public:
	virtual void func2() = 0;
};

int main(void)
{
	A a; // 컴파일 에러: 추상 클래스 형식 "A"의 개체를 사용할 수 없습니다.
}
  • 하지만 추상 클래스로 업캐스팅하는 것은 가능하므로, 서로 다른 파생 클래스들을 추상 클래스로 업캐스팅하여 사용할 수 있다. (다형성 지원)
#include <iostream>
#include <vector>

using namespace std;

class Animal {
public:
    virtual void show() = 0; // 순수 가상 함수
};

class Dog : public Animal {
public:
    void show() override {cout << "Dog" << '\\n';}
};

class Cat : public Animal {
public:
    void show() override {cout << "Cat" << '\\n';}
};

int main(void){
    vector<Animal*> animals = { new Dog(), new Cat() };

    for (Animal* animal : animals)
        animal->show();  // 동적 바인딩 적용

    return 0;
}

 

 

실행 결과

Dog
Cat

 

 

추상 클래스의 의의

  • 일종의 "설계도" 역할을 한다.
  • 즉, 추상 클래스를 사용하면 코드를 사용하는 사람에게 “이 기능은 일반적인 상황에서 정의 내리기 힘드니 네가 직접 특수화해서 사용해라”라고 의도를 남겨줄 수 있다는 것에 의미가 있다.

참고 자료

https://kr.pinterest.com/pin/abstract-class-in-c-language--786370784919328121/

https://www.geeksforgeeks.org/function-overriding-in-cpp/

https://junk-s.tistory.com/entry/C순수-가상함수와-추상-클래스-인터페이스 interface

https://gloomystudy.tistory.com/41

https://learn.microsoft.com/ko-kr/cpp/cpp/abstract-classes-cpp?view=msvc-170

https://wn42.tistory.com/114

https://modoocode.com/210

https://modoocode.com/211

https://cosyp.tistory.com/228