티스토리 뷰

오버라이딩(Overriding)

동일한 이름의 함수를 설정하라

 

오버라이딩(Overriding)은 오버로딩(Overloading)과 비슷한 이름을 가졌지만 엄연히 다른 개념이다. 오버로딩은 같은 이름의 함수를 여러 개 만드는 것이고, 오버라이딩은 하나의 가상 함수를 상속 받아 동일한 함수의 내용을 사용하거나 아이에 다르게 사용할 수 있는 방법이다. 오버라이딩이 없다면 상속의 확장성의 폭은 굉장히 좁혀졌을 것이다. 

 


멤버 재정의와 정적 바인딩

이번 학습 단원에서는 정적 바인딩이 상속 관계에서 어떻게 동작하고, 부모와 자식간 참조 범위에 어떻게 관여하는지 연관성을 알아 볼 수 있다. 들어가기전, 조금 맛보기를 한다면 정적 바인딩은 컴파일 시간에 변수와 함수의 크기를 할당하며 이를 통해 각 변수와 함수가 독립된 존재로 컴파일러에게 정의될 수 있다.

 

즉, 상속 관계에서 부모와 자식이 동일한 이름의 변수와 함수가 있다면 서로 다른 독립적 관계로써 상호작용한다.

 

 

변수의 재정의

#include <iostream>

using namespace std; // std 네임스페이스 사용

class Base {
public:
    int commonVariable;

    Base(int value) : commonVariable(value) {}

    void display() {
        cout << "Base class: " << commonVariable << endl;
    }
};

class Derived : public Base {
public:
    int commonVariable; // Derived 클래스에서 Base 클래스의 commonVariable을 재정의

    Derived(int baseValue, int derivedValue) : Base(baseValue), commonVariable(derivedValue) {}

    void display() {
        cout << "Derived class: " << commonVariable << endl;
    }
};

int main() {

    Base baseObj(10);
    Derived derivedObj(20, 30);

    baseObj.display();    // Base class: 10
    derivedObj.display(); // Derived class: 30
    derivedObj.Base::display(); // Base class: 20

    // Base 클래스의 commonVariable에 접근
    cout << "Accessing Base class variable: " << baseObj.commonVariable << endl;

    // Derived 클래스의 commonVariable에 접근
    cout << "Accessing Derived class variable: " << derivedObj.commonVariable << endl;

    return 0;
}
Base class: 10
Derived class: 30
Base class: 20
Accessing Base class variable: 10
Accessing Derived class variable: 30

 

위 코드를 확인하면 부모인 Base와 자식인 Derived가 동일한 이름의 변수 commonVariable을 가지고 있다. 그리고 부모와 자식 객체를 각자 생성하고 생성자로 값을 초기화해주고, 각 객체의 commonVariable 값을 출력해보면 부모 클래스 객체는 할당 받은 값을 출력하지만, 자식 객체는 자식 클래스의 commonVariable에 할당된 값을 출력한다.

 

이렇게 보면 부모의 변수를 자식이 덮어씌운 것 같아 보인다. 하지만, 동일한 이름의 변수가 부모와 자식에게 존재한다면, 부모와 자식 변수 모두 메모리에 할당되어있다. 즉, 위 코드에서 자식 클래스 객체의 생성자가 20과 30이라는 값을 각각 부모와 자식 변수에 할당되는 것이다.

 

 

즉, 위 그림과 같이 동일한 이름의 변수는 각각 따로 사용될 수 있지만, 호출이될 때는 자식의 변수가 우선시 된다. 만약, 자식의 객체로 부모의 값이나 함수를 호출하기 위해서는 부모 객체의 값을 호출하는 것을 명시해 주어야한다.

 

부모 객체의 값/함수 명시적 호출
derivedObj.Base::commonVariable;
derivedObj.Base::diaplay();

 

 

 

자료형이 다른 동명 변수

위 과정에서 동일한 변수 명을 가진 변수들은 각자 메모리 주소를 할당 받기 때문에, 사용이 가능하다는 것을 알았다. 그렇다면 서로 다른 자료형을 가진 변수들이 동일한 이름을 가졌다면 어떻게 될것인가? 위 과정에서 각 변수마다 메모리 값이 할당된다는 것을 이해했다면, 결과를 바로 예상할 수 있을 것이다.

 

#include <iostream>

using namespace std; // std 네임스페이스 사용

class Base {
public:
    int commonVariable = 5;
};

class Derived : public Base {
public:
    double commonVariable = 10; // Derived 클래스에서 Base 클래스의 commonVariable을 재정의
};

