강의/computer science

[computer science] c++의 class와 inheritance(상속), class substitution, virtual function, dynamic binding

하기싫지만어떡해해야지 2024. 10. 18. 15:05

이 게시글은

서울대학교 데이터사이언스대학원

조요한 교수님의

데이터사이언스 응용을 위한 컴퓨팅 강의를

학습을 위해 재구성하였습니다.


이번 시간에 정리해 볼 내용은

c++에서의 class와 상속

그리고 virtual function에 관한 내용이다

 

 

교수님께서 포켓몬을 이용한 ..

예제를 들고오셨다 ㅋㅋ

 

c++로 이런 귀여운 애들에 대한

class를 만들어보자

 

각각의 attribute들과

method들은 위와 같다

 

 

class design은 다음과 같다

우선 Pokemon이라는 base class를 만든다

base class에는

name, hp, typed이라는 attributes가 있고

attack, decreaseHp, getHp, getName이라는

methods가 있다

 

그리고 Pokemon을 상속받은 class로

Pikachu와 Charmander를 만들어준다

 

Pikachu 클래스에는

pikachu만 갖고있는 electricLevel이라는

attribute와 cry라는 method가 있고

 

Charmander class에는

flameLevel이라는 attribute가 있다

 

 

 

Base Class의 기본적인 구현이다

constructor를 public으로 정의했고

기본적인 getter 메소드가 정의되어있다

 

 

함수 구현을 자세히 보자

여기서 attack method를 잘보면

input으로 opponent라는 Pokemon class의

reference를 받는 것을 알 수 있고

본인의 name과 상대방의 name을

가져와서 출력한다

 

이 Input에는 Pokemon을

상속받은 class도 들어갈 수 있다

 

reference를 넣어주기때문에

copy를 하지않아 메모리 효율을 높일 수 있다

 

 

 

Pokemon을 상속받은 Pikachu 클래스의

구현을 자세히보자

 

class Pikachu : public Pokemon

와 같이 정의해서

Pokemon이란 class를

Pikachu가 상속받을것임을 정의해준다

 

이렇게되면 Pikachu는

Pokemon의 모든 attribute를 상속받는다

 

하지만 electricLevel은

Pikachu만 갖는 attribute이니

따로 정의해준다

 

constructor에서 Pokemon을 먼저

초기화해준다

이 때 반드시 Pokemon을

initailizer안에 초기화해야한다

 

constructor내부에 하면

이미 초기화가 된 후에

초기화를 하는거라 안된다

 

 

상속받을 때의 접근제어자에 대해 알아보자

 

public으로 상속을 받으면

base class의 level과 똑같이

해주겠다는 뜻이다

 

따라서 Pokemon class에서

public -> public

protected -> protected

private -> private

이렇게 상속받게된다

 

protected로 받으면

base class의 Level보다

한 단계 높인 level로 상속받겠다는 뜻이다

 

따라서

public -> protected

protected -> private

private -> private

이 된다

 

마지막으로 private으로 상속받으면

모든 member를 private으로 가져오게된다

 

 

접근 제어자를 설정해주지않으면

default로 private를 설정해준다

 

또, base class에 이미 있는

attribute를 pikachu에서 재정의해주면

그건 서로 다른 attribute가 된다

 

 

아까 위에서 말했지만

derived class의 constructor에 대해서

다시 설명해보려고한다

 

parent class를 initialize 내부에서

초기화를 해주어야하고

constructor 내부에서 부를 수 없다

 

왜냐하면 constructor의 body에서는

이미 parent class가

초기화가 된 상태이기때문에

다시 부를 수가 없기 때문이다

 

만약 parent class의

constructor를 부르지 않는다면

compiler는 base class의

default constructor를 부르게된다

 

destructor의 호출은

derived class에 정의된

destructor가 먼저 호출된 뒤

parent class의 destructor가 호출된다

 

 

constructor와 destructor가

호출되는 순서이다

 

상속받은 class가 constructed될 때

parent class의 constructor가

먼저 불린 다음

상속받은 class의 constructor가 호출된다

 

