본문 바로가기
Programming/C와 C++ 게임 코드로 알아보는 코딩의 기술(저자 오즈 모리하루)

02. 간단한 설계를 위한 원칙과 패턴

by minjunkim.dev 2020. 8. 23.

    모든 내용은 [오즈 모리하루 저, "C와 C++ 게임 코드로 알아보는 코딩의 기술", 한빛미디어] 를 기반으로 제 나름대로 이해하여 정리한 것입니다. 다소 부정확한 내용이 있을수도 있으니 이를 유념하고 봐주세요!


# 객체 지향 설계에는 기본적인 사고방식과 원칙이 있다.

원칙에 너무 집착하는 것은 좋지 않지만, 어느 정도의 설계 방침으로 삼아 개발한다면 도움이 될 것이다.


1. 캡슐화, 응집도, 결합도

- 위 3가지는 보수성이 높은 클래스를 설계할 때 사용하는 기본요소

- 위 3가지를 의식하기만 해도 코드 품질은 매우 좋아짐

- 객체 지향 언어 이외의 범위에도 사용할 수 있는 중요한 개념

 

* 캡슐화(encapsulation)

- 객체 내부의 변수 또는 구현 상세를 사용자로부터 은폐하는 것

- 멤버변수를 private 하는 것만이 캡슐화가 아님

- 사용자는 public 멤버함수만 보이는데, 적절한 동작이 보장되어

클래스가 어떤 멤버변수를 가지고, 어떤 방식으로 구현되는지 알 필요가 없게 구현하는 것이 바람직함

- getter(), setter() 멤버함수를 만들면 클래스의 내부 구조가 외부에 노출되기 때문에,

캡슐화가 무너진다. 따라서, getter(), setter() 멤버함수는 되도록 만드는 것을 피하는 것이 좋음

- 클래스의 멤버변수를 변경할 때는 멤버변수가 있는 클래스에서 모두 처리하게 하자.

("직접하지 말고 명령하라"라는 객체 지향 설계의 원칙)

- 상속관계일 경우에도 protect보다는 가능한 private로 만들자.

- 추상 인터페이스를 사용하면 좋은 캡슐화를 실현 가능함

추상 인터페이스 사용자는 구현하는 클래스의 상세를 완전히 은폐할 수 있으므로 캡슐화가 가능함

사용자는 추상 인터페이스(의 멤버함수) 밖에 볼 수 없고, 클래스의 상세(구현 클래스)는 캡슐화됨

- 캡슐화의 큰 장점은 캡슐 내에 변경이 발생해도 캡슐 밖에는 영향을 주지 않음

(변경에 의한 영향 범위를 최소화 가능)

- 클래스는 객체의 복잡한 부분을 캡슐 내부에 몰아넣어, 사용자 입장에서 단순하게 보이도록 하는게 좋음

즉, 객체 내부 구조를 신경 쓰지 않아도 쉽게 사용할 수 있는 클래스 설계가 중요

- 캡슐화의 목적

1) 객체 내부의 상태를 보호

2) 객체 내부의 상세를 은폐

 

* 응집도(cohesion)

- 클래스가 하나의 역할에 얼마나 집중하는지를 나타내는 척도

- 크고 복잡하며, 여러 역할을 수행하는 클래스는 응집도가 낮음

- 다양한 역할을 하는 클래스가 있다면, 다양한 역할을 서로 다른 클래스에 이양하는 것이 하나의 방법

- 일반적으로 멤버변수의 수가 많으면 클래스가 여러 역할을 수행하므로 응집도가 낮아짐

- 따라서, 클래스의 멤버변수를 최소한으로 하는 것이 좋을 수 있음

- 클래스의 응집도를 높이려면 클래스를 자겍 나누고 역할을 분담해야 함

 

* 결합도(coupling)

- 다른 클래스와의 연관 정도를 나타내는 척도

- 다른 클래스와의 독립성이 높으면 결합도가 낮고, 독립성이 낮으면 결합도가 높음

- 클래스끼리 얼마나 영향을 주는지 나타내는 척도이기도 함

- 결합도는 낮을수록 좋음

- 소결합(loose coupling) : 결합도가 낮은 상태

- 소결합 클래스는 외부 변경에 영향을 받지 않으며, 재사용 또는 테스트도 간편함

- 테스트 주도 개발(TDD) : 개발 방법 중 하나로, 단위 테스트를 먼저 작성하고 클래스를 구현하는 방법

테스트에 기반을 두고 개발하는만큼 테스트하기 쉬운 소결합 클래스가 만들어짐


2. 상속과 이양의 관계 : 클래스 관계의 부류

- 상속 : 부모의 힘을 사용하는 관계

- 이양 : 다른 사람의 힘을 사용하는 관계

 

* 상속보다는 이양을 사용

- 가능하다면 상속보다는 "이양"을 사용(상속은 부모, 자식클래스가 밀접하게 결합하기 때문)
따라서, 설계의 유연성과 보수성 관점에서 "이양"이 더 유리

 

* 기능 상속과 추상 인터페이스 구현(서도 다른 개념임을 반드시 인지할 것)

- 상속 : 부모 클래스로부터의 "기능 상속"을 의미

- 추상 인터페이스 : "역할 구현"을 의미

 

* 추상 클래스와 추상 인터페이스

- 추상 클래스 : 하나 이상의 순수 가상함수(추상 메서드)를 포함한 클래스

기능을 이용하고 확장시키는데에 그 목적이 있다.(기능 상속)

상속은 슈퍼 클래스의 기능을 이용하거나 확장하기 위해서 사용되고,

다중 상속의 모호성 때문에 일반적으로 하나만 상속받을 수 있다.(단일 상속만 가능)

따라서 자바나 C#은 다중상속을 허용하지 않으나, C++은 다중상속을 허용한다.

- 추상 인터페이스 : 순수 가상함수(추상 메서드)만을 포함한 클래스,

추상 클래스보다 추상도가 높다.

함수의 껍데기만 있고, 이 함수의 구현을 강제시키기 위해 존재한다.(역할 구현)
인터페이스는 해당 인터페이스를 구현한 객체들에 대해서 동일한 동작을 약속하기 위해 존재한다.

(상속과는 다르게, 여러 개의 추상 인터페이스 이용 가능)


3. 객체 지향 설계의 원칙(SOLID)

- 항상 지켜야 하는 절대적 규칙은 아니므로, 클래스 설계할 때 참고하는 정도로 사용

 

* 단일 책임 원칙(SRP)

- 클래스를 변경해야 할 이유는 한 가지여야 한다.

- 즉, 하나의 클래스는 하나의 책임만 가져야 한다.

- 이 원칙은 클래스 응집도와 관련이 있다.

 

* 개방, 폐쇄 원칙(OCP)

- 소프트웨어 구성요소는 확장에 관해서는 열려있어야 하고, 변경에 대해서는 닫혀있어야 한다.
- 변화하지 않는 부분(닫힌 부분)과 변화하는 부분(열린 부분)을 분리하자.
e.g. 부모클래스 <=> 자식클래스, 추상인터페이스 <=> 구현클래스

 

