서론
서브젝트 첫 페이지에 나오듯이 다형성과 추상 클래스, 그리고 인터페이스를 구현해보는 과제이다.
여담으로 cpp 03 이후에 04부터 08까지 한번에 끝낸 다음에 평가를 받았는데, 평가를 받으면서 보니 별로 좋은 생각은 아니었다. 평가를 받으면서 무슨 생각으로 이렇게 만들었는지를 까먹고 평가자와 같이 고민하게 되는 큰 문제가있었다.
ex00
모든 exercise에서는 최대한 완벽한 테스트를 제공할 수 있도록 노력해야 한다.
모든 생성자와 소멸자는 클래스에 따라 다른 이름이 나와야 한다.
시작은 Animal 클래스 하나만 구현한다. protect로 string 타입의 type 변수 하나만 보유한 클래스이다.
Dog 클래스와 Cat 클래스를 만들어서 각자 이름에 맞는 type를 가지도록 한다. Dog는 ‘Dog’로 초기화하고 Cat은 ‘Cat’으로 초기화한다. Animal은 비워둬도 되고 원하는 걸로 설정해도 된다.
모든 클래스는 멤버 함수로 makeSound()를 보유한다. 각 클래스에 따라 적절하게 출력하도록 만들면 되며, Animal 클래스는 아무것도 출력하지 않도록 구현한다.
int main()
{
const Animal* meta = new Animal();
const Animal* j = new Dog();
const Animal* i = new Cat();
std::cout << j->getType() << " " << std::endl;
std::cout << i->getType() << " " << std::endl;
i->makeSound(); //will output the cat sound!
j->makeSound();
meta->makeSound();
...
return 0;
}
이 메인 문에 원하는 테스트를 추가로 구현하며, 이 과제를 정확하게 이해하기 위하여 WrongAnimal과 WrongCat을 구현하여 (WrongCat이 WrongAnimal의 Sound를 출력하도록) 테스트를 만들어야 한다.
#ifndef ANIMAL_HPP
# define ANIMAL_HPP
# include <iostream>
class Animal {
protected:
std::string type;
public:
Animal(void);
Animal(const Animal& obj);
Animal& operator=(const Animal& obj);
virtual ~Animal(void);
virtual void makeSound(void) const;
std::string getType(void) const;
};
#endif
#ifndef WRONGANIMAL_HPP
# define WRONGANIMAL_HPP
# include <iostream>
class WrongAnimal {
protected:
std::string type;
public:
WrongAnimal(void);
WrongAnimal(const WrongAnimal& obj);
WrongAnimal& operator=(const WrongAnimal& obj);
~WrongAnimal(void);
void makeSound(void) const;
std::string getType(void) const;
};
#endif
int main(void)
{
const Animal* meta = new Animal();
const Animal* j = new Dog();
const Animal* i = new Cat();
const WrongAnimal* wrong = new WrongCat();
std::cout << std::endl;
std::cout << i->getType() << " " << std::endl;
i->makeSound();
std::cout << j->getType() << " " << std::endl;
j->makeSound();
std::cout << meta->getType() << " " << std::endl;
meta->makeSound();
std::cout << std::endl;
std::cout << wrong->getType() << " " << std::endl;
wrong->makeSound();
std::cout << std::endl;
delete meta;
meta = NULL;
delete j;
j = NULL;
delete i;
i = NULL;
delete wrong;
wrong = NULL;
return 0;
}
C++에서는 부모 클래스의 포인터여도 자식 클래스의 객체 주소를 담고 있을 수 있다. 이를 업캐스팅이라고 한다.
그런데 부모 클래스의 포인터이다 보니 자식 클래스에서 오버라이딩한(재정의한) 함수를 실행시키려는 상황에 부모 클래스의 원래 함수가 실행되는 문제가 발생한다.
이를 해결하기 위하여 자식 클래스에서 재정의할 부모 클래스의 함수에 virtual 키워드를 붙이면 해당 함수는 가상 함수가 되면서, 의도대로 자식 클래스의 함수를 실행할 수 있게 된다.
이 과제에서는 virtual 키워드를 사용하여 makeSound 함수와 소멸자를 가상 함수로 만들어서 의도한 대로 animal 포인터가 Dog를 가리키고 있을 때 Dog의 함수가 동작하도록 만들어야 한다. (소멸자는 따로 서브젝트에 언급은 없으나 메모리 릭 방지를 위해 반드시 필요하다.)
여담으로 클래스를 const로 선언했다면, 멤버 함수 끝에 const를 붙이지 않은 함수의 사용은 금지된다. 따라서 제공하는 메인 문을 제대로 실행하려면 makeSound()에 const를 붙여야 한다.
C++에서는 가상 함수의 키워드와 동작 방식에 대해서만 규정하고 있고, 자세한 동작 원리는 컴파일러에 따라 알아서 구현하게 되어 있는데, 대부분 가상 함수 테이블을 만들어서 클래스에 virtual 키워드가 있으면 해당 함수가 가리키는 주소를 따로 저장해 둔 다음 함수 호출 시 해당 주소를 참조하여 실행하게 된다.
당연히 메모리를 더 많이 먹고 느려지기 때문에 virtual 키워드는 남용해서는 안되지만, 자식 클래스에서 오버 라이딩할 것으로 예상될 때에는 반드시 사용해야 한다.
https://marmelo12.tistory.com/m/285
http://www.tcpschool.com/cpp/cpp_polymorphism_virtual
ex01
각 클래스의 생성자와 소멸자는 다른 메시지를 표시해야 한다.
Brain 클래스를 만듭니다. 해당 클래스는 ideas라는 이름의 std::string [100] 멤버 변수를 가지고 있다.
Dog와 Cat은 해당 클래스를 private 멤버 변수로 가진다.
Dog와 Cat은 생성 시 new Brain()을 실행한다.
Dog와 Cat은 소멸 시 delete를 실행한다.
메인 문에서 animal의 배열을 만들어서 절반은 Dog를, 절반은 Cat을 담고, 프로그램이 종료될 때 올바른 순서로 소멸자가 호출되는지 확인한다.
Dog와 Cat은 복사 시 반드시 깊은 복사를 수행한다. 메인 문에서 해당 사항을 테스트한다.
메모리 릭을 체크한다.
#ifndef BRAIN_HPP
# define BRAIN_HPP
# include <iostream>
# include <sstream>
class Brain {
private:
std::string ideas[100];
public:
Brain(void);
Brain(const Brain& obj);
Brain& operator=(const Brain& obj);
~Brain(void);
std::string getIdeas(int n) const;
void setIdeas(std::string idea, int n);
};
#endif
int main(void)
{
std::string str;
Animal *meta[10];
for (size_t i = 0; i < 10; i++)
{
if (i % 2)
{
meta[i] = new Dog();
}
else
{
meta[i] = new Cat();
}
}
std::cout << std::endl;
for (size_t i = 0; i < 10; i++)
{
delete meta[i];
}
std::cout << std::endl;
Dog *d = new Dog();
Dog *d2 = new Dog();
std::cout << std::endl;
str = d->getBrain()->getIdeas(0);
std::cout << "Dog1's first idea is "<< str << std::endl;
d->getBrain()->setIdeas("something", 0);
str = d->getBrain()->getIdeas(0);
std::cout << "Dog1's first idea is "<< str << std::endl;
*d2 = *d;
str = d2->getBrain()->getIdeas(0);
std::cout << "Dog2's first idea is "<< str << std::endl;
std::cout << std::endl;
delete d;
d = NULL;
delete d2;
d2 = NULL;
return 0;
}
왜 배열로 만들어서 지워보라고 한 건지는 이해를 못 했다. 평가표를 보면 이해가 되려나 했는데 평가표에도 관련 내용은 없었다. 뭐 이런 게 하루 이틀일은 아니니까 그런가 보다 했다.
클래스 안에서 포인터를 이용해 다른 클래스의 객체를 다뤄보는 경험을 해보는 과제라고 생각한다. 그래서 동물 안에서 brain 클래스의 객체를 다뤄보면서 생성할 때 같이 생성되고, 소멸될 때 같이 소멸되도록 구현해서 leak이 없도록 만들면 된다.
서브젝트에서는 깊은 복사만을 강조했는데, 평가표에서는 반드시 Dog와 Cat 안의 Brain을 delete 하고 new 하는 것으로 깊은 복사를 구현하도록 되어있었다. 그래서 나는 서브젝트만 보고 구현을 하면서 '어차피 Brain의 크기는 100으로 동일한데 안의 내용물만 정확히 깊은 복사로 바뀌었는지를 확인하면 되지 뭐하러 새로 동적 할당을 하나' 이러면서 내 생각대로 구현해서 평가표와 어긋나는 상황이 있었다. 그 상황을 두 평가자 모두에게 얘기를 했는데, 다행히 '깊은 복사는 맞으니까.'라고 두 분 다 쿨하게 이해해주셔서 넘어갈 수 있었습니다...
여기서 헷갈렸던 부분은 *d2 = *d; 부분을 * 단항 연산자를 안 붙이고 d2 = d; 이런 식으로 쓰다 보니 포인터가 가리키는 클래스의 operator= 이 동작하지 않고 포인터의 operator= 즉 가리키는 주소가 바뀌어버리는 연산이 되어서 delete에서 double free로 인해 터지는 현상이 있었다. 곰곰이 생각해보면 당연한 연산이며 당연한 결과인데, 그때는 이게 왜 안되지? 이러면서 한참 헤맸다.
ex02
Animal을 객체로 만들어서 생기는 문제점을 피하기 위해 순수 가상 함수를 이용하여 추상 클래스로 만들어라
class Animal {
protected:
std::string type;
public:
Animal(void);
Animal(const Animal& obj);
Animal& operator=(const Animal& obj);
virtual ~Animal(void);
virtual void makeSound(void) const = 0;
std::string getType(void) const;
};
말 그대로 Animal클래스를 추상 클래스로 만들어주면 된다.
ex00에서 만든 가상 함수는 자식 클래스가 재정의 할 것이라고 ‘예상’하는 함수에 붙였다면, 순수 가상 함수는 ‘반드시 재정의 해야만 하는 함수’를 뜻한다. 또한 이러한 순수 가상 함수가 들어간 클래스를 추상 클래스라고 부른다.
문법은 단순하게 해당 함수 끝에 = 0을 붙여주면 된다. 이렇게 만들면 해당 함수는 이 클래스에서는 따로 정의하지 않아도 되며, 이러한 추상 클래스를 직접 선언하게 되면 컴파일 에러를 발생시키기 때문에 반드시 자식 클래스를 구현하여 사용해야 한다.
http://www.tcpschool.com/cpp/cpp_polymorphism_abstract
ex03
인터페이스는 C++98에는 존재하지 않는 개념이다.(물론 C++20에도 없지만.) 그러나 순수 추상 클래스를 일반적으로 인터페이스라고 부른다. 이번 ex에서는 이러한 모듈을 이용하여 인터페이스를 구현해보는 과제이다.
아래와 같은 AMateria 클래스를 만들어라
class AMateria
{
protected:
[...]
public:
AMateria(std::string const & type);
[...]
std::string const & getType() const; //Returns the materia type
virtual AMateria* clone() const = 0;
virtual void use(ICharacter& target);
};
위의 AMateria를 상속받는 Materias, 즉 속성을 두 종류 만든다. Ice와 Cure. 두 속성은 type이라고 하는 이름을 지니며 속성의 이름을 lowercase 해서 가진다(ice와 cure).
두 속성의 멤버 함수 (AMateria 가 순수 가상 함수로 보유한) clone은 같은 속성의 새로운 객체를 반환한다.
use는 다음과 같은 출력을 반환한다.
- Ice: "* shoots an ice bolt at <name> *”
- Cure: "* heals <name>’s wounds *”
name 은 인자 ICharacter& targe 이 보유한 멤버 변수 name이다.
아래와 같은 ICharacter 인터페이스 클래스를 만들어라
class ICharacter
{
public:
virtual ~ICharacter() {}
virtual std::string const & getName() const = 0;
virtual void equip(AMateria* m) = 0;
virtual void unequip(int idx) = 0;
virtual void use(int idx, ICharacter& target) = 0;
};
Character 클래스는 4개의 빈 인벤토리를 가지며 Materia를 슬롯 0~3 순서로 장착한다. 인벤토리가 가득 찼을 때 속성을 추가하거나 없는 Materias를 사용/해제하려고 했다면 아무런 동작도 하지 않는다.(당연하지만 버그는 금지됨)
unequip 함수는 절대 Materias를 delete 하지 않는다.(캐릭터가 unequip 하고 사용하지 않는 Materias는 알아서 처리하되 메모리 릭이 있으면 안 됨)
use 함수는 slot [idx]의 Materias를 사용하며 target은 AMateria의 use에 인자로 넘긴다.
당연히 캐릭터의 인벤토리는 어떤 Materias도 담을 수 있어야 한다.
캐릭터 클래스는 name을 인자로 받는 생성자가 있어야 하며, 대입/복사 연산은 당연히 깊은 복사여야 함.
복사 중에는 이전에 보유한 속성은 delete 되어야 하고, 소멸자 역시 당연히 그렇게 되어야 함.
아래와 같은 IMateriaSource 인터페이스 클래스를 구현하라.
class IMateriaSource
{
public:
virtual ~IMateriaSource() {}
virtual void learnMateria(AMateria*) = 0;
virtual AMateria* createMateria(std::string const & type) = 0;
};
learnMateria에서는 인자로 들어온 속성을 복사하여, 이후에 사용할 수 있도록 메모리에 저장한다.
캐릭터에서 구현한 것과 비슷하게 MateriaSource는 4개만 보유할 수 있다. 반드시 고유성을 지닐 필요는 없다.
createMateria에서는 이전에 학습한 MateriaSource를 반환한다. 인자에 들어온 string 값에 해당하는 MateriaSource를 모를 경우 0을 반환한다.
핵심만 말하면 MateriaSource의 템플릿을 학습한 다음 필요할 때마다 생성해낼 수 있어야 한다. 그러면 새로운 Materia를 string 만으로 구별하여 생성해낼 수 있다.
요구되는 테스트와 출력은 다음과 같다.
int main()
{
IMateriaSource* src = new MateriaSource();
src->learnMateria(new Ice());
src->learnMateria(new Cure());
ICharacter* me = new Character("me");
AMateria* tmp;
tmp = src->createMateria("ice");
me->equip(tmp);
tmp = src->createMateria("cure");
me->equip(tmp);
ICharacter* bob = new Character("bob");
me->use(0, *bob);
me->use(1, *bob);
delete bob;
delete me;
delete src;
return 0;
}
$> clang++ -W -Wall -Werror *.cpp
$> ./a.out | cat -e
* shoots an ice bolt at bob *$
* heals bob's wounds *$
# include "ICharacter.hpp"
class AMateria;
class Character : public ICharacter
{
private:
AMateria* inventory[4];
std::string name;
public:
Character(void);
Character(std::string name);
Character(const Character& obj);
Character& operator=(const Character& obj);
~Character(void);
std::string const & getName() const;
void equip(AMateria* m);
void unequip(int idx);
void use(int idx, ICharacter& target);
AMateria* getInventory(int idx) const;
};
우선 ICharacter / IMateriaSource는 서브젝트에서 준 그대로 사용하면 된다. 이 둘은 오히려 고치는 게 문제일 것 같다고 생각했는데, 다행히 평가표에서도 따로 고치지는 않았는지 체크하는 구문이 있었다. AMateria 도 서브젝트에서 요구하는대로 만들면 되며, ice / cure도 크게 어렵지 않으니 여기까진 따로 설명이 필요 없을 것 같다.
별생각 없이 여기까지 만들다 보면 character를 만들 때쯤 돼서 뭔가 이상함을 느끼게 된다. 바로 헤더의 상호 참조 문제이다. AMateria에서 use의 인자 target의 자료형으로 ICharacter를 사용하기 위해 해당 헤더를 넣어뒀는데, 다시 보니 ICharacter에서도 equip의 인자 자료형이 AMateria이기 때문에 서로를 참조하게 되어 컴파일이 되지 않는다.
이를 해결하기 위해 클래스를 전방 선언해주어야 한다. 전방 선언이란 이러한 클래스가 있다고 미리 선언하되, 해당 내용에 대해서는 필요한 경우에만 소스 파일(.cpp 파일)에 따로 헤더를 선언해주는 것을 말한다.
위 코드를 예시로 들면 class AMateria;라고 미리 적어두면, 다른 클래스에서 이러한 클래스가 있다는 사실을 미리 알 수 있다. 그 이후에 소스 코드에서 AMateria를 사용한다면, 해당 소스 파일에서 AMateria의 헤더를 선언하여 사용하게 된다.
전방 선언의 단점은 해당 클래스의 객체를 만들 때, 위 코드의 AMateria *inventory처럼 객체는 포인터로만 사용할 수 있다. 이유는 당연하게도 여기서는 해당 이름을 가진 클래스가 있다는 사실만 알뿐, 해당 클래스의 크기를 알 수 없기 때문에, 크기를 지정해야 하는 객체를 만들 수가 없기 때문이다. 다만, 인자나 반환 값의 자료형은 클래스의 크기와 상관없기 때문에 사용할 수 있다. (다만 위 헤더에서는 AMateria 가 추상 클래스이기 때문에 equip의 인자에도 해당 클래스의 객체가 직접 올 수는 없고 포인터를 사용해야 한다.)
따라서 작성한 파일들이 서로를 순환 참조하지 않도록 클래스를 전방 선언하도록 변경해주면 된다.
int main()
{
IMateriaSource* src = new MateriaSource();
ICharacter* me = new Character("me");
ICharacter* bob = new Character("bob");
AMateria* tmp_ice = NULL;
AMateria* tmp_cure = NULL;
src->learnMateria(new Ice());
src->learnMateria(new Cure());
tmp_ice = src->createMateria("ice");
me->equip(tmp_ice);
tmp_cure = src->createMateria("cure");
me->equip(tmp_cure);
me->use(0, *bob);
me->use(1, *bob);
me->unequip(0);
me->unequip(1);
delete tmp_ice;
tmp_ice = NULL;
delete tmp_cure;
tmp_cure = NULL;
delete bob;
delete me;
delete src;
return 0;
}
출력을 이것만 요구했으므로 다른 출력은 다 제거해준다.
과제에서 unequip이 delete를 동반하지 않도록 제약이 걸려있어서, 어떻게 처리해야 하나 한참 고민하다가 다른 사람들에게 물어봤는데, 그냥 메인에서 해당 메모리를 관리하는 식으로 메인 문을 만들었다고 해서 나도 그렇게 만들었다.
뭔가 코드를 보면 입맛도 텁텁하니 마음에 안 드는데 해당 제약조건을 우회한 채로 메모리 릭이 없도록 데이터를 관리할 수 있는 방법이 떠오르지 않았다. 더 좋은 해결책이 있으면 나중에라도 바꿀 예정이다.
https://blog.naver.com/hyungjungkim/60202456568
https://coding-restaurant.tistory.com/504
https://ju3un.github.io/c++-forward-declaration/
https://dydtjr1128.github.io/effectivec++/2019/07/25/Effective-Cpp-item-11.html
'프로그래밍' 카테고리의 다른 글
[42서울] CPP Module 06 - 형변환 (1) | 2022.09.11 |
---|---|
[42서울] CPP Module 05 - 재사용성과 예외처리 (3) | 2022.09.10 |
[42서울] CPP Module 03 - 클래스 상속 (0) | 2022.08.18 |
[42서울] CPP Module 02 - 고정 소수점 클래스 만들기 (0) | 2022.08.18 |
[42서울] CPP Module 01 - 클래스와 레퍼런스 (0) | 2022.08.10 |