int main() {
    Base baseObj;
    Derived derivedObj;
    
    // Base 클래스의 commonVariable에 접근
    cout << "Accessing Base class variable: " << baseObj.commonVariable << endl;

    // Derived 클래스의 commonVariable에 접근
    cout << "Accessing Derived class variable: " << derivedObj.commonVariable << endl;

    // Derived 클래스의 commonVariable에 접근
    cout << "Accessing Derived Base class variable: " << derivedObj.Base::commonVariable << endl;

    return 0;
}
Accessing Base class variable: 5
Accessing Derived class variable: 10
Accessing Derived Base class variable: 5

 

별탈이 없다. 다만, 애초에 동일한 변수 명칭을 사용하는 것 자체가 옳치않은 선택이기에 그럴 환경을 비켜가려 노력해야한다.

 

 

 

함수의 재정의

함수의 재정의도 변수의 재정의와 크게 다를 것은 없다. 단, 가상 함수의 개념이 들어오면 또 다르게 할 말이 있지만 이는 조금 있다 확인하자.

 

함수 재정의
#include <iostream>

using namespace std; // std 네임스페이스 사용

class Base {
public:
    void bFunc() {
        cout << "Base::BFunc()" << endl;
    }
};

class Derived : public Base {
public:
    void bFunc() { // 재정의
        cout << "Derived::BFunc()" << endl;
    }
};

int main() {
    Derived derivedObj;
    
    // Base 클래스의 bFunc 호출
    derivedObj.Base::bFunc();
    derivedObj.Derived::bFunc();

    return 0;
}
Base::BFunc()
Derived::BFunc()

 

 

 

상속 관계 포인터

정적 할당

포인터는 본래 자신의 자료형만을 가리킬 수 있다. 즉, int형은 int형 변수의 주소만 가질 수 있는 것이다. 그런데 다음 예시를 보자.

 

부모 포인터 할당
#include <iostream>

using namespace std; // std 네임스페이스 사용

class Base {
public:
    void bFunc() {
        cout << "Base::BFunc()" << endl;
    }
};

class Derived : public Base {
public:
    void bFunc() { // 재정의
        cout << "Derived::BFunc()" << endl;
    }
};

int main() {
    Derived derivedObj;
    Base *baseObj = &derivedObj;

    // Base 클래스의 bFunc 호출
    baseObj->bFunc();

    return 0;
}
Base::BFunc()

 

위 코드의 main 함수 안에는 Base 부모 객체 포인터가 Derived 자식 객체의 주소의 값을 받고 있다. 이처럼 더 확장된 자식 클래스를 부모 클래스의 포인터에 넣을 수 있다.

 

다시 생각해보면, 자식 클래스는 부모 클래스 하나를 할당 받아 하나의 부모를 가지지만, 부모 클래스는 모든 자식 클래스를 포용하고 있기 때문에 부모 클래스 타입을 가리키는 포인터가 자식 클래스 객체를 가리키는데 문제가 없다.

 

 

위와 같이 '트럭', '경차', '중형차'라는 카테고리는 '자동차'라는 부모에 포용되기 때문에, 자동차는 각 차종이 될 수 있지만, '트럭'이 '경차'가 될 수 없다. 단, 부모 클래스 포인터라도 자식 클래스의 함수를 호출할 수 없다. 다음 예제를 보며 확인하자.

 

부모 객체가 자식 클래스 호출
#include <iostream>

using namespace std; // std 네임스페이스 사용

class Base {};

class Derived : public Base {
public:
    void dFunc() {
        cout << "Derived::DFunc()" << endl;
    }
};

int main() {
    Derived derivedObj;
    Base *baseObj = &derivedObj;

    // Base 클래스의 dFunc 호출
    baseObj->dFunc(); // 컴파일 에러!!

    return 0;
}
main.cpp: In function ‘int main()’:
main.cpp:28:8: error: ‘class Base’ has no member named ‘dFunc’; did you mean ‘bFunc’?
   28 |     baseObj->dFunc(); // 컴파일 에러!!
      |        ^~~~~
      |        bFunc

 

위 코드에서 자식 클래스 객체의 포인터를 받은 부모 클래스 포인터가 자식 클래스의 함수를 호출하려할 때, 컴파일 에러가 발생한다. 이는 자식 클래스의 고유 멤버를 모르는 부모가 자식 클래스를 호출할 수 없기에 발생한 문제이다.

 

위 코드의 부모 클래스 객체 포인터 b는 Base의 포인터이고 Base 클래스는 dFunc이라는 멤버 함수가 존재하지 않는다. 물론 우리 눈에는 직접적으로 b가 Derived 클래스 객체 주소를 가지지만, 실제 Base에서 파생된 클래스가 Derived 외에도 다수 자료형(파생클래스)으로 존재할 수있기 때문에 Base 클래스 포인터는 Base의 멤버들만 알 수 있다. (파이썬과 같은 동적 타이핑 언어라면 가능할 수도 있다.)

 

