본문 바로가기

컴퓨터공학/C, C++

C,C++> 다형성

가상 함수

객체와 포인터

가상 함수란 클래스 타입 포인터로 멤버 함수를 호출할 때 동작하는 특별 함수이다. 

다형성을 구현하는 문법 기반이 가상 함수이다. 

클래스 타입 포인터와 객체와의 관계를 보자.

Human H("dsdf");
Student S("dsfsd",23432);
H=S; //가능
S=H; //에러

부모 클래스 객체가 자식 클래스 객체를 대입받는 건 가능하다. 

H에 없는 멤버는 대입에서 제외된다. 하지만 그 반대는 에러로 처리된다.

부모 객체는 자식 객체를 대입받을 수 있지만 그 반대는 안된다. 

Human H("송지희");
Student S("설희",12345);
Human *pH;
Student *pS;

pH=&H; //가능
pS=&S; //가능
pH=&S; //가능
pS=&H; //에러

 

클래스 타입 포인터끼리도 객체간 관계와 동일한 규칙이 적용된다. 

Human의 모든 멤버를 Student 객체인 S도 갖고 있기 때문에 세 번째 대입문도 가능하다. 

하지만 그 반대는 안된다. 모든 사람은 학생이 아니고 학생이 할 수 있는 행동 중 사람이 할 수 없는 행동이 있기 때문이다. 

캐스팅해서 대입하면 컴파일러가 이의 제기를 하지 않을 수 있지만 오작동할 수 있다. 

 

포인터에는 두 가지 종류 타입을 가진다. 정적 타입(Static Type)이란 포인터가 선언될 때 타입이다.

동적 타입(Dynamic Type)은 포인터가 실행 중에 가리키고 있는 대상체 타입이다. 

정적과 동적 타입이 일치하지 않은 경우는 위 예시에서 pH = &S 대입같은 경우이다. 

C에서 포인터끼리 타입이 완전히 일치할 때 대입이 허용된다. 

그러나 C++에서 상속 관계에 있는 클래스끼리 대입할 때 좌변이 더 상위 클래스 타입이면 캐스팅하지 않아도 직접 대입을 허용한다.

레퍼런스에도 그대로 적용된다. 레퍼런스도 어차피 포인터라서 같은 규칙이라고 볼 수 있다. 

 

가상함수 개념 

A클래스를 상속한 B클래스를 A클래스 타입 포인터로 가리켜서 함수를 실행하면

오버라이딩한 자식 클래스 함수가 실행이 안되고 부모 함수가 실행된다. 

왜냐하면 컴파일러가 포인터의 정적 타입을 보고 타입에 맞는 함수를 호출하기 때문이다.

다형성을 위해 부모 클래스 포인터로 만들었는데 정적 타입에 따라 멤버 함수를 선택하는 것이 아니라 가리키는 객체 타입(동적 타입)에 따라 멤버 함수를 실행하고 싶다. 

이 문제는 함수 선언문에 virtual 키워드를 붙여 가상 함수로 선언한다. 

부모 멤버 함수가 가상함수면 자식 멤버 함수도 자동으로 가상 함수가 된다. 그래서 굳이 virtual 키워드를 안 써도 되지만 분명히 표시하기 위해 양쪽에 모두 붙이는 것이 좋다. 

virtual 키워드는 클래스 선언문에만 쓸 수 있으며 함수 정의부에서는 쓸 수 없다. 

즉 가상 함수란 포인터 정적 타입이 아닌 동적 타입을 따르는 함수이다. 

 

객체를 함수 인수로 전달하거나 객체 배열을 작성할 때는 포인터로 전달하므로 개체 포인터로 멤버 함수를 호출하는 경우가 많다.

 

객체의 경우에 따라서 다른 동작을 할 수 있는 능력이 다형성의 개념이다. 

부모 클래스형 포인터로 멤버 함수를 호출할 때 비가상 함수는 포인터가 어떤 객체를 가리키는 가에 상관없이 항상 포인터 타입 클래스 멤버 함수를 호출한다. 반면 가상 함수는 포인터가 가리키는 객체 함수를 호출한다는 점이 다르다. 

따라서 파생 클래스에서 재정의하는 멤버 함수 또는 앞으로 재정의할 가능성이 있는 멤버함수는 가상으로 선언하는 것이 좋다. 

 

동적 결합

컴파일하는 시점(링크 시점)에서 어디로 갈 것인가 결정하는 결합 방법을 정적 결합(Static Binding) 또는 이른 결합(Early Binding)이라고 한다. 결합(Binding)이란 함수 호출문에 관해서 실제 호출될 함수 번지를 결정하는 걸 말한다. 