* 리코스프 치환원칙(LSB)

- 파생 자료형은 기본 자료형과 치환할 수 있어야 한다.
- 부모 클래스로 치환한 상태에서도 정상 작동해야 한다.
- 상속에 의존한 설계는 맥없이 무너지기 쉬우므로 복잡한 상속관계는 피하자.
- 가능하다면 상속보다는 "이양"을 우선하자.

 

* 인터페이스 분리 원칙(ISP)
- 클라이언트가 사용하지 않는 멤버함수의 의존을 클라이언트에 강요하면 안된다.
- 클래스 사용자에게 불필요한 인터페이스는 공개하지 말라.
- 멤버함수가 많은 큰 클래스일수록 복잡한 역할을 수행할 가능성이 크다.
따라서, 최소한의 멤버함수로만 클래스를 구성하자.
- 필요한 멤버함수만으로 한정한 전용 클래스를 따로 만들어 간접적으로 사용하자.

// 송신 인터페이스
class Sender
{
public:
    virtual ~Sender() {}
    // 데이터 송신
    virtual void send(void* data, unsigned int size) = 0;
};

// 수신 인터페이스
class Receiver
{
public:
    virtual ~Receiver() {}
    // 데이터 송신
    virtual void receive(void* data, unsigned int size) = 0;
};

// 통신 클래스(송수신 구현 담당)
class Communicator : public Sender, public Receiver
{
public:
    // 데이터 송신
    virtual void send(void* data, unsigned int size) override;
    // 데이터 수신
    virtual void receive(void* data, unsigned int size) override;
};

// 클라이언트 클래스(송신만)
class Client : public Sender
{
public:
    Client(Sender* sender) : sender_(sender) {}
    void update()
    {
        ... 생략
        sender_->send(data, size)
    }
private:
    Sender* sender_; // 송신 전용 인터페이스
    ... 생략
};

// 서버 클래스(수신만)
class Server : public Receiver
{
public:
    Client(Receiver* receiver) : receiver_(receiver) {}
    void update()
    {
        ... 생략
        sender_->receive(data, size)
    }
private:
    Receiver* receiver_; // 송신 전용 인터페이스
    ... 생략    
};
// 송신 클래스를 구현하는 클래스
class SenderImpl : public Sender
{
public:
    SenderImpl(Communicator *communicator) : communicator_(communicator)
    {}
    // 송신
    virtual void send(void* data, unsigned int size) override
    {
        communicator_->send(data, size); // 송신만 사용
    }
};

// 수신 클래스를 구현하는 클래스
class ReceiverImpl : public Receiver
{
public:
    ReceiverImpl(Communicator *communicator) : communicator_(communicator)
    {}
    // 수신
    virtual void receive(void* data, unsigned int size) override
    {
        communicator_->receive(data, size); // 수신만 사용
    }
};


* 의존관계 역전원칙(DIP)
- 상위 모듈은 하위 모듈에 의존하지 않는다, 두 모듈 모두 별도의 추상화된 것에 의존한다.

- 상위 모듈 : 사용하는 측의 모듈 / 하위 모듈 : 사용되는 측의 모듈

- 보통 상위레벨 모듈은 하위 레벨 모듈을 이용해 만들어지는데,

상위 모듈이 하위 모듈에 직접 결합해버리면, 하위 모듈의 변경이 상위 모듈에 영향을 미친다.

이때는 상위 모듈과 하위 모듈의 관계를 역전시켜 소켤하는 것이 좋다.

- 이를 위해, "사용자 측에 있는 상위 레벨 모듈"의 요구에 맞춰 추상 인터페이스를 작성한다.

(이 때, 이 추상 인터페이스는 반드시 상위 모듈이 소유해야 한다.)
- 하위 레벨에서는 추상 인터페이스에 맞춰 구현 클래스를 작성한다.
- 즉, 상위 모듈은 생성한 추상 인터페이스를 거쳐 하위 모듈을 사용한다.
- 이러한 방법으로 상위, 하위 모듈간의 직접적인 결합을 피한다.

- 이렇게 되면, 구현 클래스를 교환하는 것만으로도 다른 환경으로 변경이 가능하다.

- 즉, 하위 레벨의 모듈을 변경하거나 교환하기가 쉬워진다.
- 프로그램 전체를 계층화하고 분리할 때 이 원칙을 사용한다.

// 클라이언트 클래스(송신만)
class Client
{
public:
    // 클라이언트 클래스 전용 송신용 추상 인터페이스
    class Sender
    {
    public:
        virtual ~Sender() {}
        // 데이터 송신
        virtual void send(void* data, unsigned int size) = 0;
    };
public:
    // 생성자로 송신 클래스를 받음
    Client(Sender* sender) : sender_(sender)
    {}
    void update()
    {
        ... 생략
        // 추상 인터페이스를 사용한 송신
        sender_->send(data, size);
    }
private:
    Sender* sender_; // 송신 전용 인터페이스
    ... 생략
};

// 송신 구현 클래스
class Sender : public Client::Sender
{
public:
    Sender(Communicator* commnuicator) :
    communicator_(communicator) {}
    // 송신
    virtual void send(void* data, unsigned int size) override
    {
        commnuicator_->send(data, size);
    }
private:
    Commuicator* commuicator_;
};


* keyword(virtual, override, final)
- virtual : 가상함수(추상메소드) 선언 맨 앞에 작성
- override : 가상함수(추상메소드)를 명시적으로 오버라이딩함을 표현하고자 할 때, 선언 맨 뒤에 작성

- final : 상속을 방지하기 위한 키워드(클래스와 가상함수 모두 사용 가능)로, 선언 맨 뒤에 작성


+ 6. 데메테르(디미터) 법칙("최소 지식의 원칙")

- SOLID 원칙에는 없지만, 객체 지향 설계에서 매우 중요한 클래스 관련 규칙

- 직접적인 친구와만 관련한다.

여기서 "친구"란,

1) 자기 자신

2) 자신이 가지는 클래스

3) 매개변수로 전달한 클래스

4) 멤버함수 내부에서 실체화한 클래스

- 클래스에 적당히 getter() 를 만드러간 Singleton 패턴을 사용해버리면

데메테르 법칙을 위반하는 클래스가 만들어진다.

- 무조건인 적용보다는 참고를 위한 방침 정도로 생각하자.

class Game
{
    Game()
    {
        initialize(); // 자기 자신
    }
    void initialize()
    {
        ... 생략
    }
    void draw(Renderer& renderer) // 매개 변수로 전달한 클래스
    {
        ... 생략
    }
    void save(const std::string& fileName)
    {
        // 멤버 함수 내부에서 실체화한 클래스
        std::ofstream file(fileName);
        ... 생략
    }
private:
    Score score_; // 자신이 가지는 클래스
};

 

* 싱글톤(Singleton) 패턴
- 애플리케이션이 시작될 때 어떤 클래스가 최초 한번만 메모리를 할당하고(Static)

그 메모리에 인스턴스를 만들어 사용하는 디자인 패턴
- 싱글톤 패턴을 쓰는 이유는, 고정된 메모리 영역을 얻으면서

