강께르의 개발일지

[C++] 클래스의 상속(가상 함수와 다형성) 본문

프로그래밍/C++

[C++] 클래스의 상속(가상 함수와 다형성)

강께르 2021. 6. 16. 01:24

1. 객체 포인터 변수

- 클래스를 기반으로 포인터 변수를 선언할 수 있다. 이는 객체의 주소 값을 저장하는 포인터 변수로, 객체 포인터 변수라고 부른다.

- 만약 Parent 클래스가 있고, 그것의 자식 클래스인 Child 클래스와 그의 자식 클래스인 GrandChild 클래스가 있다고 하자.

Parent* ptr;
ptr = new Parent(); // 이런 객체 포인터가 가능하다.

ptr = new Child(); // 자식 클래스를 가리키는 것도 가능하다.

ptr = new GrandChild(); // 이보다 더 자식 클래스를 가리키는 것도 가능하다!

- Parent 객체의 포인터 변수로 선언했는데 대입되는 주소값은 그의 자식과 또 그것의 자식 클래스가 가능하다!

- 객체 포인터 변수는 다음과 같이 해석할 수 있다. Parent 클래스 포인터 변수는 Parent 클래스와 그 클래스를 직, 간접적으로 상속하는 모든 객체를 가리킬 수 있다.

- 개념적으로는 이렇게 설명한다. (하위 클래스)는 (상위 클래스)의 한 종류이다. 예를 들어서

한성 컴퓨터의 모니터는 전자 제품의 한 종류이다.

딸기는 음식의 한 종류이다.

 이렇게 설명할 수 있을 것이다. 이것이 어떻게 가능할까?

 

2. 업캐스팅

- 여기서 업캐스팅을 이해해야한다. 업캐스팅이란 자식 클래스의 객체를 부모 클래스의 포인터 변수로 가리키는 것을 말한다. 그렇게 해서 자식 클래스의 객체를 부모 클래스의 객체처럼 다룰 수 있게 하는 것이다.

- 업캐스팅을 통해서 자식 클래스의 객체여도 포인터 변수가 부모 클래스로 선언되었다면 포인터 변수의 클래스형을 따라 기본 클래스의 객체로 취급한다는 것이다.

- 이는 다형성과 관련이 있다. 하나의 이름으로 다양한 모양으로 해석될 수 있는 다형성이라는 특징은 이런 업캐스팅을 통해 드러난다.

- 부모 클래스의 포인터 변수를 선언하면, 부모 클래스의 객체만이 동적할당할 수 있는 게 아니라, 직접 상속받는 자식 클래스나 자식 클래스의 상속 받아 간접적으로 상속 관계로 연결되어 있는 클래스의 객체까지 모두 포인터 변수로 가리킬 수 있다는 뜻이다.

 

3. 정적 바인딩과 동적 바인딩

 3-1. 바인딩이란?

- 바인딩에 대한 설명으로 여러 가지가 있는데 설명을 다음과 같이 나열해보겠다.

- 프로그램 소스에 쓰인 각종 내부 요소, 식별자들에 대해 값 또는 속성을 확정하는 과정

- 프로그램 구성 요소의 성격을 결정해주는 것

- 함수를 호출하는 부분에 함수가 위치한 메모리 번지를 연결해주는 것

- 이와 같은 설명을 보아 할 때, 변수나 함수가 결정되어야 할 속성이 값(자료형 등)이 결정되는 과정 정도로 이해하고 넘어가야겠다. 

 

 3-2. 정적 바인딩

- 정적 바인딩은 변수나 함수의 속성이 결정되는 시점이 컴파일 과정 중에 이루어지는 것을 의미한다.

- int a = 0;이라는 코드가 있으면 이는 코드로 명시되어있는 타입과 변수명으로 컴파일 중에 이미 결정이 될 것이다.

- 이렇게 컴파일 중에 이미 결정되어 프로그램이 될 것이니 속도가 빠르고, 잘못된 프로그래밍으로 인한 자료형 오류를 조기에 발견할 수 있지만, 컴파일 시 이미 결정되기에 변경이 불가능하다. 일반적인 변수나 함수에서 사용한다.

 