지금까지 일반 함수는 모두 정적결합이다. 그러나 가상 함수는 포인터가 가리키는 개체 타입에 따라 호출될 실제 함수가 달라진다. 컴파일 시 호출 주소가 결정되는 정적 결합으로는 정확하게 호출할 수 없다. 왜냐하면 포인터가 실행 중 어떤 타입의 객체를 가리킬 지 컴파일 중에는 알 수 없기 때문이다. 

실행 중 호출 함수를 결정하는 결합 방법을 동적 결합(Dynamic Binding) 또는 늦은 결합(Late Binding)이라고 한다.

동적 결합은 포인터나 레퍼런스로 호출할 때 동작한다. 

 

가상 함수란 무엇인가? 가상 함수란 동적 결합하는 함수이다. 

 

가상 함수 테이블 

실행 중에 호출 함수를 결정해야 한다면 객체 타입을 판별해서 이 타입에 맞는 함수를 선택해야 할 것이다. 

대부분 컴파일러는 vtable이라는 가상 함수 목록을 작성하고 각객체에 vtable을 가리키는 숨겨진 멤버 vptr을 추가하는 방식을 사용한다. 

가상 함수 테이블(vtable)이란 가상 함수 번지 목록을 가지는 일종의 함수 포인터 배열이다. 

실행 중 호출 함수를 결정하기 위해 컴파일할 때 모든 예비 동작을 미리 취해놓는 것이다. 

컴파일 속도가 느려지고 실행 파일이 커지겠지만 가상  함수 호출 속도는 빨라진다. 

동적 결합은 정적 결합보다 호출 속도가 느리다. 그리고 가상 함수를 가진 클래스별로 vtable이라는 여분 메모리를 소모하며 객체들도 vptr을 위해 4바이트씩 더 커진다. 

멤버 함수 결합 방법의 디폴트는 정적으로 되어 있으며 virtual 키워드를 쓸 때만 동적 결합한다. 

자바 메소드는 모두 가상이다. 

 

가상 함수의 활용

멤버 함수가 호출하는 함수

모든 멤버 함수들에게 숨겨진 this가 전달되고 멤버 함수 내에서 멤버 참조문 앞에 암시적으로 ->this가 숨겨져 있다. 

이것은 호출 객체 포인터이므로 이 포인터로 호출되는 가상함수는 동적 결합되어야 한다. 

 

가상 파괴자

기반 클래스 파괴자는 가상으로 선언해야 한다. 

객체로 생성할 땐 문제없다. 부모 생성자가 먼저 호출되고 자신의 생성자가 실행되며 파괴될 때 반대 순서로 파괴자가 호출된다.

근데 포인터가 개입되면 다르다. new 연산자로 객체를 만들고 포인터를 부모 클래스로 대입하면 부모와 자신의 생성자가 호출되어 두 개의 버퍼를 동적으로 할당받는다.

그러나 이 포인터를 delete로 해체하면 부모 파괴자만 호출된다.  왜냐하면 포인터 타입이 부모 클래스이기 때문이다. 포인터 타입에 따라 정적 결합이 돼서 실제 파괴되는 객체는 부모 객체이다. 이렇게 되면 메모리 누수가 발생한다. 

해결법은 파괴자가 동적 결합하도록 가상 함수로 만들면 된다. 

 

함수의 가상성

가상 함수는 객체 타입에 따라 정확하게 호출된다는 장점이 있지만 비가상 함수에 비해서 느리고 더 많은 메모리를 소모한다. 각 클래스별로 가상 함수 개수 *4 만큼 vtable이 필요하고 각 객체별로 vptr이 필요하다. 또한 런타임에 간접적으로 함수를 호출하므로 함수를 고르는 시간만큼 호출 오버헤드도 있다. 그러나 우려할 정도로 오버헤드가 크지 않다. 

비가상이어도 되는 함수를 가상으로 선언해도 약간의 오버헤드가 있을 뿐 문제 없다. 따라서 생성자만 제외하고 모두 가상으로 선언해도 무관하지만 가상으로 만들 필요 없는 함수가 있다. 

다음 조건이 모두 만족되면 함수를 가상으로 선언하자.

1. 자식 클래스가 파생될 가능성이 있을 경우.

2. 파생 클래스에서 동작을 재정의할 가능성이 있는 경우.

3. 부모 클래스 타입 포인터에서 호출할 가능성이 있는 경우.

 

 

순수 가상 함수 

