[Study Note] C++ 가상 함수와 다형성 #164
jinlee0310
started this conversation in
Blog
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
TL;DR
이 글은 씹어먹는 C++ 6-2, 6-3을 정리 및 요약한 글입니다.
is-a와 has-a
C++에서 상속을 도입한 이유는 단순히 똑같은 코드를 또 쓰는 것을 막기 위함은 아니다.(그러한 이유도 약간은 있지만) 실제 이유는 상속이라는 기능을 통해 객체지향 프로그래밍에서 추구하는 실제 객체의 추상화를 좀 더 효과적으로 할 수 있게 되었다. C++에서 상속을 도입함으로써 클래스 사이의 관계를 표현할 수 있게 되었다.
is-a
Manager이 Employee를 상속한다.라는 의미는 Manager 클래스는 Employee의 모든 기능을 포함한다. Manager은 Employee의 모든 기능을 수행할 수 있다. 따라서 모든 Manager는 Employee이다.따라서 모든 상속 관계는
is a관계라고 볼 수 있다. 그리고 이를 뒤바꾸면 성립할 수 없다.BankAccount라는 클래스가 있고 CheckingAccout, SavingsAccount라는 클래스가 있다. 이 둘은 좀더 일반적인 BankAccount 클래스를 상속받는다.
이를 통해 알 수 있는 상속의 특징 중 하나는 파생되면 파생될수록 좀더 특수화(구체화; specialize)된다는 의미이다. 반대로 기반 클래스로 올라갈수록 일반화 된다.
Employee 클래스가 일반적인 사원을 위한 클래스였다면 Manager클래스는 일반적인 사원들 중 좀 더 특수한 부류의 사원을 의미하게 된다.
has-a
모든 클래스를 is-a 관계로만 표현할 수는 없다. 자동차 클래스를 생각해본다면 자동차 클래스를 구성하기 위해 엔진 클래스, 브레이크 클래스, 오디오 클래스 등이 필요하다. 이들 사이에서는 has-a 관계로 표현한다. (자동차 has a 엔진, 자동차 has a 브레이크 등)
EmployeeList는 Employee들과
has-a관계이다.(다시 보는)오버라이딩
이번에는 Derived의 객체 c를 Base 객체를 가리키는 포인터에 넣었다. Derived는 Base를 상속 받았기 때문에
Derived is a Base관계가 성립한다. 따라서 Base객체 타입의 포인터가 c를 가리킬 수 있다. 이러한 형태의 캐스팅(파생 클래스에서 기반 클래스로 캐스팅 하는 것)을업캐스팅이라고 한다.하지만 p의 주소는 당연히 Base이기 때문에 Derived 타입의 포인터에 할당할 수 없다.(다운캐스팅 불가)
또한 static_cast라는 키워드를 통해 강제로 다운 캐스팅을 할 수는 있지만 컴파일 타임에 오류를 찾아내기 매우 어렵기 때문에 권장하지 않는다.
dynamic_cast
C++에서는 상속 관계에 있는 두 포인터들 간에 캐스팅을 해주는
dynamic_cast라는 것을 지원한다. 사용은 static_cast와 동일하게 할 수 있지만 캐스팅이 불가능한 관계에서는 컴파일 오류를 발생시킨다.virtual
업 캐스팅을 이용해 EmployeeList를 조금 더 간단하게 만들어보고 싶지만 문제가 있다.
기존의 employee_list와 manager_list로 두개의 배열을 따로 관리했던 것에서 Employee를 가리키는 배열로 합친다고 생각해보자.(Manager is a Employee)
하지만 이 방식에서는 print_employee_info에서 print_info를 할 때 Employee의 print_info를 실행한다. 또한 calculate_pay에서도 Employee의 pay를 계산하기 때문에 manager의 calculate_pay를 사용할 수 없다는 문제가 있다.
이 문제를 해결하기 위해 virtual이라는 키워드를 사용한다. 간단한 예시를 먼저 보자.
p_p와 p_c 모두 Base 객체를 가리키는 포인터이다. 따라서 p_p->what()과 p_c->what() 모두 Base 객체의 what이 실행되어야 한다. 하지만 virtual 키워드를 사용했기 때문에 적절한 what 함수를 호출했다.
virtual 키워드는
p_c->what()을 실행할 때 다음과 같은 역할을 한다.p_p->what()을 실행할 때 다음과 같은 역할을 한다.이렇게 컴파일시 어떤 함수가 실행될 지 정해지지 않고 런타임시 정해지는 일을 가리켜
동적 바인딩(dynamic binding)이라고 부른다.p_c->what(); 에서 Derived의 what을 실행할 지 Base의 what을 실행할 지 런타임에 결정된다.
또한 virtual 키워드가 붙은 함수를
가상 함수(virtual function)라고 부른다.파생 클래스의 함수가 기반 클래스의 함수를 오버라이드 하기 위해서는 두 함수의 꼴이 같아야 한다.(이름, 인자)
override 키워드
C++에서는 파생 클래스에서 기반 클래스의 가상 함수를 오버라이드 하는 경우, override 키워드를 통해 명시적으로 나타낼 수 있다.
override 키워드를 사용하면 실수로 오버라이드를 하지 않는 경우를 막을 수 있다. 만약 기반 클래스와 형태가 다른데 override 키워드를 사용하면 컴파일 타임에 오류가 발생하면서 실수를 막을 수 있다.
EmployeeList
EmployeeList가 Employee 타입의 배열을 가지게 하고 함수에 virtual 키워드를 사용하면 처음에 발생했던 문제를 해결할 수 있다.
이렇게 같은 print_info를 호출했음에도 여러가지 다른 작업들을 하는 것을
다형성(polymorphism)이라고 부른다.virtual 소멸자
클래스의 상속을 사용함으로써 중요하게 처리해야 하는 부분이 있다. 상속 시에 소멸자를 가상함수로 만들어야 한다는 점이다. 소멸자를 가상함수로 만들지 않는다면 업캐스팅시 child 객체의 소멸자가 호출되지 않게 되고 이는 메모리 누수로 이어질 수 있다.
상속될 여지가 있는 클래스들은 소멸자를 virtual로 만들어줘야 나중에 문제가 발생하지 않게 된다.
현대에서 스마트 포인터를 사용한다고 해도 객체가 소멸될 때 스마트 포인터가 내부적으로 delete를 사용하여 객체를 소멸시키기 때문에 현대에도 상속될 여지가 있는 클래스에는 소멸자에 virtual 키워드를 쓰는 것이 좋다.
가상 함수의 구현 원리
지금까지 virtual 키워드에 대해 배운 내용을 바탕으로 이런 질문을 할 수 있다.
사실 모든 함수를 virtual로 만들어도 문제되지 않는다. 자바의 경우 모든 함수들이 디폴트로 virtual 함수로 선언된다. 하지만 C++에서 virtual 키워드를 사용하여 사용자가 선언하도록 만든 이유는 가상함수를 사용하면 약간의 오버헤드가 존재하기 때문이다.
이를 이해하기 위해 가상 함수가 어떻게 구현되는지 살펴보자.
C++ 컴파일러는 가상함수가 하나라도 존재하는 클래스에 대해서 가상 함수 테이블을 만들게 된다. 가상함수 테이블은 전화번호부라고 생각하면 된다.
위 경우 parent와 child 모두 가상 함수를 포함하고 있기 때문에 두개 다 가상 함수 테이블을 생성하게 된다.
가상함수와 가상함수가 아닌 함수와의 차이점을 살펴보자면 Child의 func3()과 같이 비 가상함수는 특별한 단계를 거치지 않고 func3을 호출하면 직접 실행된다.
가상함수를 호출하였을 때는 그림에 나온 것처럼 가상 함수 테이블을 한 단계 더 거쳐서 실제로 어떤 함수를 고를 지 결정하게 된다.
Parent* p=Parent(); p->func1();이라고 하면 컴파일러는
Parent* c=Child(); c->func1();위처럼 프로그램 실행시 가상 함수 테이블에서 func1에 해당하는 함수를 호출하는데 c가 Child를 가리키고 있으므로 Child 객체의 가상함수 테이블을 참조하여 Child::func1을 호출하게 된다. 따라서 Parent::fun1을 오버라이드한다.
두 단계에 걸쳐서 함수를 호출함을 통해 동적 바인딩을 구현할 수 있게 된다. 이러한 이유로 가상함수를 호출하는 경우 일반적인 함수에서보다 약간 더 시간이 오래 걸린다.
이 차이는 극히 미미하지만 최적화가 매우 중요한 분야에서는 감안할 필요가 있다. 이로 인해서 C++에서는 멤버함수가 디폴트로 가상함수가 되도록 설정하지 않는다.
순수 가상 함수와 추상 클래스
virtual void speak=0;이 함수는 몸통이 정의 되어 있지 않고 =0;으로 처리되어 있는 가상 함수이다. 이 함수는무엇을 하는 지 정의 되어 있지 않은 함수이고 반드시 오버라이딩 되어야 하는 함수이다. 이렇게 =0;으로 정의되어서 반드시 오버라이딩 되도록 만든 함수를순수 가상 함수(pure virtual function)라고 부른다. 순수 가상 함수는 본체가 없기 때문에 함수를 호출하는 것은 불가능하고, 그렇기 때문에 Animal 객체를 생성하는 것도 불가능하다.(컴파일 타임에 오류가 생긴다)순수 가상 함수를 최소한 한개 포함하고 있는 - 반드시 상속되어야 하는 클래스를 가리켜
추상 클래스(abstract class)라고 부른다.cf). private 함수도 오버라이드 가능하다. 자식 클래스에서 호출하지 못할 뿐.
추상 클래스는 설계도 라고 생각하면 좋다. 일반적인 상황에서는 만들기 힘드니 특수한 상황에 맞춰서 만들어서 쓰라는 의미이다. Animal의 speak 같은 경우 어떤 소리를 내는 지는 동물마다 다르기 때문에 speak 함수를 가상 함수로 만들기는 어렵다. 따라서 Animal을 상속받아 speak을 상황에 맞게 구현하게 된다.
추상 클래스는 객체를 만들 수 없지만 추상 클래스를 가리키는 포인터는 만들 수 있다.
다중 상속
C++에서는 한 클래스가 다른 여러 개의 클래스를 상속 받는 것을 허용한다. 이를 가리켜 다중 상속이라고 한다.
위 경우 클래스 C가 A, B를 동시에 상속 받고 있다.
다중 상속을 한다면 C에 A와 B의 내용이 들어간다고 생각하면 된다.
다중 상속 시 주의점
다중 상속을 올바르게 사용하기 위해서는 주의해야 할 점이 몇 가지 있다.
A와 B에 a라는 이름의 멤버 변수가 들어있고 C가 A, B를 상속 받을 때 c 객체를 생성해 a에 접근한다면 컴파일 오류가 발생한다.
또한 주의점은 다이아몬드 상속이다.
위 사진처럼 상속 관계가 이루어지는 것을 다이아몬드 상속이라고 한다. 만약 Human에 name이라는 멤버 변수가 있다고 하면 HandsomeHuman과 SmartHuman 모두에 name이 생긴다. 그러면 그걸 상속 받은 Me라는 클래스에 name이라는 변수가 겹치게 된다.
이를 해결하기 위해서는 virtual로 상속을 받을 수 있다.
이렇게 virtual로 상속 받는다면 컴파일러가 Human을 한번만 포함하도록 지정할 수 있다.
All reactions