본문 바로가기
언어/C++

[C++ 기본 공부정리] 17-1. OOP 다형성 - 가상 함수(virtual function)

by 민-Zero 2020. 1. 9.

공부 내용을 정리하는 목적 이므로 참고용으로만 읽어 주시기 바랍니다.

틀린 부분에 대한 지적은 감사합니다.

1. 가상 함수(virtual function)란?

가상 함수는 순수 가상 함수(pure virtual class)와 일반 가상 함수(virtual class)로 구분되어 사용된다. 둘 다 가상 함수 이기 때문에 기본 개념은 같고 사용 방법의 차이만 있다. 가상 함수의 기본 개념은 다음과 같다.

가상함수를 사용하게 되면 부모 클래스에서 선언한 함수가 자식 클래스에서 재정의 될 수 있다고 알려주게 되어 기본 클래스 타입의 포인터 또는 참조를 통해 자식 클래스의 객체를 참조하여 해당 객체에 대한 함수를 실행할 수 있게 된다.

 

일반 가상 함수와 순수 가상 함수를 비교해보면,

일반 가상 함수(virtual function)는 부모 클래스에서 선언한 메소드를 자식 클래스에서 재정의해서 사용해도 된다는 가능성을 열어두는 것이다. 앞의 오버라이딩에서 했던 것처럼 부모 클래스에서도 함수의 기능이 정의되어 있고 필요하다면 기능을 확장하거나 변경하는데 virtual 키워드를 통해 포인터 타입이 아닌 참조하는 객체의 메서드를 사용하기 위해 붙이게 된다.

순수 가상 함수(pure virtual function)는 자식 클래스에서 반드시 재정의해서 사용해야 하는 메소드를 뜻한다. 따라서 부모 클래스에서 함수의 기능이 정의되어 있지 않으며 자식 클래스에서 재정의하지 않으면 사용이 불가능하다.

 

문법)

class 클래스명{

    virtual 멤버 함수의 원형;    //일반 가상 함수

    virtual 멤버 함수의 원형 = 0;    //순수 가상 함수

};

부모 클래스에서 virtual을 통해 함수를 선언하면 자식 클래스에서 재정의한 함수 또한 자동으로 virtual 함수가 되기 때문에 자식 클래스에서 virtual을 생략해도 되지만 가상 함수임을 명시하기 위해 자식 클래서에서도 virtual 키워드를 붙여주는 것이 좋다.  

 

가상 함수와 순수 가상 함수를 예시를 통해 확인하자.

앞의 오버라이딩에서 보여주었던 주의할 점과 같은 예시이다. 다음과 같이 Car 클래스와 SuperCar 클래스를 선언하고 Car 클래스 타입의 포인터 변수에 SuperCar 클래스로 만든 객체의 주소를 담아주면 SuperCar의 Speed() 함수를 호출할 것 같지만 그렇지 않고 포인터 타입에 해당하는 클래스의 함수를 호출하는 것을 확인할 수 있다. 이를 해결하기 위해 가상 함수를 사용한다.

일반 가상 함수를 통해 Speed라는 함수가 자식클래스에서 재정의되어 사용될 수 있다고 알려주기 때문에 Car 타입의 포인터 변수로 scar의 객체의 주소를 담아 사용하면 그때부터 해당 객체의 메소드를 사용할 수 있게 된다.

 

이번엔 순수 가상 함수의 예를 확인하면

부모 클래스에서 순수 가상 함수로 선언되어 있기 때문에 자식 클래스에서 함수를 재정의 하지 않을 경우 사용할수 없는 것을 확인할 수 있다.

따라서 자식클래스에서 순수 가상 함수로 선언된 함수는 무조건 재정의하여야 하며 일반 가상 함수와 똑같이 Car타입의 포인터 변수로 자식 객체를 담아 사용할 수 있게 된다.

순수 가상 함수와 일반 가상 함수의 기능적인 차이는 없으며 설계하는 프로그래밍에 맞게 골라서 사용하면 된다.

 

※추가

override, final 키워드가 c++ 11 이후로 추가되어있다. 이는 상속을 좀 더 정교하게 하기 위해 추가된 키워드로 virtual과 함께 사용한다.

override 키워드는 오버라이드 한 함수라는 것을 명시적으로 컴파일러에게 알려주기 위해 사용하는 키워드이다.

final 키워드는 이 키워드를 사용한 함수까지만 상속을 진행하고 더 이상 상속을 진행하지 않게 하기 위한 키워드이다.

 

각 키워드를 예시를 통해 확인하면

만약 A 클래스를 상속받는 B클래스를 상속받아 IntFunc라는 함수를 오버라이딩하려고 했는데 매개변수를 int가 아닌 double로 사용했다고 하자. 그럼 함수가 오버라이딩되지 않지만 문법적으로는 아무런 문제가 없기 때문에 오류가 발생하지 않는다.

하지만 override 키워드를 사용하면 컴파일러에게 이 함수는 오버로딩하여 사용하는 함수라고 명시적으로 알려주기 때문에 오버라이딩을 통해 재정의 되지 않았다면 오류를 보여주게 된다. 즉 문법적으로는 오류가 아니지만 원래 의도와 다른 실수를 컴파일러가 잡아줄 수 있도록 도와주게 하는 키워드이다.

final을 함수에 사용하면 해당 함수를 더 이상 상속하여 재정의하지 못한다. 따라서 C는 B클래스를 상속받는대 InfFunc이 final로 선언되어 있으므로 해당 함수를 재정의 할 수 없게 된다. final을 통해 좀 더 간단하게 상속을 차단할 수 있게 되었다.   

 