한번의 new로 인스턴스를 사용하기 때문에 메모리 낭비를 방지할 수 있음
- 또한 싱글톤으로 만들어진 클래스의 인스턴스는 전역 인스턴스이기 때문에

다른 클래스의 인스턴스들이 데이터를 공유하기 쉬움
- 싱글톤 인스턴스가 너무 많은 일을 하거나 많은 데이터를 공유시킬 경우
다른 클래스의 인스턴스들 간에 결합도가 높아져 "개방-폐쇄 원칙" 을 위배하게 됨.

즉, 객체 지향 설계 원칙에 어긋나게 되어 수정이 어려워지고 테스트하기 어려워진다.


4. 디자인 패턴

- 과거에 작성한 객체 지향 프로그램에서 반복해 나오는 형태와,

그 해결 방법에 이름을 붙여 패턴화한 것

- 대표적인 디자인 패턴 : GoF(Gang of Four)

- 장래의 확장성 또는 재사용성을 높이기 위한 패턴이 많음

- 객체 지향으로 설계된 게임 엔진 또는 클래스 라이브러리가 다수의 디자인 패턴을 사용

 

* Template Method 패턴
- 부모 클래스에 정형화한 처리 과정을 정의하고, 처리가 다른 부분을 자식 클래스로 구현하는 패턴
- 상속을 사용한 재사용의 고전적인 패턴

- 클래스 라이브러리의 애플리케이션 프레임워크 등에 자주 사용됨

* Strategy 패턴
- 알고리즘이 변화하는 부분을 클래스화해서 교환할 수 있게 하는 패턴
- 교환할 수 있는 알고리즘을 구현한 클래스가 바로 "전략(Strategy)" 부분에 해당
- switch 조건문이 나온다면, Strategy 패턴으로 해결할 수 없는지 살펴보자.
- Strategy 패턴은 추상 인터페이스를 사용해서 구현 부분을 교환하는 고전적인 패턴

// AI 추상 인터페이스
class AI
{
public:
	virtual ~AI() {}
	virtual void think(Board& board) = 0;
};

// 쉬움 모드 AI
class EasyAI : public AI
{
public:
	virtual void think(Board& board) override
	{
		// 쉬움 모드의 사고 알고리즘 구현
	}
}

// 보통 모드 AI
class NormalAI : public AI
{
public:
	virtual void think(Board& board) override
	{
		// 보통 모드의 사고 알고리즘 구현
	}
}

// 어려움 모드 AI
class HardAI : public AI
{
public:
	virtual void think(Board& board) override
	{
		// 어려움 모드의 사고 알고리즘 구현
	}
}

class ComPlayer
{
public:
	// 생성자로 추상화된 AI를 받음
	ComPlayer(AI* ai) : ai_(ai) {}
	// 소멸자
	~ComPlayer()
	{
		delete ai_;
	}
	// 사고
	void think(Board& board)
	{
		ai_->think(board);
	}
private:
	AI* ai_; // AI 알고리즘
	// 여러가지 멤버변수
};


3. State 패턴
- 객체가 가지는 여러가지 상태를 클래스화하는 패턴
- State 패턴을 사용하지 않으면, switch 조건문으로 조건분기 처리를 해야함
- Strategy 패턴과 구현 방법이 거의 비슷하나(추상 인터페이스 활용),
사용하는 상황이 다르므로 구분해서 사용함

// 상태 추상 인터페이스
class ActorState
{
public:
	virtual ~ActorState() {}
	// 업데이트
	virtual void update(float time) = 0;
};

class Actor
{
public:
	Actor() : state_(nullptr)
	{
		// 대기 상태에서 시작
		changeState(new WaitState(this));
	}
	~Actor()
	{
		delete state_;
	}
	// 상태 업데이트
	void update(float time)
	{
		state_->update(time); // 현재 상태의 업데이트 실행
	}
	// 상태 변경
	void changeState(ActorState* state)
	{
		delete state_; // 오래된 상태 제거
		state_ = state; // 새로운 상태 설정
	}
	... 생략
private:
	ActorState* state_;
};

// 대기 상태
class WaitState : public ActorState
{
public:
	// 생성자
	WaitState(Actor* owner) : owner_(owner)
	{
		// 대기 상태의 초기화 처리
	}
	~WaitState()
	{
		// 대기 상태의 종료 처리
	}
	virtual void update(float time) override
	{
		// 대기 상태 구현
		// 전투 중인지 확인
		if(owner_->isAttack())
		{	
			// 공격 상태로 변경
			owner_->changeState(new AttackState(owner_));
		}
	}
private:
	Actor* owner_; // 상태의 소유자
};

// 걷기 상태
class WalkState : public ActorState
{
    ... 생략
};

// 걷기 상태
class AttackState : public ActorState
{
    ... 생략
};

// 걷기 상태
class DamageState : public ActorState
{
    ... 생략
};


4. Composite 패턴
- 트리 구조로 재귀적인 데이터를 만들어 객체를 관리하는 패턴
- 게임 엔진의 게임객체 관리 등에 자주 사용
- 객체관리 외에도 씬 그래프라고 불리는 그래픽 객체 처리에도 사용됨

// 게임 객체 관리 노드 클래스
class Node
{
public:
	... 생략
	// 자식 추가
	void addChild(Node* child)
	{
		children_.push_back(child);
	}
	// 업데이트
	void update(float deltaTime)
	{
		// 자신을 업데이트
		doUpdate(deltaTime);
		// 자식을 업데이트
		std::for_each(children_.begin(), children_end(),
		[=](Node* child) { child->update(deltaTime); });
	}
	// 렌더링
	void draw(Renderer& renderer)
	{
		// 자신을 렌더링
		doDraw(renderer);
		// 자식을 렌더링
		std::for_each(children_.begin(), children_.end(),
		[&](Node* child) { child->draw(renderer) });
	}

private:
	// 자신을 업데이트
	virtual void doUpdate(float deltaTime)
	{
		(void)deltaTime;
	}
	// 자신을 렌더링
	virtual void doDraw(Renderer& renderer)
	{
		(void)renderer;
	}
	// 자식노드
	std::list<Node*> children_; // Node 클래스를 자기 참조
	// 여러가지 멤버변수
};
// 플레이어 클래스
class Player : public Node
{
	... 생략
private:
	// 업데이트
	void doUpdate(float deltaTime) override
	{	
		... 생략
		if(input.isBomb())
		{
			// 폭탄 공격
			addChild(new Bomb(position_));
		}
	}
	// 렌더링
	virtual void doDraw(Renderer& renderer)
	{
		// 플레이어 렌더링 처리
	}
	... 생략
};

// 플레이어의 옵션 캐릭터
class PlayerOption : public Node
{
	... 생략
};

// 폭탄
class Bomb : public Node
{
	... 생략	
};