📌 동적 타이핑 언어와 정적 타이핑 언어 
동적 타이핑 언어, 변수의 타입이 정해져 있지 않고 초기화를 할 때, 결정되는 언어로 실행 전까지 변수의 타입을 알 수 없다.
정적 타이핑 언어, 변수의 타입을 반드시 알아야하고 한 변수는 한 가지 타입만 가질 수 있다.

 

 

동적 할당

정적 할당 부분에서 우리는 C++에서 부모 객체가 자식 객체의 멤버를 호출할 수 없다는 것을 알았다. 이는 동적 할당이된 변수들에도 동일하게 동작한다.

 

동적할당
#include <iostream>

using namespace std; // std 네임스페이스 사용

class Base {
public:
    void bFunc() {
        cout << "Base::BFunc()" << endl;
    }
};

class Derived : public Base {
public:
    void bFunc() {
        cout << "Derived::BFunc()" << endl;
    }

    void dFunc() {
        cout << "Derived::DFunc()" << endl;
    }
};

int main() {
    Derived *derivedObj = new Derived;
    Base *baseObj = derivedObj; // d에 들어있는 주솟값이 b에도 들어가므로, 위에서 동적 할당한
    
    // 그 객체를 똑같이 가리키라는 뜻
    baseObj->bFunc();
    // baseObj->dFunc(); // 컴파일 에러!!!
    derivedObj->bFunc();
    
    delete derivedObj; // d와 b가 가리키고 있는 객체는 같은 객체이므로 한 번만 삭제해야 함
    
    return 0;
}
Base::BFunc()
Derived::BFunc()

 

위 코드의 결과를 확인하면, 동적할당을 받은 부모 객체는 자식 객체의 멤버 호출을 할 수 없다. 이는 정적 할당과 동일한 상황이며 "C++에서 부모는 자식 객체의 멤버를 알 수 없다."는 규칙을 준수한 것이다.

 

이러한 현상을 '정적 바인딩'(static binding)이라 할 수 있다. 정적 바인딩은 컴파일 타임에 함수나 변수의 호출이 어떤 함수나 변수에 바인딩될지 결정되는 바인딩 방식이다. 일반적으로 컴파일러가 소스 코드를 컴파일하는 단계에서 결정되며, 런타임에는 바인딩이 변경되지 않습니다.

 

정적 바인딩은 가상 함수가 아닌 경우 발생하고, 함수 오버로딩로딩에 적용된다.

 

📌 바인딩
'바인딩'이란, 메서드 호출 시 호출이 가능한 메서드 중 어떤 메서드를 실행할지 결정하는 작업이다.

 

 

결론

  1. 상속 관계에서 멤버 재정의는 자식 클래스의 멤버가 더 우위를 잡는다.
    1. 동일한 멤버 명칭을 가진다면 자식 클래스의 멤버가 호출된다.
    2. 부모 클래스의 멤버를 호출하기 위해서는 부모 클래스 멤버를 호출한다는 명칭을 명시해야 한다.
    3. 동일한 명칭의 변수가 부모와 자식에 따로 존재할 수 있는 이유는 각 멤버 변수가 각자의 주소 값을 가진 독립적 존재이기 때문에 가능하다.
  2. 부모 클래스 포인터는 자식 객체를 할당 받을 수 있다.
    1. 자식 객체를 할당 받은 부모 클래스는 자식 멤버를 사용할 수는 없다.
    2. 위 규칙은 동적/정적 할당 모두 적용된다.
  3. 정적 바인딩은 컴파일 타임에 각 멤버의 크기가 결정되어 독립적 형태로 존재할 수 있게 만드는 것이다.
    1. 멤버 함수는 독립된 주소를, 멤버 변수는 독립된 크기를 가진다.

 

 


가상 함수와 동적 바인딩

앞서 멤버와 정적 바인딩을 통해 어떤 규칙이 성립되는지 확인해 보았다. 이제 오버라이딩과 가상 함수를 통해 "동적 바인딩"을 학습하겠다. 우선 '동적 바인딩'(dynamic binding)이란, 컴파일 타임이 아닌 런타임(프로그램 실행 중)에 메서드나 변수를 바인딩하는 방법이다.

 

#include <iostream>

using namespace std; // std 네임스페이스 사용

class Base {
public:
    virtual void virtualMethod() {
        cout << "Base::virtualMethod()" << endl;
    }
};

class Derived : public Base {
public:
    void virtualMethod() override {
        cout << "Derived::virtualMethod()" << endl;
    }
};

