Search

CPP Module 04

Created
2021/10/10
tag
42서울
42Seoul
CPP Module
All of C++98

Subjects

1. ex00 (Polymorphism)

Compiletime Polymorphism & Runtime Polymorphism

Module 04의 시작 챕터 이름인 Polymorphism으로부터 미루어 보아 이번 서브젝트의 주 학습 목표는 다형성에 대한 정확한 이해를 하는 것이다. 다형성은 단순하게 보았을 때는 상속만으로 다형성을 이룬 것으로 생각할 수 있으나, 다형성을 이루기 위해선 상속 구조에서도 몇 가지 작업을 만족해야 한다. 이를 컴파일 타임과 런 타임으로 나누어 2가지 관점에서 이해할 수 있다.
컴파일 타임에서는 상속 관계에 있는 클래스들의 함수와 연산자의 Overloading들이 적절히 이뤄져아 하고, 런 타임에서는 virtual의 적절한 Overriding 통해 상속 관계에 있는 클래스들이 타입에 맞는 함수를 호출할 수 있어야 한다. 위의 사진은 GeeksforGeeks의 자료인데, 다형성 만족을 위한 조건들을 명확하게 이해할 수 있다.
Overloading의 경우, 여러 시그니처로 구성된 함수들 중 어느 함수를 고를 것인지 컴파일러의 규칙에 의해서 컴파일 타임에 결정된다. OverridingOverloading과 달리 컴파일 타임에 어느 함수를 고를 것인지 결정 짓는 것이 아니라, 런 타임에 vTable에 유지되고 있는 virtual로 명시된 함수에 대해 자신의 타입을 확인하면서 적절한 함수를 결정 짓는다. 이 때문에 Overloading - 컴파일 타임, Overriding - 런 타임으로 구분된다. (Overriding의 호출에 대한 구분이 런 타임에 이뤄진다는 것이지, Overriding을 위해 작성한 코드는 당연히 컴파일 타임에 이뤄진다.)
특히 Overriding에 대해서 virtual 키워드는 Module 03에서 학습했기 때문에, Module 03에서 했던 것처럼 작성을 잘해주면 업 캐스팅에도 문제 없이 다형성을 유지하는 것을 확인할 수 있다. 지금 언급된 부분이 이해가 잘 안된다면, 반드시 Module 03 글을 다시 참고하자.

Wrong Class

WrongAnimal 클래스WrongCat 클래스는 정상적으로 virtual 키워드를 Overriding 한 클래스들과 비교를 위해 virtual 키워드 없이 Overriding하여 작성하도록 한다. 이 때 Animal 클래스로 다형성을 이루는 객체와 WrongAnimal 클래스로 다형성을 이루는 객체 간 업 캐스팅포인터로 확인을 해보면, WrongAnimal 클래스의 객체는 자신의 타입으로 함수를 호출하는 것이 아니라 항상 WrongAnimal 클래스의 함수를 호출하려는 것을 볼 수 있다. 이와 같은 결과는 Module 03에서 virtual 키워드를 다루었을 때 예측한 결과와 같다.
다시 한 번 올바른 다형성을 위해 Overriding 부분을 짚자면, 기반 클래스에서 재정의 하려는 함수는 virtual로 명시해야 하고, 파생 클래스소멸자virtual로 명시해야 한다.

2. ex01 (I don’t want to set the world on fire)

Destructor

기존에 완성해둔 클래스들에게는 별도의 포인터로 정의된 멤버 변수가 없었다. 즉, 클래스를 객체로 이용할 때는 객체 자체의 자유 공간으로부터 할당 여부만을 제외하면 객체의 소멸과 함께 내부에 유지 중인 메모리들은 정상적으로 반환된다. 하지만 클래스 내에 포인터로 정의한 멤버 변수가 있고, 이 포인터자유 공간에서 할당 받은 메모리를 참조하도록 이용한다면 단순히 소멸자 호출만으로 자유 공간의 메모리는 반환되지 않는다. 해당 경우에는 자유 공간의 메모리는 반환되지 않는데, 소멸자 호출 때문에 포인터 자체는 이용할 수 없게 되므로 메모리 누수로 이어진다. 따라서 클래스 내에 멤버 변수를 포인터로 선언해둔 것이 있다면, 반드시 포인터에 할당된 자유 공간의 메모리를 적절히 반환할 수 있도록 신경써야 한다.
물론 포인터자유 공간으로부터 할당 받은 메모리를 참조하도록 할당해두지 않았다면, 멤버 변수로 포인터를 둬도 메모리들은 객체의 소멸자 호출과 함께 정상적으로 반환된다. 하지만 일반적으로 멤버 변수로 포인터를 두는 이유는 자유 공간으로부터 메모리를 할당 받아 이용하기 위함이므로, 위에서는 이 경우에 대해서 설명한 것이다.
서브젝트에서는 Brain 클래스Cat 클래스Dog 클래스포인터로 존재하도록 요구했으므로, Cat 클래스Dog 클래스생성자에서 할당받은 Brain 클래스의 객체를 각 클래스의 소멸자에서 delete를 통해 적절히 해제하도록 구현하면 된다.