이게 당연한게

상속받은 class는

부모 클래스로부터 상속을 받기때문에

부모 클래스가 생성되지않는다면

자식 클래스는 생성될 수가 없다

(부모없이는 자식이 있을 수가 없..)

 

destructor의 경우는

자식 class의 destructor가 호출되면

자식 class의 destructor가 먼저 호출된 뒤

parent class의 destructor가 호출된다

 

 

Pikachu에서 attack 함수를

정의해보자

 

만약 input으로 들어온

다른 Pokemon의 type이 FIRE라면

상대의 hp를 eletricLevel-2만큼 감소시킨다

 

옆에 짤이 아주 귀엽다

 

 

base class에 없는 메소드도

새로 정의가 가능하다

 

base class에는 없는

cry라는 함수를

Pikachu에 정의해보자

 

교수님 말씀에 따르면

피카츄는 Pika Pika하고 운단다

apparently..

 

Charmander class도

pikachu와 비슷하게 정의해준다

 

짤이 아주 구ㅣ염뽀짝하다

 

 

이제 main함수에서

어떻게 활용하며

어떻게 동작하는지 살펴보자

 

위 코드를 output을 찍어본 결과이다

 

pikachu와 charmander에서

정의해준 method들이 호출되는 것을

확인해줄 수 있다

 

base class의 method는

호출되지 않은 것을 확인할 수 있다

(출력이 안되어서)

 

dervied class에서

specific한 method들만

호출이 된 것을 확인할 수 있다

 

 

 

base class의 method를 쓰고싶다면

scope operation을 이용해서

base class의 method를

위 코드와 같은 방법으로 가져오면된다

 

상속을 받을 때도

예외 케이스가 있다

 

아래의 4개

constructor, destructor,

copy assignment operator,

move assignment operator

는 상속이 되지 않는다

 

 

그렇다면 이제

base class와 구현해준

child class들의 memory layout을

한 번 확인해보자

 

 

우선 base class에 있는 attribute들이

derived class에도 먼저 쌓인 다음

electricLevel, flameLevel과 같은

개별 attribute가 쌓인 걸 확인할 수 있다

 

그리고 pikachu와 charmander같은 경우는

원래라면 36바이트가 되어야하지만

40바이트를 차지하고 있는 것을

확인할 수 있었다

4바이트 정도 padding값이 있단걸

추측해볼 수 있다

 

 

기본적으로 메모리는

연속적으로 할당된다

 

memory block에는

먼저 선언이 된 것부터 순서대로

할당이 되기 때문에

 

derived class에서도

base class에 있는

attribute들이 먼저 할당된 뒤

다른 추가적인 attribute들이

할당된다

 

cpu가 메모리에 접근할 때

1바이트씩 가져오지 않는다

한 번 메모리에 접근할 때

8바이트 정도로 여러개를 가져온다

 

따라서 cpu가 접근할 때

메모리 접근 효율을 높여주기 위해서

compiler가 그 단위에 따라 padding과 같은

공간을 할당한다

이를 memory alignment라고

한다고 한다

 

이런 memory alignment는

compiler에 따라

다르게 구현될 수도 있다

 

 

Class Substitution

 

 

base class를 가지고

derived class의 시작 주소를

갖게 할 수가 있다

 

그래서 Pokemon을 정의한 다음

Pikachu의 method에 접근하게 할 수가 있다

 

하지만 Pikachu에만 있는

base class에는 없는

method들에는 접근을 못하고

base class에 정의된

method들에만

접근할 수 있다

 

그 이유를 한 번 잘 살펴보자

pointer변수는

메모리주소의 시작부분을 갖고있는데

그럼 시작부분에서

어디까지 가져와야하는지를

어떻게 알까

 

그건 바로 변수 타입을 보고 안다

 

pointer변수의 타입이

int이면 int의 크기만큼

접근해서 주솟값을 가져오고

string이면 string 크기만큼

접근해서 가져온다

 

따라서 Pokemon이라는

class의 타입으로 지정된

pointer 변수는

Pokemon타입의 사이즈만큼