// 적 캐릭터
class Enemy : public Node
{
	... 생략
};
class MyGame
{
	... 생략
	// 게임 초기화
	void initialize()
	{
		// 플레이어 생성
		auto player = new Player(Vector2(100, 320));
		// 플레이어 노드 아래에 객체 추가
		player->addChild(new PlayerOption(player));
		player->addChild(new PlayerOption(player));
		player->addChild(new PlayerOption(player));
		// 플레이어를 루트 객체에 추가
		root_.addchild(player);
		// 적 캐릭터를 루트 객체에 추가
		root_.addChild(new Enemy(Vector2(500, 320)));
	}
	// 업데이트
	void upadte(float deltaTime)
	{
		root_.update(deltaTime); // 모든 노드가 재귀적으로 업데이트
	}
	// 렌더링
	void draw(Renderer& renderer)
	{
		root_.doDraw(renderer); // 모든 노드가 재귀적으로 렌더링
	}
private:
	Node root_; // 모든 노드의 부모가 되는 루트 노드
};


5. Iterator 패턴
- 컨테이너 클래스 내부 요소에 순서대로 접근하는 방법을 제공함
- 컨테이너 클래스 내부의 자료구조를 은폐한채 쉽게 접근하는 방법을 제공함
- 이를 통해 자료구조의 구현상세를 캡슐화할 수 있음

- STL 컨테이너에 있는 vector, list, map 등의 클래스도 Iterator 패턴을 사용
- STL 알고리즘 함수도 자료구조에 관계없이 재사용할 수 있는데, 이것도 Iterator 패턴의 효과

- iterator 클래스가 구현된 클래스는 간단한 for 반복문으로 각 요소에 접근할 수도 있음(C++11 for 구문)

- Iterator 패턴의 다른 구현 방법으로, "내부 이터레이터"가 있음(람다식을 사용함)

// 게임 객체 관리 노드(트리 구조를 예시로 함)
class Node
{
public:
	... 생략
	// 내부 이터레이더
	void each(std::function<void (Node*)> fn)
	{
			fn(this);
			for(auto child: children_)
			{
				child->each(fn); // 자식노드 순회
			}
	});
private:
	// 자식노드
	std::list<Node*> children_;
	// 여러가지 멤버변수
};

/* 사용자 코드 */
// 모든 노드 렌더링
root_.each([&](Node* node) { node->draw(renderer); });


6. Observer 패턴
- 어떤 객체의 상태 변화를 다른 객체에 통지해주는 패턴
- GUI 버튼의 이벤트 통지 등에 많이 사용됨

- Observer 패턴은 GUI를 다루는 클래스 라이브러리에서 필수로 쓰이는 패턴

// 버튼 클릭 관측자 인터페이스(관측자를 추상화)
class ButtonClickListner
{
public:
	// 가상 소멸자
	virtual ~ButtonClickListner() {}
	// 버튼이 클릭되었을 때
	virtual void onClick() = 0;
};

// 버튼 클래스(관측자에 통지하는 역할)
class Button
{
public:
	// 관측자 등록
	void addListener(ButtonClickListner* listener)
	{
		listeners_.push_back(listener);
	}
	// 클릭
	void click()
	{
		// 버튼이 클릭되었을 때 관측자에 통지
		notify();
	}
private:
	// 관측자에 통지
	void notify()
	{
		// 버튼이 클릭되었을 때 관측자에 통지
		for(auto listener : listeners_)
		{
			listener->onClick();
		}
	}
	// 이벤트를 받을 관측자들
	std::vector<ButtonClickListner*> listeners_;
	... 생략
};

// 타이틀 씬 클래스
class TitleScene : public ButtonClickListner
{
public:
	// 타이틀 씬 초기화
	void initialize()
	{
		// 스타트 버튼에 자기 자신을 리스너로 등록
		startButton_.addListener(this);
	}
	// 스타트 버튼이 눌렀을 때 실행되는 함수
	virtual void onClick() override()
	{
		// 씬 변경
		changeScene(GamePlay);
	}
	... 생략
};

- C++11의 람다식과 STL의 function 클래스를 사용하면 더 간단하게 구현할 수 있다.

// 버튼 클래스(관측자에 통지하는 역할)
class Button
{
public:
	// 관측자 등록
	void attachListener(std::function<void (void)>listener)
	{
		// 현재 관측자
		const auto& current = listener_;
		// 람다식으로 새로운 관측자 추가
		listener_ = [=] { current();  listener(); };
	}
	// 클릭
	void click()
	{
		// 버튼이 클릭되었을 때 관측자에 통지
		listener_(); // 추가된 여러 관측자에 통지 가능
	}
	... 생략
private:
	// 이벤트를 통지받을 관측자
	std::function<void (void)> listener_ = [] {}; // 빈 람다식
	... 생략
};

// 타이틀 씬 클래스
class TitleScene
{
public:
	void initialize()
	{
		// 버튼에 리스너 등록
		startButton_.attachListener([=] { changeScene(GamePlay); });
	}
};


7. Singleton 패턴
- 객체 인스턴스화를 1개로 제한하고, 전역에서 접근할 수 있게하는 패턴
- 전역변수를 대신하는 용도로 잘못 사용하는 경우가 많음
- 간단한 구현 방법중 하나는,
1) 일단 생성자를 private로 지정(외부 클래스에서 인스턴스화하지 못하도록 방지)
2) 복사 생성자 및 대입연산자 오버로딩도 private로 지정(복사 금지)
3) 인스턴스 추출을 위한 전용 static 멤버함수를 만들고,

이 내부에서 클래스의 인스턴스화가 딱 한번만 수행되도록 함
4) 전용 static 멤버함수를 통해 인스턴스 변수를 추출하고,

이 멤버함수를 호출하는 형태로 인스턴스 변수를 사용
- Singleton 패턴은 다양한 문제가 발생(데메테르(디미터) 법칙 위반, 독립성이 떨어져 단위테스트가 어려움,
멀티스레드 프로그램에서 사용하면 mutex 클래스 등 배타 제어가 필요, 등등...)

따라서, 남용하지 말자.

// 입력 장치 클래스
class InputDevice
{
public:
	// 인터페이스 추출을 위한 static 멤버함수
	static InputDevice& getInstance()
	{
		// static 변수의 인스턴스는 1개이며, 인스턴스화도 1번만 이루어짐
		static InputDevice self;
		return self;
	}
	bool isLeft() const
	{
		return keyborad_.getKeyState(KEY_LEFT);
	}
	bool isRight() const
	{
		return keyborad_.getKeyState(KEY_RIGHT);
	}
	bool isUp() const
	{
		return keyborad_.getKeyState(KEY_UP);
	}
	bool isDown() const
	{
		return keyborad_.getKeyState(KEY_DOWN);
	}
private:
	// 생성자를 private로 설정(외부에서의 인터페이스화 금지)
	InputDevice() {}
	// 복사 금지
	InputDevice(const InputDevice& other); // 복사생성자
	InputDevice operator=(const InputDevice& other); // 대입연산자 오버로딩
	// 키보드 입력 클래스
	Keyboard keyboard_;
};

if(InputDevice::getInstance().isLeft())
{
	position.x -= 5.0f;
}

if(InputDevice::getInstance().isRight())
{
	position.x += 5.0f;
}