- 내가 이해하기로는 이렇다. int a = 0;이 있다면 이는 컴파일 중에 a에 대한 메모리 공간을 int형만큼 확보한다는 뜻으로 이해했다.

 

 3-3. 동적 바인딩

- 동적 바인딩은 정적 바인딩의 컴파일 시간 중에 바인딩이 이루어지는 것이 아닌 실행 도중, 런타임 중에 바인딩이 이루어지는 것을 의미한다.

- 실행 파일을 만들 때 바인딩 하지 않고 보류 상태로 둔다. 그리고 실행 중에 주소값이 정해지기 때문에 주소값을 저장할 메모리에 4바이트를 가지고 있다가 프로그램 실행 시 결정하는 것이다.

- 이와 같은 개념은 가상 함수를 설명할 때 쓰인다.

- 실행 도중에 필요한 필요한 객체의 함수가 결정되어 호출함으로 유연성을 지니고 있지만, 예상치 못한 자료형을 가진 변수가 나올 수 있어 안정성이 떨어진다고 한다. 정적 바인딩이 보다 상대적으로 느리다고 한다.

 

- 내가 이해라기로는 이렇다. int* a = new int;라고 할때, a라는 포인터 변수는 컴파일 중에는 주소값이 주어지지 않아 가리키는 메모리 공간이 정해지지 않는데 이것이 실행시간 중에 결정되어 int형만큼의 메모리공간을 확보한 주소값을 받아 바인딩한다는 의미로 이해했다.

 

- 위 설명을 가상함수와 연결지어 할텐데, 이는 조금 더 뒤에 가서 정리하겠다. 

 

4. 함수 오버라이딩과 함수 오버로딩

- 가상함수를 정리하기 전에, 함수 오버라이딩에 대해 정리하고 넘어가겠다.

- 함수 오버라이딩은 부모 클래스의 멤버 함수의 자료형, 이름, 매개변수이 똑같은 자식 클래스의 멤버 함수를 만드는 것인데, 이 때 부모 클래스의 멤버 함수와 자식 클래스의 멤버 함수의 몸체에 들어갈 내용이 다르다. 예를 들어보자

class PermanentWorker
{
public:
	int GetPay()
	{
		return workTime * payPerHour;
	}
private:
	...
};

class SalesWorker : public PermanentWorker
{
public:
	int GetPay()
	{
		return PermanentWorker::GetPay() + salesResult * bonusRatio;
	}
private:
	...
};

- 위의 코드는 열혈 C++ 프로그래밍의 일부를 요약해서 가져왔다.

- 정규직을 뜻하는 PermanentWorker 클래스는 GetPay()함수가 있다. 그 클래스를 상속받고 영업직을 뜻하는 SalesWorker 클래스도 GetPay()가 있다. 몸체 부분의 변수들은 신경 쓰지 말고, 내용이 다르다는 것에 주목하자.

- 이처럼 자식 클래스에서 부모 클래스의 멤버 함수가 재정의되는 것을 오버라이딩이라고 한다. 이 때에 자료형, 함수 이름, 매개변수는 동일해야하고 몸체만 달라야 한다. 부모의 함수를 자식의 함수로 덮어버린다고 생각하면 편하다.

 

- 함수 오버로딩은 같은 함수의 이름이라도 매개변수의 유형과 갯수를 달리해서 그에 따른 함수 몸체를 재정의하는 것을 의미한다.

- 그렇기에 함수 오버라이딩은 같은 이름, 같은 매개변수 등 같은 모양의 함수 머리를 사용하고 몸체가 다르기에 부모 클래스와 자식 클래스의 상속 관계에서 사용된다.

- 유의할 점은 함수 오버로딩이라고 상속 관계에서 사용하지 않는 것은 아니다. 상속 관계이지만 자식 클래스에서 매개변수 유형과 갯수를 달리해 정의하면 그것은 함수 오버로딩이다.

 

5. 가상함수

Parent* ptr;
ptr = new Child();
ptr.ChildFunc();