주솟값을 가져오게된다

 

따라서 Pikachu class에

Pokemon이 갖고있지 않은

method나 attribute는

Pokemon class의

attribute와 method들보다

더 뒤의(?) 공간에 할당이 되기 때문에

Pokemon class의 size만큼 가져오면

그 이후의 Pokemon에 있는

attribute와 method들밖에 접근할 수가 없는 것이다

 

이런걸 class substitution이라고 한다

 

 

위의 Class Substitution을

구현한 예제 코드를 잘보자

 

createPokemon에서

실제로 생성되는 object는

pikachu나 charmander지만

반환값은 Pokemon의

포인터임을 확인할 수 있다

 

 

그럼 위와 같은 방식으로

Pikachu와 Charmander를

생성한 다음

함수들이 어떻게 작동하는지 ㅣ확인해보자

 

Pokemon 포인터를 생성한 다음

Pikachu와 Charmander의

시작 주소를 가리키도록 했다

 

그런 다음

attack을 시전하고

hp의 양을 출력하게 해줬다

 

그랬더니 출력값으로

base class에 있는 attack이 호출되고

hp도 줄어들지 않은 것을

확인할 수 있었다

 

그럼 우리는 base class인

Pokemon의 포인터를 사용하면서

우리가 사용하고 싶은 method는

Pikachu와 Charmander에서

재정의해준 method라는 것을

어떻게 알릴 수 있을까?

 

 

Virtual Function

 

그걸 가능하기 위해 나온게

바로 virtual function이라는

매우 중요한 개념이다

 

virtual function이란

base class의

member function을

정의해줄 때 사용하는 것인데

base class에서 정의된

virtual function들은

derived class에서

override해서 사용할 수 있는

함수들을 말한다

 

기본적으로 함수 반환값앞에

virtual라고 정의해주며

 

derived class에서

저 함수를 override 할 수 있도록

만들어주기위해서

base class의 함수들에

virtual를 붙이는 것이다

 

그런 다음 derived class로 가서

재정의하는 함수 뒤에

override라고 명시해주면

compiler는 이를 확인하고

부모 class에 가서 virtual함수를 확인한다

 

이는 객체지향(OOP)에서

아주 중요한 내용인

Polymorphism(다형성)의 개념인데

 

상속관계에 있는 class들이 있을 때

같은 이름을 가진 method들이

따로따로 정의가 되어있을 때

method를 호출당한 오브젝트가

뭔지 파악해서

그 오브젝트에 특화된 implementation을

호출하도록 해주는 것이다

 

 

이렇게 base class의 attack function을

virtual로 정의해주고

다시 main에서 호출해주면

 

이제 Pikachu는 Pikachu에서 정의된

attack 함수가

Charmander는 Charmander에서 정의된

attack함수가 호출되며

hp가 깎이는 것을 확인할 수 있다

 

 

destructor는 virtual로

설정해주는 것이 안전하다

 

destructor를 virtual로 설정해주지않으면

부모의 destructor가 호출이 된다

그럼 우리가 의도한대로

free가 안될 수도 있다

 

이전 내용에서는

operation overloading에 대해서

배웠었는데

override와 overload가

어떤 점에서 다른건지 알아보자

 

우선 overriding은

derived class가 base class에 있는

virtual function에 대해서

derived class specific한

implementation method를

제공해주는 것이다

 

이는 부모 클래스에 있는

메소드와 자식 클래스에 있는 메소드가

정확히 동일한 function signature를

갖고있어야한다
(이름, 인자, 반환값이 모두 동일해야함)

 

이는 주로 상속관계에서 적용이 되고

base class의 행위를

derived class에서 다르게 해주는 것이 목적이다

 

 

이제 overloading을 한 번 보자

overloading은 같은 scope 안에 있으면서

함수들의 이름이 동일하지만 인자가 다를때

발생한다

 

원래대로라면 인자가 다르다면

함수의 이름을 다 다르게 써줘야하는데

그 과정이 귀찮으므로

인자가 달라도 함수 이름을 같게 써주는 것을

