CPP Module 04 (2)

“CPP Module 04에 대하여”

Subtype polymorphism, abstract classes, interfaces

런타임 다형성의 모든 것

ex03: Interface & recap

4서클 CPP MODULE의 최대 난제

C++ Interface

  • 소멸자(virtual)와 순수 가상 함수만이 선언된 클래스, 반드시 파생 클래스를 구현하여 override 해야만 한다. subject에 ICharacterIMateria 클래스가 인터페이스의 예시로 제시되어 있다. 아무것도 구현할 필요가 없으니 cpp 파일 또한 필요없다. 따라서 ICharacterIMateria는 .hpp 파일로만 존재한다.
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;
};

class IMateriaSource
{
    public:
        virtual ~IMateriaSource() {}
        virtual void learnMateria(AMateria*) = 0;
        virtual AMateria* createMateria(std::string const & type) = 0;
};

Interface와 결합

  • Interface는 객체들의 강한 결합(Tight Coupling)을 약한 결합(Loose Coupling)으로 바꾸어놓는 것에 그 용도가 있다. 객체가 강하게 결합되어 있다는 것이 무슨 의미일까?

강한 결합(Tight Coupling)

  • 대략적인 코드를 구현하며 강한 결합의 형태를 살펴보자. 예시로 Character 클래스와, Cure 클래스를 구현해보았다.

Cure

class Cure
{
    protected:
        std::string type; // "Cure"
    public:
        Cure();
        Cure(const Cure& ref);
        ~Cure();
        Cure& operator=(const Cure& ref);
};

Character

class Character
{
    private:
        std::string name;    
        Cure*    c;
    public:
        Character(std::string name_);
        Character(const Character &ref);
        Character &operator=(const Character &ref);
        ~Character();

        std::string const& getName() const;
        void equip(Cure* c);
        void unequip();
        void use(std::string target);
};

main.cpp

#include "Cure.hpp"
#include "Character.hpp"

int main()
{
    Character*   c1 = new Character("Bob");
    Cure*   s1 = new Cure();

    c1->equip(s1);
    c1->use("Dummy");
    c1->unequip();

    delete c1;
    delete s1;

    return 0;
}
  • 그닥 어렵지 않게 생각해낼 수 있었다. main.cpp의 주요 부분인 Cure 클래스의 equip, use, unequip을 확인해보자.
void    Character::equip(Cure*  cure)
{
    if (this->c)
        return ;
    this->c = cure;
}

void    Character::unequip()
{
    if (!this->c)
        return ;
    //delete this->c; Cure를 해제하지 않고, 포인터만 NULL로 변경
    this->c = NULL;    
}

void    Character::use(std::string target)
{
    if (!this->c)
        std::cout << "Cannot use Spell" << std::endl;
    std::cout << "** " << "heals " << target << "\'s wound **" << std::endl;
}
  • void Character::equip(Cure* cure)은 Cure 포인터를 받아서 Chracter의 Cure 포인터 자리에 장비시키며, void Character::unequip()은 Character의 Cure 포인터를 NULL로 바꾸어준다. 원래 장비하고 있던 Cure 포인터는 main에서 해제해준다든지 해서 적절히 관리되어야 할 것이다. void Character::use(std::string target)은 매개 변수로 들어온 target에게 Cure 스펠을 사용한다. 여기까지는 문제가 없다. 그러나 여기에 새로운 Spell인 Ice가 추가된다면 어떨까?

Ice

class Ice
{
    protected:
        std::string type; // "Ice"
    public:
        Ice();
        Ice(const Ice& ref);
        ~Ice();
        Ice& operator=(const Ice& ref);
};
  • Ice를 추가하는 것 자체는 어려운 일이 아니다. 그런데 Character가 Ice를 새롭게 배우기 위해서는 코드에 변화가 있어야 한다. 기존의 equip, unequip, use는 Cure 스펠만을 상정한 채 설계되었기 때문이다. 이런 식으로 바꿀 수 있을지도 모르겠다.
class Character
{
    private:
        std::string name;
        Cure*    c;
        Ice*     i; // 추가됨
    public:
        // ... implementation
        void equipCure(Cure* c);
        void unequipCure();
        void useCure(std::string target);