정의

기반 클래스가 가상 함수를 만드는 이유는 재정의하고 포인터로 호출할 때를 대비하기 위한 것이다.

가상 함수는 재정의해도 되는 함수이지 반드시 재정의해야 하는 함수는 아니다. 

순수 가상 함수(Pure Virtual Function)는 파생 클래스에서 반드시 재정의해야 하는 함수이다. 

함수 동작을 정의하는 본체를 가지지 않으며 이 상태에서는 호출할 수 없다. 

본체가 없다는 뜻으로 함수 선언부 끝에 =0을 표기한다. 

 

하나 이상의 순수 가상 함수를 가지는 클래스를 추상 클래스(Abstract Class)라고 한다. 

추상 클래스 반대 개념은 (Concrete Class)이다. 

 

객체를 만들지도 못하는 추상 클래스를 왜 만드는 걸까? 

왜냐하면 공동 조상 클래스로 파생 클래스 객체 집합을 관리할 수 있기 때문이다.  

또 다른 중요한 역할은 다형적인 함수 집합을 정의하는 것이다. 

필요한 함수 집합을 추상 클래스에 순수 가상 함수로 선언하면 이 클래스로 파생되는 클래스는 이 가상 함수를 반드시 재정의해야 한다. 

 

일반적으로 동작을 기술하는 본체를 가지지 않지만 순수 가상 함수도 본체를 가질 수 있다. 

후손이 동작하는데 공통적으로 필요한 구현이 있다면 추상 클래스의 순수 가상 함수에 코드를 미리 작성해 넣을 수 있다.

 

 


온고지신

가상 함수 테이블이나 가상 함수 같은 기능들은 언어 창시자에 의해 어느 날 뚝딱 만들어 진 것은 아니다. 

가상 함수 테이블이라는 구조는 C++ 이전에도 개발자들이 직접 만들어 사용했다. C++는 단지 이를 컴파일러가 지원하도록 언어 스펙에 포함됐을 뿐이다. 

어느 날 문득 생겨난 것은 아무 것도 없다. 이론과 솔루션은 수십 년에 걸친 사람들의 결실이다.  1980년대 말 바이러스가 세상에 알려졌다. 그러나 사실 컴퓨터가 만들어지기 전에 이미 폰 노이만은 논문을 통해 바이러스의 등장을 예고했다. 압축 기술도 실용화된 것은 1970년대이지만 1940년대에 이미 수학 이론이 정립되었다. 

프로그래밍 구문 기본인 조건문과 루프, 함수는 고급 언어에서나 정형화되어 사용하는 것이다. 그런데 이런 구조는 이미 19세기 에이다라는 여성 논문에 정리되어 있는 것이다. 플랫폼 독립 언어, XML, 분산 처리, 하이퍼스레딩 이런 기술들도 최소 10년 이상 연구 끝에 발표된 것들이다. 

자고 일어나면 새로운 기술이 쏟아져 나온다고 하며 늘상 새로운 기술을 접하고 배워야 하는 입장이다. 사실이기도 하지만 새로운 기술이 어제 없던 완전히 새로운 것일 수는 없다. 어떤 기술이라도 세상을 일거에 바꿀 수 없다. 기술 발전 속도가 빠르다고 표현하지만 다소 과장된 표현이다. 신기술을 따라잡지 못하면 낙오되는 느낌이 들고 항상 최신을 쫓느라 늘 피곤하게 살아야 할 것 같다. 그러나 최신 기술도 항상 과거와 연결되어 있다는 것을 안다면 힙겹게 유행을 따르려 할 필요가 없다. 

그보다 어떤 기술이 연구되고 있는지 현재 나와 잇는 기술의 유래는 어떠한지 과거에 관심을 가져 보자. 과거를 알면 신기술이 어떤 원리로 만들어진 것인지 알 수 있으며 발표된 기술도 쉽게 이해할 수 있다. 심지어 앞으로 어떤 기술이 나올 건지 예측할 수 있으며 스스로 세상을 주도하는 기술을 만들 수도 있다. 온고지신은 진리이다. 과거를 모르는 자 현재를 이해할 수 없으며 미래를 정복할 수 없다. 

 

 

 

 

'컴퓨터공학 > C, C++' 카테고리의 다른 글

C,C++> 예외 처리  (0) 2022.09.01
C,C++> 템플릿  (0) 2022.08.31
C,C++> 상속  (0) 2022.08.19
C,C++> 연산자 오버로딩  (0) 2022.08.18
C,C++> 캡슐화  (0) 2022.08.09