.. Cover Letter

ㅇ 공부#언어/(C++)기초

(기초 C++) 9장. C++의 가상 함수와 추상 클래스

BrainKimDu 2023. 3. 6. 18:37

주의 : 이 글은 C의 기초문법에 대해 상세하게 다루지 않습니다. (즉 C언어에서 배울 수 있는 기초내용은 생략합니다)

※ 명품 C++ Programming 의 책을 참고하여 개인적으로 정리한 글입니다. 
이 글의 목적은 해당 책의 내용을 인용하여 더 쉽게 이해하고자 정리하고, 더 쉬운 예제를 통해 이해하는 것을 목표로 하고 있습니다.
명품 C++ Programming의 예제문제와 실습문제가 정말 좋으므로, 깊게 공부하고 싶다면 책을 구매하는 것을 추천드립니다.
책의 저작권 등등 각종 권한은 출판사와 지은이/옮긴이에 있습니다.

- 출판사: (주)생능 출판사
- 지음: 황기태

명품 C++ Programming - YES24


C++ 상속 관계의 메소드 재정의

다음은 부모클래스의 메소드를 자식클래스에서 다시 정의하는 것을 보인다.

#include <iostream>
using namespace std;

class animal {
public:
	virtual void set_name() {
		cout << "이름을 변경하는 메소드 입니다." << endl;
	}
};

class dog : public animal {
public:
	virtual void set_name() {
		cout << "자식 클래스의 이름을 변경하는 메소드 입니다" << endl;
	}
};

int main() {
	dog d, *pointer_dog;
	pointer_dog = &d;
	pointer_dog->set_name();

	animal *pointer_animal;
	pointer_animal = pointer_dog;
	pointer_animal->set_name();

}

결과는 다음과 같다.  우선 main문안에 있는 코드를 해석해보자. 

자식클래스 dog의 객체 d와 포인터형 변수 pointer_dog 를 선언했다. 
pointer_dog 가 객체 d를 참조하게 한다. 이 상태에서 set_name() 메소드를 호출하면 당연하게도 set_name을 실행한다.

 

그리고 이번에는 부모 클래스의 포인터형 변수 pointer_animal 을 선언하고
pointer_animal = pointer_dog 을 하면 업캐스팅 된 상태이고, 객체 d를 가리키게된다.
이상태에서 set_name() 메소드를 호출하면 업캐스팅된 상태이니 부모 클래스의 메소드가 실행된다.

(쓰는 나도 어렵다)

상속에 있어 부모 클래스의 멤버 함수로 원하는 작업을 할 수 없는 경우, 자식 클래스에서 동일한 원형으로 그 함수를 재정의하여 해결한다.

 

또한 다음처럼 범위연산자로 자식클래스에서 재정의된 메소드 대신에 부모클래스의 메소드를 호출할 수 있다. 

int main() {
	dog d, *pointer_dog;
	pointer_dog = &d;
	pointer_dog->set_name();
	pointer_dog->animal::set_name();
}

 

 

가상함수와 오버라이딩

가상함수(virtual function)과 오버라이딩(overriding)은 상속에서 볼 수 있는 객체지향 언어의 특징 중 하나입니다. 

오버라이딩이란 무엇일까? 쉽게 생각해서 부모클래스에서 만든 메소드를 자식 클래스에서 새롭게 활용하는 것을 말합니다. 그냥 가장 쉽게 코드로 보면 됩니다. 자식클래스 부모클래스의 메소드를 같은이름으로 재정의해서 사용할 수  있습니다.

C++ 에서 오버라이딩을 한다면 다음의 키워드를 이해할 필요가 있습니다. 가상함수 , 동적 (후기)바인딩, 정적(초기)바인딩 이라는 용어입니다. 

코딩의 시작, TCP School

 

코딩교육 티씨피스쿨

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

tcpschool.com

가상함수는 간단하게 말해서 자식 클래스에서 재정의할 것 같은 멤버함수를 의미하며 멤버함수 원형에 virtual을 추가합니다. 

virtual 멤버함수의원형;

 

 

바인딩이란 일반적으로 한 항목을 다른 항목에 매핑하는 것을 나타냅니다. 

정적바인딩 : 실행 이전에 값이 확정된다. 컴파일 타임에 호출될 함수가 결정되는 것으로, 컴파일러는 포인터의 자료형을 기반으로 호출의 대상을 결정한다. (일반적으로 빌드 중에 이루어진다.)