- 다음과 같은 코드는 문제 없을까? 부모 클래스의 객체 포인터 변수가 자식 클래스 객체를 참조 가능하다. 그리고 그 포인터 변수로 자식 클래스의 함수를 호출한 것이다.

- 이와 같은 경우엔 컴파일러는 실제로 가리키고 있는 객체의 자료형으로 판단하지 않고 지금 선언한 포인터의 자료형으로 판단하여 포인터 형에 해당하는 클래스에 정의한 멤버에만 접근이 가능하기 때문에 자식 클래스의 멤버 함수를 호출하지 못하는 모습이다.

- 다음 코드를 한 번 살펴보자.

GrandChild* GCptr = new GrandChild();
Child* Cptr = GCptr;
Parent* Pptr = Gcptr;

// 모든 클래스에 Func()이 오버라이딩으로 구현됐다고 가정
GCptr->Func();
Cptr->Func();
Pptr->Func();

- 위 코드는 가장 하위에 있는 클래스를 객체 포인터 변수로 선언해 그 자료형과 똑같이 동적할당을 받고 그 주소값을 자신의 상위 클래스와 그보다 더 상위 클래스의 객체 포인터 변수에 대입하는 모습이다.

- 그리고 각 객체 포인터 변수는 Func() 함수를 호출할 것이다. 이 때, 각 포인터 변수는 어느 클래스의 멤버함수를 호출할까?

- 바로 위의 설명을 참고하여 설명하자면, 객체 포인터 변수는 포인터 형에 해당하는 클래스에 정의한 멤버에만 접근이 가능하기 때문에 자신이 선언된 클래스에 따라서 멤버 함수가 호출될 것이다.

GCptr->Func(); // GrandChild 멤버함수
Cptr->Func(); // Child 멤버함수
Pptr->Func(); // Parent 멤버함수

- 위의 결과를 보고 그저 포인터 변수의 자료형에 따라서 가리키는 객체에 상관없이 그 클래스의 멤버함수를 호출하는 거라고 생각할 수 있다.

- 하지만 이런 문제점을 제기할 수 있다. 함수를 오버라이딩했다는 것은 해당 객체에서 호출되어야 하는 함수를 재정의해서 바꾸겠다는 것인데 기껏 바꾼 것을 호출 못하고 포인터형에 따라서 호출되는 함수가 달라지는 것이 문제가 있다고 여길 수 있다.

- 그래서 C++은 이런 상황을 위해 가상함수라는 것을 제공한다. 포인터의 자료형에 따라서 호출 함수를 결정하는 것이 아니라 포인터 변수가 가리키는 실제 객체를 참조하여 호출 함수를 결정한다.

- 사용방법은 다음과 같다. 실제 가리키는 객체에 따라 호출되길 원하는 함수의 부모 클래스에 가서 그 멤버 함수 앞에 virtual 키워드를 추가해주면 된다.

class Parent
{
public:
     virtual void Func() { }
    // 이 함수는 포인터 변수에서 호출될 때
    // 포인터의 자료형이 아닌 실제 가리키는
    // 객체에 따라서 호출될 것이다.
};

6. 함수에 대한 짤막한 설명

- 함수에 대한 짤막한 설명이 필요할 것 같다. 클래스를 통해 객체를 생성할 때마다, 객체 내의 멤버변수는 따로 메모리공간을 할당받을 것이다. 그럼 함수는 어떻게 될까? 변수와 같이 객체가 생성될 때마다 메모리 공간을 받을까?

- C++에서 멤버함수는 객체가 선언될 때마다, 변수와 함께 메모리 공간을 할당받지 않고, 따로 멤버함수만 메모리 어딘가에 위치해서 이 함수를 정의한 클래스의 객체들이 함수를 쓸 일이 있으면 그 함수의 주소값을 갖고 찾아가서 사용하는 방법을 쓴다. 그래서 객체 내에 멤버함수를 모두 지니고 자기 꺼를 쓰는 게 아니라 모든 객체가 하나의 함수를 공유하는 것이다.

 

7. 가상 함수 테이블

- 가상 함수의 원리를 정리하기 위해 가상 함수 테이블에 대한 정리가 필요하다.