* 디자인 패턴 구현방법은 여러가지일 수 있으나, 패턴 자체가 달라지거나 다른 패턴이 되지는 않는다.
e.g. 
위에서 설명한 Strategy 패턴에서 구현한 방법(추상 인터페이스를 사용했음)을,
1) 템플릿 클래스로 구현 : 그러나, 컴파일시 결합하기에 프로그램 실행중 AI 알고리즘 교환 불가

// 클래스 템플릿화
template<typename AI>
class ComPlayer
{
public:
	// 생성자
	ComPlayer() {}
	// 사고
	void think(Board& board)
	{
		ai_.think(board);
	}		
private:
	AI ai_; // AI 알고리즘(템플릿으로 지정)
};

// 템플릿의 매개변수로 지정
ComPlayer<EasyAI> easyCom; // 쉬움모드 컴퓨터
ComPlayer<NormalAI> normalCom; // 보통모드 컴퓨터
// 사고
easyCom.think(board);
normalCom.think(board);

2) STL의 function 클래스와 람다식을 사용한 구현

// 컴퓨터 플레이어
class ComPlayer
{
public:
	// 생성자에서 함수 객체를 받음
	ComPlayer(std::function<void (Board&)> ai) : ai_(ai) {}
	// 사고
	void think(Board& board)
	{
		ai_(board);
	}
private:
	std::function<void (Board&)> ai_; // AI 알고리즘
};

// 쉬움모드 AI (shared_ptr로 인스턴스 관리)
std::shared_ptr<EasyAI> easyAI = std::make_shared<EasyAI>();
// 쉬움모드 COM
ComPlayer easyCom([=](Board& board) { easyAI->think(board); });
// 사고
easyCom.think(board);

- STL의 function 클래스가 AI의 추상 인터페이스 대신 사용됨

- 추상 인터페이스의 멤버함수가 1개라면 function 클래스로 변환되며,
function 클래스에는 람다식 이외에도 함수 포인터나 함수 객체를 넣을 수 있으므로,
유연성이 추상 인터페이스보다 높다.


* 디자인 패턴 활용

- 프로그램의 규모가 커지고 복잡해질수록 디자인 패턴을 활용하게 된다.

- 디자인 패턴이 "어떤 곳에 어떠한 형태로 사용되는지 아는 것"이 중요하며, 이를 활용하여 적용하자.

- 추가로, 디자인 패턴은 여러 사람과 함께 클래스를 설계할 때 사용하는 용어이다.

 

* 디자인 패턴의 남용
- 소규모 프로그램에 남용하지는 말자. 재사용성과 확장성 등은 높아지지만,

클래스 개수가 많아지고 구조가 복잡해지는 경향이 있기 때문이다.
- 따라서,
1) 일단 객체지향 설계의 원칙 중심으로 클래스를 설계하고,
2) 프로그램의 규모가 크다면, 적절히 디자인 패턴 활용을 고려한다.


5. 클래스 설계의 기본

- 각 클래스의 역할을 정하고, 업무 책임을 분담하는 작업

- 클래스 이름은 담당하는 업무의 역할을 나타냄

- 클래스는 하나의 역할에 대해 하나의 책임을 갖게 설계 해야함

=> 클래스 이름도 간단해지며, 무엇을 하는지도 명확해짐

- 객체 지향 프로그래밍에서는 자료구조와 알고리즘은

객체를 작동시키기 위한 수단에 지나지 않음

(물론 실행속도 측면에서 자료구조와 알고리즘은 매우 중요함)

- 클래스에 어떤 역할과 책임이 있는지가 훨씬 중요하므로 항상 생각하자.


6. 클래스의 역할

 

* 작업 역할 클래스와 관리 역할 클래스

- 작업 역할 클래스 : 구체적인 구현을 하는 말단 클래스. 1개의 역할에 대해 책임을 진다.

- 관리 역할 클래스 : 작업 역할 클래스를 제어하는 클래스.

여러개의 작업 역할 클래스를 엮고 제어하며, 실질적인 작업은 수행하지 않는다.(관리 역할에 집중)

- 1개의 클래스에 너무 많은 일을 주지 않게 설계하자.

 

* 중재 역할 클래스

- 객체들의 조정을 전문으로 담당하는 클래스

- 작업 중재가 역할이므로, 다른 역할은 수행하면 안됨

- 클래스의 결합수를 줄이는 효과가 있어, 클래스 간의 의존관계를 단순화시킬 수 있음

- GoF의 디자인 패턴 중 Mediator 패턴에 해당함

 

* 창구 역할 클래스

- 작업을 의뢰받고, 실질적인 작업을 다른 클래스에 전달하는 역할을 수행

- 중재 역할은 회사 내부에서 동료들의 조정을 담당하지만,

창구 역할은 외부 회사와의 창구 업무를 담당하는 점에서 차이가 있음

- 중재 역할과 마찬가지로 클래스의 결합수를 줄이는 효과가 있음

- 내부 조직 구조를 은폐하는 역할도 담당

- GoF의 디자인 패턴에서 Facade 패턴에 해당함

- 외부와의 인터페이스 부분을 클래스화 하자.

 

* 생성 역할 클래스

- 객체 생성을 전문으로 수행하는 공장과 같은 클래스

- 생성할 객체의 변경 또는 수정이 간단해지게 함

// 액터 공장 클래스
class ActorFactory
{
   // 액터 생성
   // Player, Enemy, Bullet 클래스는 Actor의 유도 클래스
   Actor* createActor(const std::string& name, const Vector2& position)
   {
       if(name == "player") return new Player(position);
       if(name == "enemy") return new Enemy(position);
       if(name == "bullet") return new Bullet(position);
       assert(!"유효하지 않은 이름");
       return nullptr;
   }
};

- 클래스들의 연관을 줄이는 효과도 가져옴

- 객체 생성에 복잡한 과정이 필요한 경우에도 생성 역할 클래스를 만들어서 사용하는 것이 좋음

- GoF의 디자인 패턴에 Abstract Factory 패턴

: 객체 생성을 전문으로 담당하는 클래스를 생성하는 디자인 패턴

이름 그대로 공장 클래스를 추상화하는 패턴

공장 클래스를 추상화하면 공장 자체를 교환할 수 있게 됨

공장을 통째로 교환해버리면 생성하는 클래스도 교환할 수 있음

class ActorAbstractFactory
{
public:
    virtual ~ActorAbstractFactory(){}
    virtual Actor* createActor(const std::string& name,
                               const Vector2& position) = 0;
};

// 액터 공장
class ActorFactory : public ActorAbstractFactory
{
   // 액터 생성
   Actor* createActor(const std::string& name, const Vector2& position)
   {
       if(name == "player") return new Player(position);
       if(name == "enemy") return new Enemy(position);
       if(name == "bullet") return new Bullet(position);
       assert(!"유효하지 않은 이름");
       return nullptr;
   }
};

// 하이퍼 액터 공장
class ActorFactory
{
   // 액터 생성
   Actor* createActor(const std::string& name, const Vector2& position)
   {
       if(name == "player") return new HyperPlayer(position);
       if(name == "enemy") return new HyperEnemy(position);
       if(name == "bullet") return new HyperBullet(position);
       assert(!"유효하지 않은 이름");
       return nullptr;
   }
};

 