허용해주는 것이다

 

override가 상속관계라면

overload는 수평적인 관계에서

동일한 scope이 있을 때 작용하고

 

서로 다른 인자를 갖는

동일한 이름의 함수가 있을 때

그 함수를 인자에 따라서 여러 가지 버전을 만들어

사용할 수 있게 해주는 것이 주요 목적이다

 

 

Dynamic Binding

Dynamic Binding이란

base class의 포인터로

derived class의 메소드에 접근할 때

runtime과정에서

접근하는 과정을 말한다

 

 

다시 클래스를 잘 보자

Pokemon 안에는

세 개의 virtual function이 있다

 

pikachu와 charmander 모두

attack과 evolve를 상속받았다

 

 

이제 저 위와 같은 구조의

상속관계에서

각 메소드들이 어떻게 호출되는지를 보자

 

우선 virtual function을

썼기 때문에

각 class들에는 vptr이라는

포인터가 추가된다

 

그리고 그 vptr이 갖고있는

메모리 주소에는

vtable이 저장되어있는데

vtable은 각각의 object에

정의되어있는 method들의

주소를 갖고있는 table이다

 

pikachu와 charmander에서

useItem 메소드는

override를 해주지 않았으므로

base class의 method를

가리키고 있는 것을 확인할 수 있다

 

 

그럼 Pokemon 포인터를 정의해서

Pikachu를 생성한 뒤

evolve 함수를 호출해보자

 

runtime에서는

무슨 일이 일어날까

 

그럼 우선 Pikachu의 vptr에 접근해서

vtable로 향한다

그런 다음 evolve 함수가

정의되어있는 주소를 찾아

Pikachu에 정의되어있는

evolve 메소드를 수행하게된다

 

 

useItem을 실행하게된다면

똑같이 vptr -> vtable로가서

useItem의 주솟값을 찾는다

이는 Pokemon의 useItem과 매핑되어있으므로

타고 들어가서 이를 수행하게 되는 것이다

 

 

다시 dynamic binding을 정리해보자면

object의 실제 type에 기반해서

runtime 때 의도한 class의 function을

implementation대로 호출할 수 있도록 해주는 것이다

 

virtual table은

function들의 코드의 위치를

갖고있는 array이고

 

virtual pointer는

vtable의 주솟값을 갖고있는

pointer이다

 

그럼 compile time에선

어떤 일이 발생할ㄲㅏ?

 

각각 virtual function이

들어있는 class에 대해서 vtable을 생성하고

맨 앞부분에 vptr이 들어갈

메모리 공간을 생성한다

 

그런 다음 vptr에는 vtable의

시작 주솟값을 넣어준다

 

runtime에선

vptr과 vtable을

dereferencing하는 과정을 통해

주소를 찾아가서 코드를 수행하게 된다

 

 

 

아까 말했던 polymorphism에 대해서

다시 짚고 넘어가보자

 

이러한 과정을 통해

object에 특화된 implementation을

호출할 수가 있는 것이다

 

runtime에 정확히 어떤 함수를

실행해야할지 정해지기때문에

이를 dynamic polymorphism이라 부르고

 

compile 단계에 정확히

어떤 함수를 실행할지 정해지게한다면

이를 static polymorphism이라고 부른다

 

 

그럼 vtable은

메모리 상 어디에 저장될까?

 

메모리 주소를 한 번 출력해봤다

 

vptr에 저장된 주솟값(vtable의 시작 주솟값)은

다른 밑의 attribute들이나 메소드들과 달리

0x102... 가 찍혀있는 것을

확인할 수 있다

 

그럼 다른 밑의 attribute, method들과는 다른

한참 앞의 어떤 메모리 공간에

저장이 되어있다는 뜻이다

 

보통 vtable은

code section에 저장된다

 

그래서 stack에 저장되는

다른 attribute, method들과는

전혀 다른 주솟값이 찍히는 것이다

 

 

시작은 포켓몬으로 귀여웠지만

뒤는 전혀 귀엽지않았던

class의 상속, virtual function, dynamic binding까지

정리 마무리.. -!