- 가상 함수는 어떻게 객체에 따라서 함수를 정할까? 그것은 동적 바인딩과 함수에 대한 이해가 필요하다.

- 동적 바인딩은 실행 시간 중에 함수가 위치한 메모리 공간과 연결한다고 했다. 만약 프로그램이 이미 실행되었다고 가정하자. 그럼 컴파일러는 클래스 중에 가상 함수가 있는지 확인한다. 만약 있다면 메모리 어딘가 가상함수가 있는 만큼 4바이트씩 공간을 확보한다. 이 4바이트 공간은 가상함수의 실제 함수가 위치한 메모리 공간 주소값을 저장할 함수 포인터 변수이다. 이것을 가상 함수 테이블이라고 한다.

- 가상함수의 실제 함수가 위치한 메모리 공간 주소값을 저장할 함수 포인터 변수는 프로그램 시작 시 가상 함수가 있는 만큼 만들어진다. 그리고 실제 주소값이 정해지기 전까지 보류 상태로 머문다.

Parent* p = new Child();

- 위의 코드를 예시로 계속 정리하겠다. 프로그램 시작되고 나서, Child 객체를 생성이 될 때, 컴파일러는 가상 함수 중에 자식 클래스에서 재정의된 함수가 있는지 확인한다. 재정의가 안되었다면 부모 클래스의 함수의 주소를 가상 함수 테이블에 저장한다. 만약 재정의가 된 함수가 있다면 자식 클래스의 함수의 주소를 가상 함수 테이블에 저장한다.

- 그리고 Parent형 포인터 변수 p를 통해서 Child 클래스에서 재정의한 함수를 호출한다면 가상 함수 테이블에 참조하여 주소값을 알아내 그 주소값으로 함수를 호출하게 될 것이다.

 

- 그림과 같은 설명이 필요하다면 아래 더보기를 눌러 출처의 글을 읽어서 참고가 되길 바란다.

 

8. 순수 가상 함수와 추상 클래스

- 상속 관계를 정의하다보면 부모 클래스의 객체가 생성되는 것이 불필요할 경우가 있다. 오로지 클래스들 사이의 공통점을 모아 놓은 역할과 오버라이딩하여 자식 클래스에서 정의할 함수를 부모 클래스에 선언하기만 할 뿐, 객체가 생성되는 예외 사항을 만들고 싶지 않은 경우가 있다. 이럴 때는 순수 가상함수를 사용하면 된다.

class Parent
{
public:
     virtual void Func() = 0;
}

Parent* p = new Parent(); // 불가능하다.

- 위와 같이 가상함수에 0을 대입한 모양이 순수 가상함수의 사용방법이다. 순수 가상함수는 함수의 몸체가 정의가 되지 않은 함수를 말하며, 0의 대입은 명시적으로 몸체를 정의하지 않음을 컴파일러에게 알려주는 방법이다.

- 이렇게 순수 가상함수가 하나 이상 포함한 클래스를 추상 클래스라고 부르며, 추상 클래스는 자식 클래스에게 오버라이딩할 함수를 제공할 역할과 더불어 이 클래스가 객체가 생성되지 않도록 막는 역할을 하게 될 것이다.

- 순수 가상함수가 하나 이상 있어 완전하지 않은, 객체 생성이 불가능한 클래스라는 의미이다.

 

9. 가상 소멸자

Parent* p = new Child();

- 위와 같은 상황에서 p에 대해 delete 연산을 하면 어떻게 될까? 기대되는 것으로 동적할당한 Child와 p의 메모리 반환이 이상적일 것이다.

- 하지만 p만 메모리 반환을 하고 동적할당한 Child 객체는 반환되지 못하고 메모리를 차지하는 꼴이 되었다.

- 이를 위한 방안으로 소멸자 앞에 virtual 키워드를 붙인다. 이는 가상 소멸자라고 한다. 가상 소멸자는 부모 클래스에서 사용한다면 자동으로 자식 클래스의 소멸자가 가상 소멸자로 선언된다. 

- 위와 같은 상황에서 가상 소멸자에 의한 메모리 반환이 시작된다면, 자식 클래스의 소멸자 호출부터 시작해서 부모 클래스의 소멸자 호출까지 역순으로 진행된다.

 