* 전용 클래스와 범용 클래스

- 특정 어플리케이션에서만 사용하는 전용 클래스와

재사용을 목적으로 하는 범용 클래스를 구별해서 설계하는 것이 좋음

1) 특정 애플리케이션 전용

2) 특정 애플리케이션 범용

3) 게임 애플리케이션 범용

4) 다양한 애플리케이션 범용

- 전용 클래스(what)는 범용 클래스(how)를 사용해서 구현함

- 클래스 라이브러리로 제공되는 클래스는 범용 클래스

- 사용자가 사용하기 어려운 클래스를 그대로 사용하지 말고,
사용자가 사용하기 쉬운, 필요한 인터페이스만을 추출한 전용 클래스를 사용해

간접적으로 사용하는 것이 좋음 ("인터페이스 분리 원칙", 불필요한 복잡성을 배제)


7. 클래스의 책임

- 클래스의 하나의 역할을 제대로 찾았다면, 이에 걸맞은 하나의 책임을 제대로 이양해야 함

- 클래스에 책임 부담이 제대로 되지 않으면, 해당 클래스를 사용하는 측의 코드가 복잡해짐

- 사용자측 코드가 간단해질수 있도록, 담당 클래스에 책임을 제대로 이양하자.

class Score
{
public:
    // 생성자
    Score(int score = 0) : score_(0)
    {
        add(score);
    }
    // 점수 추출
    int get() const
    {
        return score_;
    }
    // 테트로미노 낙하
    void dropTetrimino()
    {
        add(1);
    }
    // 블록 제거
    void removeBlocks(int lines)
    {
        assert(0 <= lines && lines <= 4);
        // 제거된 라인 수마다의 점수를 저장한 배열
        static const int REMOVE_LINE_SCORES[] = 
        { 0, 40, 100, 300, 12000 };
        
        add(REMOVE_LINE_SCORES[lines]);
    }
private:
    void add(int score)
    {
        static const int MAX = 9999999; // 점수의 상한
        score_ = std::min(score_ + score, MAX);
    }
private:
    int score_; // 점수
};

/* 사용자측 코드 */

// 테트리미노 낙하
if(input_.isDropTetrimino())
{
    score_.dropTetrimino();
    ... 생략
}
... 생략
// 라인 제거
const int lines = blocks_.removeLine();
score_.removeBlocks(lines);

/* 득점 계산과 관련된 구현의 자세한 내용은
Score 클래스에 의해서 완전히 은폐됨
따라서 사용자의 부담이 줄어들고 코드가 단순해짐
점수와 관련된 사양에 변경이 있더라도,
Score 클래스 내부를 변경하는 것만으로 충분함 */

8. 클래스의 추상도

- 추상도가 높을수록 코드가 단순해져서 수정 또는 변경 등에도 유연하게 대응할 수 있음
- 추상도를 높이려면 "문제의 본질을 파악"할 수 있어야 함
- 추상도를 높이면 구현 상세가 캡슐화되고 코드가 단순해짐
- 클래스 사용자측 시점에서 탑다운해서 생각하는 것이 보다 추상도 높은 클래스 설계에 도움이 됨
- 항상 사용자 입장에서 추상도 높은, 이상적인 인터페이스를 어떻게 생성할지 생각해보자.


9. 클래스 결합

- 클래스 결합의 좋고 나쁨은 결합 수로만은 판단할 수 없음
- 추상 인터페이스를 작성하면 구현 수단을 제공하는 클래스와의 관계를 소결합으로 만들 수 있음.

따라서, 구현 수단을 교환할 수 있음

- 추상 인터페이스를 사용하면 구현 수단이 다른 환경에서도 재사용할 수 있는 클래스를 만들 수 있음
- 구현 수단이 변화하는 부분은 추상 인터페이스화 대상 후보임

class Circle
{
public:
	// Circle 전용 렌더링 인터페이스
	class Renderer
	{
	public:
		virtual ~Renderer() {}
		// 렌더링
		virtual void draw(const Vector2& center, float radius) = 0;
	};
public:
	// 생성자
	Circle(const Vector2& center, float radius) :
		center_(center), radius_(radius)
	{}
	// 렌더링
	void draw(Renderer& renderer)
	{
		renderer.draw(center_, radius_);
	}
	// 면적 계산
	double area() const
	{
		return radius_ * radius_ * M_PI;
	}
private:
	Vector2 center_; // 중심
	float radius_; // 반지름
};

// 콘솔 화면 전용
class ConsoleRenderer : public Circle::Renderer
{
public:
	virtual void draw(const Vector2& center, float radius) override
	{
		std::cout << "x:" << center.x << "y:" << center.y
					<< "r:" << radius << std::endl;
	}
};


// 그래픽 화면 전용
class GraphicsRenderer : public Circle::Renderer
{
public:
	GraphicsRenderer(Graphics& renderer) : renderer_(&renderer) {}
	virtual void draw(const Vector2& center, float radius) override
	{
		renderer_->drawCircle(center, radius);
	}
private:
	Graphics* renderer_; // 그래픽 화면 전용 렌더러
};

- C++11의 람다식을 사용하여 구현할 수도 있음

- Circle::Renderer 클래스 대신, STL의 function 클래스를 사용해 변경

- Circle::Renderer 클래스처럼 멤버함수를 하나만 가지는 추상 인터페이스는,

STL의 function 클래스를 사용해 간단하게 코드를 변환할 수 있음

class Circle
{
public:
	// 생성자
	Circle(const Vector2& center, float radius) :
		center_(center), radius_(radius)
	{}
	// 렌더링(매개변수로 람다식을 받음)
	void draw(std::function<void (const Vector2&, float)> renderer)
	{
		renderer(center_, radius_);
	}
	// 면적 계산
	double area() const
	{
		return radius_ * radius_ * M_PI;
	}
private:
	Vector2 center_; // 중심
	float radius_; // 반지름
};

// 콘솔 화면에 출력
circle.draw([](const Vector2& center, float radius)
{
	std::cout << "x:" << center.x << "y:" << center.y
				<< "r:" << radius << std::endl;
});

// 그래픽 화면에 출력
circle.draw([](const Vector2& center, float radius)
{
	graphics.drawCircle(center, radius);
});

10. 추상 인터페이스 사용 방법

 

* 클래스 공통화
- 서로 다른 클래스라도 공통 추상 인터페이스를 가진다면,
공통 추상 인터페이스를 통해 서로 다른 클래스를 일괄 관리 할 수 있음

- 부모 클래스 또는 추상 클래스를 사용해 클래스를 한꺼번에 관리하는 것과 같은 방법

* 구현 수단 교환

- 구현 수단을 교환하고 싶다면 추상 인터페이스를 사용하자.
- 특정 구현 수단에 직접 결합해버리면 구현 수단을 변경할 때마다 코드를 수정해야 함
- 사용자 측과 구현 측 중간에 추상 인터페이스를 거치게 하면,
사용자 측의 코드를 변경하지 않더라도 구현수단을 교환할 수 있음
- GoF 디자인 패턴의 Strategy 패턴