Deep Copy

포인터를 멤버 변수로 두었을 때 주의해야할 점은 소멸자 뿐만 아니라 복사에도 있다. 클래스 내에서 복사를 정의하는 경우는 operator=복사 생성자가 있다. 클래스에서 제시된 2개의 함수를 정의하지 않으면 컴파일러에 의해 자동으로 기본 할당 연산자기본 복사 생성자가 생성되는데, 이들은 Shallow Copy (얕은 복사)에 기초하고 있다. Shallow Copy가 어떤 식으로 동작하기에 포인터에서 문제가 되는지 아래에서 살펴보자.
class A { public: std::string _x; int _y; }; int main(void) { A a; A b; // Copy Every Member in b to a a = b; return (0); }
C++
복사
Shallow Copy는 기본적으로 각 타입의 operator=에 근거하여 동작한다. 즉, operator=복사 생성자가 정의되지 않아 컴파일러에 의해 생성된 함수들은 클래스가 보유하고 있는 모든 멤버 변수에 대해서 단순 대입을 시도한다. 따라서 위 코드에서 a_xb_xa_yb_y로 대입이 이뤄진다.
class A { public: std::string _x; int _y; int* _z; A(void) { _z = new int; } ~A(void) { delete _z; } }; int main(void) { A a; A b; // Copy Every Member in b to a a = b; return (0); }
C++
복사
이 때 만일 기존의 코드를 위와 같이 포인터를 멤버 변수로 두도록 수정하고, 생성자에서는 해당 포인터동적 할당을 하도록 만들면 어떤 일이 벌어질 지 예상될 것이다. a_z가 기존에 참조하고 있던 자유 공간b_z가 참조하는 자유 공간을 함께 참조하게 되면서, 기존의 자유 공간에 접근할 수 있는 포인터를 잃어버렸기 때문에 메모리 누수가 발생한다.
class A { public: std::string _x; int _y; int* _z; A& operator=(const A& a) { _x = a._x; _y = a._y; if (_z) { delete _z; _z = NULL; } _z = new int; _z = a._z; } A(void) { _z = new int; } ~A(void) { delete _z; } A(const A& a) : _x(a._x), _y(a._y) { if (_z) { delete _z; _z = NULL; } _z = new int; _z = a._z; } }; int main(void) { A a; A b; // Copy Every Member in b to a a = b; return (0); }
C++
복사
따라서 이와 같이 포인터를 멤버 변수로 둔 경우에는 복사가 이뤄지는 모든 함수에서 Deep Copy가 이뤄질 수 있도록 함수들을 직접 정의해야 한다.
이처럼 클래스 내에 원시 포인터를 두는 것은 메모리 누수도 문제지만, 직접 정의할 코드도 늘어난다. 특히 클래스 외에도 단순히 원시 포인터를 이용하는 경우에는 에러가 발생했을 때 try-catch로 잡아내더라도 포인터가 참조하는 공간에 대해서는 해제가 되지 않으므로 여러모로 메모리 누수에 취약하다. (try-catch 내에서 작성된 구문에서 에러가 발생했을 때는 Stack Unwiding이라는 작업을 통해 객체들의 소멸자들이 자동으로 호출되는데, 원시 포인터는 객체가 아니므로 메모리 해제를 보장받을 수 없다.) 따라서 Modern C++에서는 이와 같은 문제들을 해결하기 위해 스마트 포인터가 도입되어, 원시 포인터를 객체로 감싸서 이용하게 된다. 물론 스마트 포인터원시 포인터를 완전히 대체할 수 있는 것은 아니지만, 소멸자Deep Copy의 문제는 극복할 수 있다. Modern C++을 이용할 생각이라면 스마트 포인터에 대해서도 반드시 학습하길 바란다.

Additional Function

Module 01에서 Zombie 클래스의 멤버 변수 _name을 만들기 위해 이용한 Random 클래스를 다시 정의했다. 해당 함수는 Brain 클래스_idea 배열의 요소로 할당하기 위한 문자열을 만든다. 그 외에도 _idea 배열의 요소를 설정하고 갖고올 수 있도록, setIdeagetIdea 함수를 적절히 정의해주면, 조금 더 원활한 동작이 이뤄지는 main 함수를 구성할 수 있다.

3. ex02 (abstract class)

Pure Virtual Function & Abstract Class

기존에 정의해둔 Animal 클래스업 캐스팅을 위해 공통의 포인터로 이용될 수는 있지만, Animal 클래스 자체를 객체로 이용하지는 않기 때문에 이에 대한 적절한 수정을 하는 것이 ex02의 목표이다. 이는 Pure Virtual Function (순수 가상 함수)를 이용하여 달성할 수 있다.
class A { private: // ... implementation public: virtual void pvf(void) const = 0; // ... implementation };
C++
복사
Pure Virtual Function이란 위 코드에서 볼 수 있듯이, 클래스 내에서 virtual로 명시된 함수들 중에서 0이 할당된 함수를 의미한다. 0을 할당한 의미는 해당 함수를 정의하지 않겠다는 것을 의미한다. 함수를 정의하지 않겠다라는 사실이 시사하는 점은 2가지가 있다. 첫 째는 정의되지 않은 함수가 포함된 객체는 다형성을 위한 파생 클래스에서의 생성을 제외하고는 직접 생성이 불가능하다는 점이다. 둘 째는 해당 함수를 파생 클래스에서 Overriding 하지 않으면 여전히 정의되지 않은 함수가 되어 파생 클래스 역시 객체로 만들 수 없으므로, 파생 클래스에서는 반드시 Pure Virtual Function을 정의해야 한다는 점이다.
즉, Pure Virtual Function을 갖고 있는 클래스는 파생 클래스에게 청사진을 제공하면서, 그 자체로는 객체로 이용할 수 없기 때문에 일종의 Interface로 동작한다. 이와 같은 클래스를 Abstract Class (추상 클래스)라고 부른다.
#include <iostream> class A { private: virtual void something(void) = 0; }; class B : public A { public: void something(void) { std::cout << "hi" << std::endl; } }; int main(void) { B b; b.something(); return (0); }
C++
복사
참고로 Pure Virtual Functionprivate 영역에 있어도 파생 클래스에서 Overriding이 가능하므로, 어느 영역에 있어도 문제가 되지 않는다. 구현하려는 함수의 의미에 맞춰서 적절한 영역에 위치시키고, 파생 클래스에서는 Pure Virtual Function을 적절히 Overriding를 해주도록 하자.

Orthodox Canonical Form of Abstract Class?

#include <iostream> class A { virtual void something(void) = 0; }; int main(void) { A a; return (0); }
C++
복사
추상 클래스Interface로 동작하기 때문에 그 자체를 객체로 만들 수 없다고 했다. 따라서 위 구문은 컴파일 되지 않으며, 에러가 발생하는 것을 볼 수 있다. 이 때 객체로 만들 수 없다는 오해 때문에 Orthdox Canonical Form을 작성하는 것이 의미가 있는지 의문이 들 수 있다. 하지만 추상 클래스는 특정 경우에 객체화 될 수 있으며, 생성자와 소멸자를 호출할 수 있다.
추상 클래스가 객체화 될 수 있는 경우는 파생 클래스를 생성할 때이다. 실제로 추상 클래스 내에는 Pure Virtual Function만 존재할 수 있는 것이 아니라, 실제로 호출될 수 있도록 정의된 함수를 포함하여 멤버 변수들도 갖고 있을 수 있다. 따라서 추상 클래스를 상속하여 생성된 파생 클래스에서는 당연히 추상 클래스의 멤버 변수 뿐만 아니라 정의된 함수들도 호출할 수 있다. 이 때 만일 추상 클래스가 객체로써 파생 클래스의 메모리 레이아웃에 함께 존재하지 않으면, 파생 클래스에서는 추상 클래스에 정의된 여러 변수와 함수를 이용할 수 없다.
#include <iostream> class A { private: virtual void something(void) = 0; public: A(void) { std::cout << "A Default Constructor" << std::endl; } ~A(void) { std::cout << "A Destructor" << std::endl; } }; class B : public A { public: void something(void) { std::cout << "hi" << std::endl; } B(void) : A() { std::cout << "B Default Constructor" << std::endl; } ~B(void) { std::cout << "B Destructor" << std::endl; } }; int main(void) { B b; b.something(); return (0); }
C++
복사
이를 확인하기 위해 위와 같은 코드를 작성하여 실행해보면, B 클래스를 객체로 만들기 위해 A 클래스생성자를 호출하는 것을 볼 수 있고 마지막엔 소멸자도 호출되는 것을 볼 수 있다. 즉, B 클래스생성자에서는 상황에 맞는 A 클래스의 생성자를 호출할 수 있도록 구현할 수 있으므로, A 클래스에서 필요한 생성자를 포함하여 Orthodox Canonical Form을 최대한 유지할 필요가 있다.

4. ex03 (Interface & recap)

Figure Out

마지막 문제는 ex00 - ex02까지 주어진 문제를 재활용하는 것이 아니라, 이제까지 학습한 다형성을 위한 올바른 Overloading, Overriding 그리고 추상 클래스를 활용하여 적절한 OOP 프로그래밍을 해보는 것이다. 문제를 보면 꽤나 구현할 요소들이 많은데, 하나 하나 살펴보면 그렇게 어렵지는 않다.
AMateria → Interface of Cure & Ice
Cure
Ice
ICharacter → Interface of Character
Character
IMateriaSource → Interface of MateriaSource
MateriaSource
정의할 클래스는 모두 7개이며, 이들 중 ICharacterIMateriaSource는 서브젝트에 정확한 정의가 있다. 특히 두 클래스는 소멸자에 대해선 클래스 내에서 직접 정의되어 있고, 나머지 함수는 Pure Virtual Function으로 선언되어 있기 때문에 추가적인 정의가 필요하지 않다. 따라서 AMateria, Cure, Ice, Character, MateriaSource에 대한 정의만 적절히 두면 된다. 각 클래스의 정의는 서브젝트에서 주어진 대로 꼼꼼히 진행하면 되고, 각 클래스의 용도가 이해가 되지 않는다면 아래를 참고하자.
1.
사용자는 Character로 나타나고, 이들은 CureIce 같은 AMateriaInterface로 하는 물질들을 소지할 수 있다. 이 때 CharacterAMateria에 대해 equip, unequip, use라는 상호작용을 만들어낼 수 있다.
2.
Ice, Cure와 같은 AMateriaInterface로 하는 물질들은 단순히 생성해낼 수 있는 것은 아니다. 해당 물질들을 생성하기 위해선 IMateriaSourceInterface로 하는 MateriaSource를 이용해야 한다. 그리고 MateriaSource를 이용하여 물질을 생성하기 전에는 선행적으로 물질에 대한 학습이 필요하다. 이를 위한 상호작용으로는 MateriaSourcelearnMateriacreateMateria가 있다.

Don't Mistake

이번 풀이에서는 원시 포인터를 활용할 일이 굉장히 많다. CharacterAMateria*를 4개까지 이용할 수 있으며, 생성할 수 있는 물질은 MateriaSource에서 AMateria*로 4개까지 관리된다. 즉, 다른 클래스들은 몰라도 CharacterMateriaSource에 대해선 Deep Copy 뿐만 아니라, 소멸자에 대해서도 매우 신경써야 문제가 발생하지 않는다.
Deep Copy와 소멸자 모두 기존에 갖고 있던 AMateria를 모두 해제할 필요가 있다고 나와 있는데, C 언어에서 동적 할당을 작성했을 때와 마찬가지로 이중 해제를 방지하기 위해서 메모리 해제 시 Defensive-Style을 고수할 수 있도록 한다. 메모리를 잘 해제 했다면, Deep Copy는 객체의 AMateria가 갖고 있는 clone 함수를 호출하여 새로운 AMateria를 기존 객체의 요소로 두도록 깔끔하게 작성할 수 있다.
여기서 말하는 Defensive-Style은 메모리 해제 이후에 해당 메모리를 참조하고 있던 포인터NULL을 할당하는 것을 의미한다.
또한 두 클래스에 대해 생성자로 객체를 만들면, AMateria* 배열의 요소들을 NULL로 초기화를 하는 것이 안전하다. 이렇게 해야 equip, unequip, use, learnMateria, createMateria 등으로 AMateria*를 조작할 때 예외 케이스들을 잘 걸러낼 수 있다. C 언어에서 학습했던 각 함수들의 NULL 여부 확인을 잊지 않도록 하자.