int main() {
    Base baseObj;
    Derived derivedObj;

    // 포인터를 통한 동적 바인딩
    Base* basePtr1 = &baseObj;
    Base* basePtr2 = &derivedObj;

    basePtr1->virtualMethod(); // Base::virtualMethod() (정적 바인딩)
    basePtr2->virtualMethod(); // Derived::virtualMethod() (동적 바인딩)

    return 0;
}
Base::virtualMethod()
Derived::virtualMethod()

 

위 코드에서 이전에는 볼 수 없었던 독특한 결과가 나왔다. 정적 바인딩이었다면 분명 부모 객체는 자식의 멤버를 볼 수 없을 터인데, basePtr2 포인터가 호출한 virtualMethod는 자식의 멤버 함수를 정확하게 출력하고 있다. 이러한 상황이 가능한 것은 virtual 키워드와 가상 함수의 개념으로 '오버라이딩'(overriding)이 발생했기 때문이다.

 

컴파일러는 가상 함수를 바인딩하지 않는다. 즉, 가상 함수는 컴파일러가 어느 메서드와 바인딩할지 결정하지 않고, 실행 도중 함수 포인터가 가르키는 주소를 선택하여 해당 함수를 호출하여 실행한다. 여기서 중요한 개념이 등장한다. 바로 가상 테이블이다.

 

 

가상 테이블 (중요)

가상 테이블은 가상 함수들의 포인터가 들어있는 배열

 

가상 함수 테이블은 일반적으로 클래스의 첫 번째 가상 함수에 대한 포인터부터 시작하여 순서대로 가상 함수들의 포인터가 저장되어 있다. 객체가 생성되면, 이 가상 함수 테이블은 해당 객체의 가상 함수 포인터를 가리키게 되며, 이를 통해 실행 시간에 적절한 가상 함수가 호출된다.

 

즉, 가상 함수가 호출될 때, 해당 배열을 확인하여 원하는 가상 함수 배열의 주소를 확인하여 함수를 호출한다.

 

 

 


가상 소멸자

소멸자는 메모리 해제와 같은 객체 소멸에 마무리 작업을 진행하는 중요한 역할을 하며, 가상 소멸자는 상속 관계에서 중요한 역할을 할 수 있다.

 

가상 소멸자 설정 전
#include <iostream>

using namespace std;


class Base {
private:
    char* heap; 
public:
    Base(int size) {
    	heap = new char[size];
        cout << "Base 생성자 호출" << endl;
    }
    ~Base() {
    	delete[] heap;
        cout << "Base 소멸자 호출" << endl;
    }
};

class Derived : public Base {
private:
    char* heap;
public:
    Derived(int size, int pSize) : Base(pSize) {
    	heap = new char[size];
        cout << "Derived 생성자 호출" << endl;
    }
    ~Derived() {
    	delete[] heap;
        cout << "Derived 소멸자 호출" << endl;
    }
};

int main() {
    Base* derivedObj = new Derived(5, 10); // Derived 클래스의 객체 생성
    delete derivedObj;
    return 0;
}
Base 생성자 호출
Derived 생성자 호출
Base 소멸자 호출

 

위 코드에서 자식 객체를 Base 객체에 동적 할당 업케스팅하는 코드이다. 업캐스팅은 기본적으로 부모 객체가 자식 객체의 멤버를 모르기 때문에 Base 소멸자만 호출되는 현상이 발생했다. 이를 방지하기 위하여 사용하는 것이 가상 소멸자이며, 가상 소멸자가 선언되면 자식 클래스가 부모 클래스의 소멸자를 오버라이딩하여, 부모의 소멸자가 호출되면 자식의 소멸자도 같이 호출된다.

 

가상 소멸자 설정
#include <iostream>

using namespace std;


class Base {
private:
    char* heap; 
public:
    Base(int size) {
    	heap = new char[size];
        cout << "Base 생성자 호출" << endl;
    }
    virtual ~Base() {
    	delete[] heap;
        cout << "Base 소멸자 호출" << endl;
    }
};

class Derived : public Base {
private:
    char* heap;
public:
    Derived(int size, int pSize) : Base(pSize) {
    	heap = new char[size];
        cout << "Derived 생성자 호출" << endl;
    }
    ~Derived() override {
    	delete[] heap;
        cout << "Derived 소멸자 호출" << endl;
    }
};

int main() {
    Base* derivedObj = new Derived(5, 10); // Derived 클래스의 객체 생성
    delete derivedObj;
    return 0;
}
Base 생성자 호출
Derived 생성자 호출
Derived 소멸자 호출
Base 소멸자 호출
최근에 올라온 글
최근에 달린 댓글
«   2025/06   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30
글 보관함