동적 바인딩 : 실행 이후에 값이 확정되면 동적 바인딩이라고 하며, 런타임에 호출될 함수가 결정되는 것으로 virtual 키워드를 통해 동적 바인딩 하는 함수를 가상 함수라고 하며, 함수가 가상 함수로 선언되면, 포인터 변수가 실제로 가리키는 객체에 따라 호출의 대상이 결정된다.

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

 

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

바인딩(binding)이란? 네이버 지식백과에서 찾아보면 바인딩은 '컴퓨터 프로그래밍에서 각종 값들이 확정되어 더 이상 변경할 수 없는 구속(bind) 상태가 되는 것' 으로 설명되어있다. 즉, 프로그래

todayscoding.tistory.com

 

자식 클래스에서 부모 클래스의 가상 함수를 재정의하는 것을 함수 오버라이딩이라고 합니다. 함수의 재정의가 컴파일 시간 다형성(compile time polymorphism)이라면, 오버라이딩은 실행 시간 다형성 (run time polymorphism)을 실현합니다.

 

그렇다면 재정의와 오버라이딩의 차이점은 무엇일까? 위에서의 코드를 다시 한 번 보자.

#include <iostream>
using namespace std;

class animal {
public:
	virtual void set_name() {
		cout << "이름을 변경하는 메소드 입니다." << endl;
	}
};

class dog : public animal {
public:
	virtual void set_name() {
		cout << "자식 클래스의 이름을 변경하는 메소드 입니다" << endl;
	}
};

int main() {
	dog d, * pointer_dog;
	pointer_dog = &d;
	pointer_dog->set_name();

	animal* pointer_animal;
	pointer_animal = pointer_dog;
	pointer_animal->set_name();
}

그러면 자식 클래스의 객체는 어떤 메소드를 들고 있는 상태인가?

재정의의 경우에는 이러한 상황이다.

부모의 메소드도 있고, 자식의 메소드도 있는데, 재정의를 하게되면 객체는 두 개의 같은 메소드를 가지게된다.

그래서 결과적으로 이런 결과가 나오는데

그러나 다음처럼 가상함수로 선언하고 접근을 하는 경우

#include <iostream>
using namespace std;

class animal {
public:
	virtual void set_name() {
		cout << "이름을 변경하는 메소드 입니다." << endl;
	}
};

class dog : public animal {
public:
	virtual void set_name() {
		cout << "자식 클래스의 이름을 변경하는 메소드 입니다" << endl;
	}
};

int main() {
	dog d, * pointer_dog;
	pointer_dog = &d;
	pointer_dog->set_name();

	animal* pointer_animal;
	pointer_animal = pointer_dog;
	pointer_animal->set_name();
}

 

차이점이 무엇일까? set_name()이 가상함수이므로 pointer_dog와 pointer_animal 두 포인터형 변수는 모두 자식 클래스의 객체를 가리키데된다.  그 이유는 pointer_animal 을 가리키는 포인터형 변수는 오버라이딩한 set_name()을 포함함으로 동적 바인딩을 통해서 dog의 set_name이 호출된다. 