        // 추가됨
        void equipIce(Ice *i);
        void unequipIce();
        void useIce(std::string target);
};
  • CharacterCure 이외에 Ice를 위한 새로운 멤버 변수를 가져야 할 것이다. 또한 Cure 만을 상정한 equipCure, unequipCure, useCure 이외에, Ice를 받을 수 있는 새로운 함수 ice 시리즈(equipIce, unequipIce, useIce)를 만들어야 할 것이다.

  • 코드 줄이 늘어나는 것이 별로 유쾌한 광경은 아니다. 뿐만 아니라 이런 식으로 코드가 변경되면, 이 프로그램은 개방 폐쇄의 원칙(Open Close Principle)을 위배하게 된다.

  • 개방 폐쇄의 원칙(OCP)이란 기존의 코드를 변경하지 않으면서, 기능을 추가할 수 있도록 설계가 되어야 한다는 원칙을 말한다. 보통 OCP를 확장에 대해서는 개방적(open)이고, 수정에 대해서는 폐쇄적(closed)이어야 한다는 의미로 정의한다. 이는 Ice와 같은 확장된 클래스들을 적용하기 용이하되(예를 들어, Fireball, polymorph와 같은 추가적인 Spell들), 그러한 클래스들을 추가할 때 코드가 수정되어서는 안된다는 것이다.

  • 아까 Character 클래스의 void equip(Cure *c)를 기억할 것이다. 강한 결합은 이렇게 어떤 함수가 어떤 인자만을 매개 변수로 받아들이는 것이다.(여기서는 Cure 포인터) 강한 결합으로 구현된 객체는 매개 변수를 교체하기가 어렵고, 확장성이 매우 떨어진다. Ice 이외에 수많은 Spell들이 추가될 수 있는데, 클래스 간의 결합이 강한 결합으로 이루어져 있으면 이런 식으로 각자의 장비, 사용 함수들을 일일히 구현해야 할 것이다(equipIce, equipFireBall, equipPolymorph 등등등). 이는 매우 비효율적인 행위이다. 문제를 해결해보자.

재사용 가능한 추상 클래스 AMateria와 약한 결합(Loose Coupling)

  • c++ 코드의 재사용성에 대한 이야기는 상당히 프로페셔널한 부분이어서, 여기서 자세히 다루기는 어려운 것 같다. 다만 여태까지의 모듈을 통해 배운 것으로 망라하건데, 추상 클래스 AMateria를 구현하고 Ice, Cure와 같은 Spell들로 하여금 AMateria를 상속하게 만듦(약한 결합)으로써 코드의 재사용성을 강화하고, OCP를 준수하도록 할 수도 있다. AMateria를 만들면서 약한 결합(Loose Coupling)에 대해서도 알아보자.

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);
};
  • 이것이 subject에서 주어진 AMateria의 원형이다. 이걸 가지고 채워보자.
class AMateria
{
    protected:
        std::string type;
    public:
        // AMateria();
        AMateria(const AMateria& ref);
        virtual ~AMateria();
        AMateria& operator=(const AMateria& ref);

        AMateria(std::string const& type);

        std::string const &	getType() const;
        virtual AMateria* clone() const = 0;
        virtual void use(std::string target);
        // virtual void use(ICharacter& target);
};
  • 예를 들어, 이렇게 채워볼 수 있을 것 같다. 맨 밑줄의 virtual void use(ICharacter& target);의 경우, ICharacter를 다룰 때 같이 다뤄보도록 하고, 우선 임시로 그 윗줄의 virtual void use(std::string target);으로 대신 써보자. 주석으로는 계속 써 줄 예정이다.

Cure, Ice

// ...

#include "AMateria.hpp"

class Cure : public AMateria
{
    public:
        Cure();
        Cure(const Cure& ref);
        ~Cure();
        Cure& operator=(const Cure& ref);

        AMateria*	clone() const;
        void	use(std::string target);	
        //  void use(ICharacter& target);		
};

class Ice : public AMateria
{
    public:
        Ice();
        Ice(const Ice& ref);
        ~Ice();
        Ice& operator=(const Ice& ref);

        AMateria* clone() const;
        void	use(std::string target);	
        // void use(ICharacter& target);		
};
  • 이제 Cure와 Ice는 AMateria를 상속한다. AMateria를 상속하게 된 만큼, 추상 클래스 AMateria의 가상 함수들을 오버라이딩 해야 할 것이다.

  • Character 클래스에도 변화가 있다. Character는 더 이상 Cure와 같은 구체적인 클래스를 매개변수로 취하지 않는다.

Character

class AMateria;

class Character
{
    private:
        std::string name;    
        AMateria*    slot[4];
    public:
        Character(std::string name_);
        Character(const Character &ref);
        Character &operator=(const Character &ref);
        ~Character();

        std::string const& getName() const;
        void equip(AMateria* m);
        void unequip(int idx);
        // void use(int idx, ICharacter& target);
        void use(int idx, std::string target);
};
  • 스펠이 여러가지로 추가될 수 있다는 것을 감안하여, 이제 캐릭터는 AMateria 클래스 포인터의 배열 slot을 멤버 변수로 가진다. 캐릭터가 새로운 스펠을 장착할 때 AMateria 클래스 포인터의 배열을 하나씩 채우고 해제할 때 인덱스를 받아 포인터를 NULL로 돌린다. 사용할 때에도 인덱스를 받아 해당 스펠을 타겟에 사용한다.

  • 이제 equip은 구체적인 클래스(Cure, Ice 등)가 아니라, AMateria 포인터를 매개변수로 받는다. Character는 AMateria를 상속하는 어떠한 Spell 클래스도 장착할 수 있다. 이것이 약한 결합이다. C++ 약한 결합은 이렇게 추상 클래스 내지는 인터페이스(Interface)를 통해 구현될 수 있으며, 확장성이 높고 코드를 관리하기 용이하다.