* 변화하기 쉬운 부분 관리
- 변화하기 쉬운 부분을 추상 인터페이스화하면, 클래스 외부로 빼내어 분리할 수 있음(개방/폐쇄의 원칙)

* 부적절한 결합 관계 분리
- 직접 결합되어서는 안되는 클래스를 분리할때 추상 인터페이스를 사용
- 중재 역할 또는 창구 역할을 담당하는 추상 인터페이스를 작성하여,
클래스의 직접적 결합을 분리해 소결합으로 만들 수 있음.

* 패키지 경계 분리
- 프로그램 규모가 커지면 관련 있는 클래스를 그룹화해서 패키지(이름공간, 모듈)로 정리함
- 이 때, 다른 패키지에 있는 클래스와 직접 결합해버리면 변경에 영향을 받을 가능성이 존재

- 패키지 내부의 클래스가 추상 인터페이스를 거쳐 외부 클래스를 사용하면, 이러한 문제점을 해결 가능

- 패키지를 독립시키고 외부 변경을 피하고자 할 때 추상 인터페이스를 사용(의존 관계 역전의 원칙)

* 추상 인터페이스는 사용자측에 배치
- 사용자 측의 내부 클래스로 만들 수 있다면 원칙적으로 사용자 측에 배치
- 내부 클래스로 만드는것이 적절치 않다면, 사용자의 클래스가 있는 패키지 안에 넣어주자.

- 추상 인터페이스를 구현 측이 아닌, 사용자 측에 배치하자.


* 추상 인터페이스는 분리와 교환을 위한 도구
- 추상 인터페이스는 주로,
1) 결합관계 분리
2) 구현 교환
할 때 사용한다.

- 추상 인터페이스 사용시 설계는 확실히 유용해지나,

클래스 수가 증가하며 복잡해지는 단점도 생긴다.


11. 그 밖의 클래스 설계시 주의점 또는 테크닉

 

* 생성자로 완전한 상태 만들기
- 객체는 생성자가 호출된 시점에 완전한 상태를 생성하게끔 설계하기
- 어떤 순서로 멤버함수를 호출해도 문제없이 작동하게 하자.
- 클래스는 생성자에서 모든 초기화 작업을 수행하고,
소멸자에서 완전한 종료처리를 할 수 있어야 함.

* 멤버함수 호출순서와 관련한 대처 방법
- 멤버함수의 호출순서에 제약이 있다면, 순서대로 호출할 것을 설계적으로 보장해야 함
이를 위해, 호출순서를 제어하는 클래스를 만들어줘야 함(제어 역할 클래스)

/* 상속 스타일로 구현 */

// 게임 클래스
class Game
{
public:
	// 실행
	void run() // 실행 순서를 보장하는 함수
	{
		initialize();
		while(isRunning())
		{
			update();
			draw();
		}
		finalize();
	}

private:
	// 초기화
	virtual void initialize() = 0;
	// 실행 중인지 확인
	virtual bool isRunning() const = 0;
	// 업데이트
	virtual void update() = 0;
	// 렌더링
	virtual void draw() = 0;
	// 종료
	virtual void finalize() = 0;
};
/* 이양 스타일로 구현 */

// 게임 클래스
class Game
{
public:
	virtual ~Game() {}
	// 초기화
	virtual void initialize() = 0;
	// 실행 중인지 확인
	virtual bool isRunning() const = 0;
	// 업데이트
	virtual void update() = 0;
	// 렌더링
	virtual void draw() = 0;
	// 종료
	virtual void finalize() = 0;
};

// 게임 실행 클래스
class GameRunner
{
public:
	// 생성자
	GameRunner(Game* game) : game_(game) {}
	// 소멸자
	~GameRunner()
	{
		delete game_;
	}
	// 실행
	void run() // 실행 순서를 보장하는 함수
	{
		game_->initialize();
		while(game_->isRunning())
		{
			game_->update();
			game_->draw();
		}
		game_->finalize();
	}
private:
	Game *game_;
};

- 상속에 사용한 스타일은 Template Method 패턴 사용

- 이양에 사용한 스타일은 Strategy 패턴 사용

- Template Method 패턴 대부분은 Strategy 패턴을 사용한 이양 스타일로 변경 가능

* 초기화는 생성자, 뒤처리는 소멸자로 시행

- 초기화 처리와 종료 처리가 필요한 API 또는 클래스가 있다면,

생성자와 소멸자를 사용해서 처리를 확실하게 해주자.
- 파일 또는 텍스터 등의 리소스 관리도 클래스의 생성자와 소멸자에서 확실히 해주자.
코드를 줄이는 것은 물론 단순 실수도 방지가 가능하다.

e.g. 실제로 C++ 표준 라이브러리의 fstream 계열 클래스는 파일 열고 닫기를

생성자와 소멸자에서 수행함

- 설계구조에 의해서 작동을 보장한다는 것이 중요
- 생성자와 소멸자를 사용한 자원관리기법 - RAII(Resource Acquisition Is Initialization)

#include <iostream>

// 텍스트 파일 출력 클래스
class TextFileWriter
{
public:
    // 생성자
	TextFileWriter(const std::string& fileName) : file_(nullptr)
	{
		file_ = fopen(fileName.c_str(), "w");
	}
	// 소멸자
	TextFileWriter()
	{
		fclose(file_);
	}
	// 쓰기
	void write(const std::string& text)
	{
		fputs(text.c_str(), file_);
	}
private:
	FILE* file_; // 파일 구조체
};

// 클래스화 한 예
int main(int argc, char* argv[])
{
	TextFileWriter file("test.txt");
    file.write("message");
	return 0; // 종료시 자동적으로 소멸자가 fclose 해줌
}
// 텍스처 클래스
class Texture
{
	// 생성자
	Texture(const Image& image)
	{
		// 텍스처 객체 생성
		glGenTextures(1, &texture_);
		... 생략
	}
	~Texture()
	{
		// 텍스터 객체 제거
		glDeleteTextures(1, &texture_);
	}
	... 생략
private:
	GLuint texture_; // 텍스처 객체
};


* 구현 상세를 은폐하는 이름 붙이기
- 클래스의 멤버함수 이름은 구현 방법의 상세를 은폐하는 이름으로 붙여주자.
- 구현 방법을 그대로 멤버함수 이름으로 하지말고, 구현 방법을 은폐하는 이름을 붙여주자.

- 멤버함수의 목적을 그대로 함수의 이름으로 사용하는 것도 하나의 방법이다.


12. 클래스 설계 초급편

 

* 대략적인 클래스 조사
1) 대상의 문제가 어떤 구성 요소로 이루어졌는지 떠오르는 대로 적어나가기
2) 클래스 이름만으로 간단한 클래스 다이어그램을 작성하고 클래스 연계를 구상
3) 코드를 작성하면서 계속해서 재설계 진행