(동적바인딩이기 때문에 부모클래스의 메소드는 가상함수라서 존재감이 사라진 상태 그러므로 자식 클래스의 메소드로 동적 바인딩되어 실행된다고 한다.

(글 쓰는 나도 왜 이렇게까지 하는지 아직도 이해가 안된다.)

 

오버라이딩은 왜 하는가(목적)

부모 클래스의 가상 함수는 상속받는 자식 클래스에서 구현해야할 일종의 함수 인터페이스를 제공한다. 그래서 '하나의 인터페이스에 대해 서로 다른 모양의 구현'이라는 객체지향의 다형성을 만족시킨다.

 

동적바인딩(실행시간 바인딩 = 늦은 바인딩)

동적바인딩은 쉽게 말해 오버라이딩된 함수가 무조건 호출된다는 것을 말한다.

가상 함수를 호출하는 코드를 컴파일할 때, 컴파일러는 바인딩을 실행 시간에 결정하도록 미루어둔다. 그 후에 가상 함수가 호출되면, 실행 중에 객체 내에 오버라이딩된 가상 함수를 동적으로 찾아 호출하는 과정을 말한다.

동적 바인딩이 발생하는 구체적인 경우는 자식 클래스의 객체에 대해, 부모 클래스의 포인터로 가상 함수가 호출될 때 일어난다. 그래서 다음의 경우에 발생한다.
- 부모 클래스 내의 멤버 함수가 가상 함수 호출
- 파생 클래스 내의 멤버 함수가 가상 함수 호출
- main()과 같은 외부 함수에서 기본 클래스의 포인터로 가상 함수 호출
- 다른 클래스에서 가상 함수 호출

다음의 사례를 다시 한 번 보자.

#include <iostream>
using namespace std;

class animal {
public:
	void set_name() {
		cout << "이름을 변경하는 메소드 입니다." << endl;
	}
};

class dog : public animal {
public:
	void set_name() {
		cout << "자식 클래스의 이름을 변경하는 메소드 입니다" << endl;
	}
};

int main() {
	animal* pointer_animal = new animal();
	pointer_animal->set_name();
}

일단 가상함수가 아닌 경우 실행은 당연히 animal의 set_name이 실행된다.

그러면 가상함수의 경우 어떤게 실행되는가?

#include <iostream>
using namespace std;

class animal {
public:
	virtual void set_name() {
		cout << "이름을 변경하는 메소드 입니다." << endl;
	}
};

class dog : public animal {
public:
	virtual void set_name() {
		cout << "자식 클래스의 이름을 변경하는 메소드 입니다" << endl;
	}
};

int main() {
	animal* pointer_animal = new animal();
	pointer_animal->set_name();
}

당연히 변화가 없다. 부모클래스만 가지고 놀고 있는 상황이기 때문이다.

그러면 부모클래스의 포인트형 변수가 자식클래스의 객체를 가리키게 하자 (업캐스팅)

 <iostream>
using namespace std;

class animal {
public:
	virtual void set_name() {
		cout << "이름을 변경하는 메소드 입니다." << endl;
	}
};

class dog : public animal {
public:
	virtual void set_name() {
		cout << "자식 클래스의 이름을 변경하는 메소드 입니다" << endl;
	}
};

int main() {
	animal* pointer_animal = new dog();
	pointer_animal->set_name();
}

이 때 동적 바인딩이 일어나는 상태이다. 그래서 부모포인터에 찾아 들어갔는데, set_name은 오버라이딩 되었어 라는 걸 알려준다. 그래서 set_name()메소드는 자식 메소드를 실행시킨다 (맞나?)

 

그러면 가상이 아니면 어떤 일이 발생하는가 virtual을 지워보자.

#include <iostream>
using namespace std;

class animal {
public:
	void set_name() {
		cout << "이름을 변경하는 메소드 입니다." << endl;
	}
};

class dog : public animal {
public:
	void set_name() {
		cout << "자식 클래스의 이름을 변경하는 메소드 입니다" << endl;
	}
};

int main() {
	animal* pointer_animal = new dog();
	pointer_animal->set_name();
}

 부모클래스의 포인트형 변수가 자식클래스의 객체를 가리키게 해도, 동적 바인딩이 일어나지 않기 때문에 set_name은 부모의 것으로 실행된다. 

 

어렵네요.. 

C++ 오버라이딩의 특징

오버라이딩을 위해서는 이름, 매개변수타입, 개수 그리고 리턴타입까지 일치해야한다.

오버라이딩은 부모메소드에서 virtual을 선언하고 자식메소드에서는 생략해도된다. 

#include <iostream>
using namespace std;

class animal {
public:
	virtual void set_name() {
		cout << "이름을 변경하는 메소드 입니다." << endl;
	}
};

class dog : public animal {
public:
	void set_name() {
		cout << "자식 클래스의 이름을 변경하는 메소드 입니다" << endl;
	}
};

int main() {
	animal* pointer_animal = new dog();
	pointer_animal->set_name();
}

 

 

동적 바인딩까지 진행되었지만, 부모의 메소드가 필요하면 다음처럼 선언하면 된다. 

int main() {
	animal* pointer_animal = new dog();
	pointer_animal->animal::set_name();
}

 

그러면 이러한 범위지정 연산자의 특징을 한 번 확인해보자.

#include <iostream>
using namespace std;

int count = 0;
int mian() {
	int count = 10;
	cout << count << " " << ::count;
}

책에서는 돌아가는데, VS상에서는 오류가 난다. 여튼 ::을 변수앞에 붙이면 전역변수를 가리키게된다.

 

 

가상 소멸자

부모 클래스의 소멸자를 만든다면 가상 함수로 작성하는 것이 좋다. 자식 클래스의 객체가 부모 클래스에 대한 포인터로 delete 되는 상황에서도 정상적인 소멸이 되도록 하기 위함이다. 한 번 확인을 해보자.

#include <iostream>
using namespace std;


class animal {
public:
	virtual void set_name() {
		cout << "이름을 변경하는 메소드 입니다." << endl;
	}

	~animal() {
		cout << "부모가 집에 갔습니다.";
	}
};

class dog : public animal {
public:
	void set_name() {
		cout << "자식 클래스의 이름을 변경하는 메소드 입니다" << endl;
	}

	~dog() {
		cout << "자식이 집에 갔습니다.";
	}
};

int main() {
	animal* pointer_animal = new dog();
	pointer_animal->animal::set_name();
	delete(pointer_animal);
}

결과는 부모의 소멸자만 생성된다. 

그러면 부모의 소멸자를 가상함수로 선언을 하는 경우를 보자.

 

#include <iostream>
using namespace std;


class animal {
public:
	virtual void set_name() {
		cout << "이름을 변경하는 메소드 입니다." << endl;
	}

	virtual ~animal() {
		cout << "부모가 집에 갔습니다.";
	}
};

class dog : public animal {
public:
	void set_name() {
		cout << "자식 클래스의 이름을 변경하는 메소드 입니다" << endl;
	}

	~dog() {
		cout << "자식이 집에 갔습니다.";
	}
};

int main() {
	animal* pointer_animal = new dog();
	pointer_animal->animal::set_name();
	delete(pointer_animal);
}

부모와 자식의 객체가 모두 소멸하였다. 

 

오버로딩, 함수 재정의, 오버라이딩의 비교

오버로딩 : 매개 변수 탕비이나 개수가 다르지만, 이름이 같은 함수들이 중복 작성되는 것

함수 재정의 : 부모 클래스의 멤버 함수를 파생 클래스에서 이름, 매개 변수 타입과 개수, 리턴 타입까지 완벽히 같은 원형을 재작성 하는 것 (정적바인딩으로 컴파일 시간 다형성)

오버라이딩 : 함수의 재정의와 정의는 같지만, 부모 클래스에서 구현된 가상 함수를 무시하고, 자식 클래스에서 새로운 기능으로 재작성하고자 하는 것으로 (동적 바인딩으로 실행 시간의 다형성)

 

추상 클래스

부모 클래스에 있는 가상 함수는 실행 목적보다는 자식 클래스에서 재정의하여 구현할 함수를 알려주는 인터페이스 역할을 한다. 

순수가상함수는 코드가 없고 선언만 하는 가상함수를 말하는데, 함수 끝에 =0을 넣으면된다.

그래서 이러한 순수 가상함수를 하나 가지게 되면 추상클래스가 되어버려서 이 클래스는 객체를 생성할 수 없다.

#include <iostream>
using namespace std;


class animal {
public:
	virtual void set_name() = 0;

	virtual ~animal() {
		cout << "부모가 집에 갔습니다.";
	}
};

class dog : public animal {
public:
	void set_name() {
		cout << "자식 클래스의 이름을 변경하는 메소드 입니다" << endl;
	}

	~dog() {
		cout << "자식이 집에 갔습니다.";
	}
};

int main() {
	animal a;
}

다음과 같은 오류가 나온다. 그러나 포인터는 선언할 수 있다.

int main() {
	animal* pointer_animal = new dog();
}

이렇게는 오류가 나지 않는다

그래서 추상클래스를 써서 상속을 위한 기본 클래스로 활용하는 것이 목적이다.  

그래서 추상클래스를 구현하면 추상클래스를 상속한 자식클래스가 모든 순수 가상 함수를 오버라이딩해서 구현하면 된다. 

설계와 구현을 분리할 수 있고, 계층적 상속관계를 가진 클래스들의 구조를 만들 때 적합하다.

 


참고 문헌
Scott Douglas Meyers(2015). Effective C++. Protec Media(프로텍 미디어)
Robert C. Martin(2021).UML 실전에서는 이것만 쓴다(UML for Java Programmers). 인사이트
황기태(2021). 명품 C++ Programming. (주)생능출판사