2. 동적 바인딩(dynamic binding)

virtual을 사용하면 포인터 타입에 따른 함수가 아니라 저장한 객체의 함수를 호출하도록 할 수 있는 것을 확인하였다. 이에 대한 이유는 C++의 컴파일러는 함수를 호출하려면 어떤 스코프 안에 있는 함수를 호출해야 하고 해당 함수가 할당된 메모리 주소도 알아야 한다. 그래야 해당 함수가 호출되었을 때 해당 함수의 시작 주소로 점프할 수 있기 때문이다.

이처럼 함수를 호출할 때 어떤 스코프의 함수인지 메모리를 할당하는 것을 바인딩(binding)이라고 한다.

 

이 바인딩은 2가지로 나뉘는데 정적 바인딩(static binding) 또는 초기 바인딩(early binding)이라고 부르는 것 과

동적 바인딩(dynamic binding) 또는 지연 바인딩(late binding)이다.

-정적 바인딩(static binding) : 대부분의 함수를 호출하는 코드는 컴파일 시에 필요한 함수를 바인딩하여 사용한다. 가상 함수가 아닌 멤버 함수는 모두 정적 바인딩을 하게 된다.

-동적 바인딩(dynamic binding) : 가상 함수는 런 타임(Run time) 시에 함수들을 바인딩해 사용한다.

 

즉, 정적 바인딩은 컴파일할 때 함수를 바인딩하여 실행 중에 필요하면 그 함수의 메모리를 참조하여 사용하는것이다. 동적 바인딩은 런 타임(실행시간)에 함수를 바인딩해 사용한다. 쉽게 말하면 프로그램 실행중 함수가 필요하기 전에 미리 바인딩하고 함수가 호출되면 미리 바인딩한 주소를 찾아간다. 

 

C++에서는 함수의 오버로딩으로 인해 이 바인딩 작업이 복잡해진다. 일반적인 멤버 함수의 경우 동일한 함수명이 없기 때문에 컴파일 시 정적 바인딩을 하게 되는데 오버로딩을 위해 가상 함수를 사용하게 되면 컴파일러는 어떤 함수를 호출해야 하는지 미리 알 수 없다. 따라서 런 타임에 올바른 함수가 실행될 수 있도록 동적 바인딩을 진행하게 된다.

물론 가상 함수라고 무조건 동적 바인딩을 진행하지는 않는다. 타입이 분명할 때에는 정적 바인딩을 하지만 부모 클래스 타입 포인터나 참조를 통해 호출될 때 동적 바인딩을 진행한다.

 

동적 바인딩을 좀 더 확인해 보자.

간단한 클래스 A를 하나 생성하는데 하나는 일반 멤버 함수일 경우 다른 하나는 그 멤버 함수를 virtual로 선언하였다. 그때 해당 클래스로 생성한 객체의 메모리 크기를 보면 동일한 함수를 사용했는데 다른 것을 확인할 수 있다. 이는 virtual로 선언된 함수가 있을 경우 동적 바인딩을 진행해야 할 수 있기 때문에 프로그램 실행 시 따로 함수의 메모리 주소를 함수 호출 전에 미리 담아놓을 가상 함수 테이블(virtual function table)을 생성하고 해당 테이블을 가리키는 포인터를 만들기 때문이다. 가상 함수 테이블에 해당 클래스의 가상 함수들의 주소가 담기게 되고 가상 함수를 호출하게 되면 테이블에 접근해 필요한 함수의 주소를 찾아 호출하게 된다.

따라서 부모 클래스 타입의 포인터 변수를 사용해도 참조하는 객체에 따른 함수를 접근할 수 있게 된다.

 

지금까지 확인한 것처럼 가상 함수를 사용하면 함수의 호출 과정이 복잡해져 메모리와 실행 속도가 일반적인 멤버 함수보다 나빠지게 된다. 따라서 모든 virtual이 동적 바인딩을 사용하지는 않고 필요한 경우에만 사용하게 된다.

 

3. 가상 소멸자(virtual destructor)

virtual을 이용해 포인터로 클래스를 이용하는데 만약 동적 할당을 이용해 할당한 객체를 가리키는 경우도 있을 것이다. 이때 주의해야 할 점은 소멸자를 반드시 virtual로 선언해야 한다.

 

일반적인 상속의 경우에 클래스의 생성자와 소멸자의 호출 과정을 살펴보면

자식 클래스 객체를 생성하면 먼저 부모 클래스의 멤버가 전달되어야 하므로 부모 클래스의 생성자가 호출되고 그다음 자식 클래스의 생성자가 호출된다. 소멸자는 자식클래스가 먼저 호출되고 그 다음 부모 클래스의 소멸자가 호출된다.

 

그런데 부모 클래스의 포인터로부터 자식 클래스를 호출하는 경우에 가상 함수로 정의하지 않은 함수를 오버로딩하여 사용하는 경우 부모 클래스의 멤버 함수가 호출되는 것을 확인했다. 따라서 소멸자도 자식 클래스에서 오버라이딩된 함수라고 볼 수 있기 때문에 만약 부모 포인터로 객체를 이용하고 소멸자가 호출되면 부모 클래스의 소멸자만 호출된다. 

그러므로 동적 할당을 사용하고자 하면 반드시 소멸자도 가상 함수로 선언해 자식 클래스의 소멸자도 호출될 수 있도록 해주어야 한다.

 

댓글