* 사용자 관점에서 멤버함수의 사양을 결정
1) 초기 단계의 러프한 설계에서부터 public 멤버함수의 사양을 생각하기

(이러기 위해서는 클래스의 역할과 책임이 명확히 결정되어야 함) 
2) 클래스 사용자 관점에서 생각하는 것이 중요
구현 상세를 사용자가 의식하지 않아도 쉽게 사용할 수 있게 하자.
읽기 좋은 직감적인 이름을 붙여주자.

* 멤버함수 구현
1) 크기가 크더라도 일단 제대로 작동하는 코드를 먼저 작성
2) 각 멤버변수의 행동을 하나하나 작은 함수로 분할(즉, 큰 멤버함수를 작은 멤버함수로 분할)
3) 책임이 많으면 멤버함수가 많아지므로, 멤버함수가 많으면 클래스 분할을 고려

* 역할과 책임을 생각하면서 클래스 분할
1) 클래스의 책임이 작아질 수 있도록 클래스를 분할 - 멤버변수 하나하나가 클래스로 만들 후보임
2) 멤버함수를 분할하다보면 클래스 후보가 보임

"단일 책임 원칙"을 바탕으로 외부로 뺄 수 있는 책임이 있다면

적극적으로 빼내어 다른 클래스로 "이양"하자.
3) 실제 작업하는 클래스인지, 다른 클래스를 관리하는 클래스인지 파악하면 책임을 나누기 쉬워짐

* 결합을 생각
1) 부적절한 클래스와의 결합은 없는지 체크 : 클래스의 단위 테스트의 용이성으로 쉽게 판단 가능
2) 부적절한 결합을 발견했다면, 추상 인터페이스를 작성해서 간접적인 결합으로 변경하자.

* 캡슐화, 응집도, 결합도 확인
- 클래스가 완성되면 캡슐화가 제대로 되어있는지, 응집도가 높은지, 결합도가 낮은지 확인
- 클래스 응집도가 특히 중요

(클래스의 역할과 책임이 명확한지, 하나의 클래스가 지는 책임이 너무 많은게 아닌지 주의)


13. 클래스 설계 중급편

- 프로그래밍 전체의 구조를 파악하자. 그리고 보수성과 이식성을 고려하자.

* 문제 영역과 구현 영역 분리
1) 문제 영역 : 해결해야 할 문제의 핵심부분, 특정 환경에 의존하지 않게 설계해야 함
2) 구현 영역 : OS, 그래픽, 사운드 등의 API를 사용해서 문제 영역의 구현 방법을 제공하는 부분.
작동 환경에 의존하며 호환성이 없음
- 이 둘을 분리하면, 구현 영역만 교환해서 다른 환경으로 이식할 수 있음
- 각 클래스의 응집도를 높이고, 소결합으로 만들어주는 효과도 있음. 단위 테스트도 쉬워짐.

* 문제 영역을 중심으로 설계
- 문제 영역은 애플리케이션 본체가 "무엇인지(WHAT)"를 명시해야 함
- HOW 부분이 구현 영역
- 문제 영역에는 애플리케이션의 개요설명을 작성하고, 구현 영역에는 애플리케이션의 상세설명을 작성하자.

* 추상 인터페이스로 분리
- 문제 영역과 구현 영역을 분리하기 위해, 문제 영역이 요구하는 기능을 추상 인터페이스화하자.
- 추상 인터페이스는 문제 영역 내부에 구현해야 함(의존 관계 역전 원칙)
- 구현 영역에서 문제 영역을 간접적으로 사용하는 추상 인터페이스를 구현하자.
- 구현 영역이 문제 영역에 맞추어야만 한다.(문제 영역에 기반을 두는 탑다운 관점으로 설계가 필요)

* 복잡한 것은 여러개의 계층으로 분리

- 복잡한 애플리케이션은 여러개의 계층을 만들어 단계적으로 구현하자.
- 각 계층의 응집도가 높아지며, 하나하나의 클래스를 축소화하는 효과가 있음

- 프로그램 전체의 설계는 각각의 클래스 설계에 영향을 줌

전체 설계가 나쁘면 코드가 복잡해짐
- 프로그램 설계의 요령은 한번에 많은 일을 하지 않는 것


14. 클래스 설계 고급편

- 클래스 설계 이전의 단계에 관해 다룸(설계 방침)

- 초기 단계의 설계 방침은 이후의 모든 설계에 영향을 줌

- 설계 방침을 지키지 못하면 불필요한 작업이 많아져 생산성이 낮아짐

 

* 컨셉과 컨텍스트
- 좋은 설계를 위해서는 컨셉(기본 개념)과 컨텍스트(문맥)을 고려해야 함
- 설계의도를 명확히 하려면 컨셉이 필요
e.g. "보수성을 우선" 또는 "실행속도를 우선"
- 컨셉이 정해지면 컨셉에 맞는 컨텍스트를 생각하자.
항상 컨텍스트가 컨셉을 제대로 표현하는지 의식하면서 구현하자.

* 규모에 맞는 설계
- 애플리케이션 규모에 맞게 설계하자.

- 규모에 맞게 설계하지 않으면 코드가 무너질 수 있다.

* 코드의 수명
- 수명이 짧은 코드에 과투자를 하지는 말자.
- 재사용 가치가 있는 부분은 시간을 들여 설계하자.
- 항상 비용 대비 효율성을 고민하자.

* 작업의 편의성
- 코드의 단순함보다는 작업의 편의성을 우선시하자.
- 컴퓨터가 아니라, 사람의 입장에서 작업하자.
- 쉽게 공동작업할 수 있고, 변경 또는 수정시 실수가 발생하지 않도록 설계하자.

* 하나의 방법에 관한 집착
- 친숙하다는 이유만으로 하나의 수단에 집착하지 말자.
- 애플리케이션 특징에 맞는 프로그래밍 언어와 개발환경을 선택하자.

* 최소한의 코드만 작성
- "어떻게 하면 코드를 작성하지 않을지" 가 중요하다.
- 이미 있는 것을 다시 만들 필요는 없다.

(기존 라이브러리 또는 미들웨어를 검토하여 괜찮으면 적극적으로 사용하자.)
- 수단에 얽매이지말고 "목적"을 중시하자.


15. 마치며

 

* 객체 지향 설계 : 코드를 정리정돈하는 기술
- 복잡한 응용 프로그램을 클래스라는 작은 부품으로 분해해서 정리, 조합하는 것.
- 분할과 정리 방법에 익숙해지자.

 


- 복잡한 것을 단순하게 만들어야 의미가 있다.

- 원칙을 가급적 지키되, 단순한 쪽을 선택하자.

- 쉽게 복잡해지는 부분, 미래에 변경될 수 있는 부분은 시간을 들여 설계하고,

변경 가능성이 작은 부분 등은 시간을 들이지 않고 간단하게 끝내자.
- 실패를 두려워하지 말자.
- 기술 관련 내용을 접하고, 다른 사람의 코드를 읽자.
- 많이 배우고 적절하게 다루자.


[출처] : 오즈 모리하루 저, "C와 C++ 게임 코드로 알아보는 코딩의 기술", 한빛미디어