10. 참조에 대해

- 객체 포인터 변수는 다음과 같이 해석할 수 있다. AAA 클래스 포인터 변수는 AAA 클래스와 그 클래스를 직, 간접적으로 상속하는 모든 객체를 가리킬 수 있다.

- 참조도 마찬가지로 해석할 수 있다. AAA 참조자(&)는 AAA 클래스 객체와 그 클래스를 직, 간접적으로 상속하는 모든 객체를 참조할 수 있다.

- 이런 점을 이용해 함수의 매개변수에 부모 클래스의 참조자 매개변수를 넣어도 매개변수로 자식 클래스를 넣을 수 있다. 가상함수도 동일하게 적용되어 참조하고 있는 자식 클래스의 멤버함수를 호출할 것이다.

 

- 이러한 가상함수의 특징들을 이용해서 같은 클래스형 포인터 변수를 사용하더라도 객체에 따라서 다양한 결과를 얻을수 있기 때문에 다형성을 구현할 수 있을 것이다.

더보기

출처 : https://secretroute.tistory.com/entry/140819

 

정적 바인딩(Static binding) vs. 동적 바인딩(Dynamic binding)

* Binding - 프로그램 구성 요소의 성격을 결정해주는 것 ex ) 변수의 데이터 타입이 무엇인지 정해지는 것  종류 정적 바인딩(Static binding) 동적 바인딩(Dynamic binding)  정의  컴파일 시간에 성격이

secretroute.tistory.com

https://medium.com/pocs/%EB%B0%94%EC%9D%B8%EB%94%A9-binding-4a4a2f641b27

 

바인딩(Binding)

바인딩(Binding) 이란 프로그램의 어떤 기본 단위가 가질 수 있는 구성요소의 구체적인 값, 성격을 확정하는 것을 말한다.

medium.com

https://gamelaboratory.tistory.com/entry/CC-%EC%A0%95%EC%A0%81-%EB%B0%94%EC%9D%B8%EB%94%A9%EA%B3%BC-%EB%8F%99%EC%A0%81-%EB%B0%94%EC%9D%B8%EB%94%A9

 

C++ - 정적 바인딩과 동적 바인딩

1. 바인딩(Binding)이란? 프로그램 소스에 쓰인 각종 내부 요소, 이름 식별자들에 대해 값 또는 속성을 확정한 과정을 바인딩이라고 한다. 2. 정적 바인딩(Static Binding)이란? 바인딩의 정의에서 보는

gamelaboratory.tistory.com

https://m.blog.naver.com/PostView.naver?isHttpsRedirect=true&blogId=dd1587&logNo=221119668089 

 

C++ 동적바인딩과 가상함수 원리

C++ 동적바인딩과 가상함수원리앞 포스팅에서 virtual이 붙는 함수가 가상함수라고 했어요. 이번 포스팅에...

blog.naver.com

http://tcpschool.com/cpp/cpp_polymorphism_virtual

 

코딩교육 티씨피스쿨

4차산업혁명, 코딩교육, 소프트웨어교육, 코딩기초, SW코딩, 기초코딩부터 자바 파이썬 등

tcpschool.com

http://www.kyobobook.co.kr/product/detailViewKor.laf?ejkGb=KOR&mallGb=KOR&barcode=9788996094043&orderClick=LEa&Kc= 

 

윤성우의 열혈 C++ 프로그래밍 - 교보문고

본 도서는 2004년도에 출간된 윤성우 저자 「열혈강의 C++ 프로그래밍」의 개정판이다. 초판과 마찬가지로 C언어를 이해하고 있다는 가정하에서 집필된 C++ 기본서로서, 초보자에게 적절한 설명과

www.kyobobook.co.kr

https://lesslate.github.io/cpp/%EC%97%85-%EC%BA%90%EC%8A%A4%ED%8C%85-%EB%8B%A4%EC%9A%B4-%EC%BA%90%EC%8A%A4%ED%8C%85/

 

C++ 업캐스팅 다운캐스팅

Intro

lesslate.github.io