ICharacter

  • 지금까지 프로그램의 구조도를 그려보자면 아래와 같다.


  • 지금까지의 내용을 이해했다면, IMateriaSource와 ICharacter의 구조 또한 파악할 수 있다. 이제, Character를 ICharacter를 상속시켜보자. ICharacter.hpp가 subject에 주어져 있으니 쉽게 구현할 수 있다. 인터페이스를 채우며 구현해보자.
class Character : public ICharacter
{
    // ... 이하 동일
}
  • 위에서 주석을 쳤던 use 함수들을 기억할 것이다. virtual void use(std::string target);, 그리고 void use(int idx, ICharacter& target);이다. ICharacter를 구현하기 이전에는 구색을 맞추기 위해 void use(std::string target);, void use(int idx, std::string target);를 사용했다. std::string target을 매개변수로 사용하는 것은 강한 결합이다. 이런 식으로 매개 변수를 받게 되면, Character가 use를 할 수 있는 대상이 target 하나만으로 한정된다. 하지만 Character가 대상 단 하나에 Spell을 쓰진 않을 것이다. 코드의 확장성을 위해 매개변수를 std::string target에서 ICharacter로 수정해야 한다. use 함수가 ICharacter를 매개 변수로 받게 되면, Character는 유동적으로 서로 다른 Character들에게 Spell들을 use 할 수 있게 된다(약한 결합). 이제 구조는 아래와 같이 바뀌게 된다.


MateriaSource

  • 이제 마지막으로 남은 MateriaSource를 구현하면 되겠다. 마찬가지로 IMateriaSource가 subject에 주어져있다.

  • MateriaSource 자체를 구현하는 것은 그렇게 어렵지 않을텐데, MateriaSource와 AMateria 그리고 Character의 관계가 좀 아리까리하게 느껴질 수 있다. Character가 AMateria를 장착하고, 해제하는 것까지는 확인했는데 MateriaSource는 또 뭘까?

  • subject의 맨 아래 주어진 main 문을 통해, 세 클래스의 대략적인 관계를 유추할 수 있다.

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;
}
  • 위 main문에서 Character와 AMateria 사이의 직접적인 접촉은 없다. Character는 직접 AMateria*를 장착하는 것이 아니라, 반드시 MateriaSource를 경유하게 된다.
AMateria* tmp = new Ice();
me->equip(tmp)

-> 이런 식으로 바로 쓰지 않고, me MateriaSource 통해서만 tmp 장착할  있다는 
  • MateriaSource는 멤버 변수로 4칸짜리 AMateria의 포인터 배열(AMateria* arr[4])을 가지는데, learnMateria를 통해 그 배열을 채우고, createMateria를 통해 빈 AMateria 포인터가 매개 변수에 맞는 Spell을 가리키게 하면, 그제서야 Character는 그 AMateria 포인터를 통해 Spell을 장착할 수 있게 된다. 과제의 제약 사항을 보면, Character가 Spell을 unequip할 때 해제해서는 안되는데, Character와 MateriaSource의 해제만으로 메모리 누수를 없도록 하려면, learnMateria로 AMateria 포인터 배열을 채우고 createMateria하여 캐릭터에게 스펠을 쥐어주는 과정에서 Spell을 무분별하게 동적 할당하지 말아야 할 것 같다. 이 부분은 개인적인 설계에 따라 충분히 달라질 수 있는 영역으로 보인다. 메모리 관리에 유의하여 프로그램을 구현해 보자. 최종적인 구조도는 아래와 같다.


  • 구현이 끝났다면, subject의 출력과 동일한지 비교해보고 메모리 누수를 확인하면서 코드를 가다듬어보자. 이것으로 4서클의 Cpp Module이 마무리되었다. 추가로 concrete class에 대해 간략하게 다뤄보면서 글을 마친다.

concrete class

  • 정의한 모든 연산(operation)이나 일부 연산의 구현을 서브 클래스로 넘기는 추상 클래스(abstract class)나 객체의 연산에 대한 구현이 포함되어 있지 않고 정의만 존재하는 인터페이스를 통해 인스턴스를 만들 수 없다. 완성되지 않은 설계도를 가지고 제품을 만들 수는 없기 때문이다.

  • 모든 연산에 대한 구현을 가지고 있는 클래스가 바로 concrete class이다. 추상 클래스가 아닌 클래스는 모두 concrete class라고 할 수 있다. 정의한 모든 연산에 대한 구현을 가지고 있는 완전한 클래스이므로 우리는 이 클래스의 인스턴스를 만들 수 있다.