Notice
Recent Posts
Recent Comments
Link
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
Archives
Today
Total
관리 메뉴

codingfarm

1장. SOLID 디자인 원칙 - 2 본문

소프트웨어 공학/디자인패턴

1장. SOLID 디자인 원칙 - 2

scarecrow1992 2019. 10. 22. 14:43

3.리스코프 치환 원칙(LisCov Substitution Principle, LSP)

어떤 자식 개체에 접근할 때 그 부모 객체의 인터페이스로 접근하더라도 아무런 문제가 없어야 한다.

 

 

LSP가 준수되지 않는경우

아래는 직사각형 클래스 이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Rectangle {
protected:
    int width, height;
public:
    Rectangle(const int width, const int height)
        : width(width), height(height) {}
 
    int get_width() const { return width; }
    virtual void set_width(const int width) { this->width = width; }
    int get_height() const { return height; }
    virtual void set_height(const int height) { this->height = height; }
 
    int area() const { return width * height; }
};
cs

이제 직사각형의 특별한 경우인 정사각형을 만든다.

이 객체는 가로/세로 get/set 멤버 함수를 모두 오버라이딩 한다.

1
2
3
4
5
6
7
8
9
10
11
class Square : public Rectangle {
public:
    Square(int size) : Rectangle(sizesize) {}
    void set_width(const int width) override {
        this->width = height = width;
    }
 
    void set_height(const int height) override {
        this->height = width = height;
    }
};
cs

 

 

언뜻 보기에는 괜찮아 보이지만 이 접근방법은 문제를 일으킨다.

그 이유는 단지 멤버함수 set에서 가로,세로 값을 모두다 설정할뿐이다. 이게 대체 무슨 잘못이란말인가?

가령 이 객체를 부모객체로 접근한다면 의도치 않은 상황이 생긴다.

1
2
3
4
5
6
void process(Rectangle& r) {
    int w = r.get_width();
    r.set_height(10);
 
    cout << "expected area = " << (w * 10<< ", got = " << r.area() << endl;
}
cs

여기서 expected area와 got의 값은 둘다 같아야 한다.

하지만 매개변수로 Square를 전달한다 가정할 경우 이상이 발생한다.

1
2
Square s(5);
process(s); //기대된 출력 = 50, 구해진 출력 = 100
cs

 

 

2개의 값이 둘다 다르게 나온다.

기존에 가로 세로 값이 5로 설정되었으며 w는 5가된다.

5 * 10 을 통해 50을 얻게된다.

그리고 set_height를 통해 가로세로 값이 둘다 10으로 설정된다.

이로인해 area()로 넓이를 구하면 100이 나온다.

 

비록 작위적인 예제 이지만 LSP를 준수하지 않아서 생긴 문제이다.

이를 방지하기 위한 해결책은 무엇인가? 여러가지가 있지만 애초에 서브 클래스를 만들지 않는것이 있다.

그 대신 아래처럼 Factory 클래스를 두어 직사각형과 정사각형을 따로 생성 하게끔 한다.

1
2
3
4
5
class RectangleFactory{
public:
    static Rectangle create_rectangle(int w, int h);
    static Rectangle create_square(int size);
};
cs

 

정사각형인지 여부를 확인 할 수 있어야 한다.

1
2
3
bool Rectangle::is_square() const {
    return width == height;
}
cs

 

또다른 대표적인 예로는 DirectX12에서 resource를 만들기 위해 ID3D12Device::CreateCommittedResource 함수를 호출할 때 D3DX12_RESOURCE_DESC를 상속받은 CD3DX12_RESOURCE_DESC의 객체를 넣어도 함수가 정상작동하는데에 있다.

 

 

4. 인터페이스 분리 원칙(Interface Segregation Principle, ISP)

또 하나의 작위적인 예를 살펴보자.

프린트, 스캔, 팩스 기능이 합쳐진 복합 기능 프린터를 만든다 생각하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Document{/* */};
 
class IMachine {
public:
    virtual void print(vector<Document*> docs) = 0;
    virtual void fax(vector<Document*> docs) = 0;
    virtual void scan(vector<Document*> docs) = 0;
};
 
class MyFavouritePrinter : IMachine {
public:
    void print(vector<Document*> docs) override;
    void fax(vector<Document*> docs) override;
    void scan(vector<Document*> docs) override;
};
 
cs

 

나쁘지는 않아 보인다. 이제 각 구현을 하청업체에 맡기려 한다. 우리는 각 하청업체에게 기능을 구현 가능하도록 IMachine 인터페이스를 추출하여 제공해준다. 여기서 문제가 발생한다. 각 하청업체가 print, fax, scan의 구현을 따로 구현함에도 불구하고 우리는 각 업체에 불필요한 정보를 제공하고 담당이 아닌 함수의 구현까지 강제한다. 물론 각 업체에서는 빈 함수를 만들어 대응하고, 클라이언트에서는 구현된 함수를 모아서 최종적으로 직접 구현하는 방법도 있다. 하지만 너무 번거로운 방법이다.

 

 

인터페이스 분리원칙이 의미하는 바는 필요에 따라 구현할 대상을 선별할 수 있도록 인터페이스를 별개로 두어야 한다는 것이다.

1
2
3
4
5
6
7
8
9
10
11
class IPrinter {
    virtual void print(vector<Document*> docs) = 0;
};
 
class IScanner {
    virtual void scan(vector<Document*> docs) = 0;
};
 
class IFax {
    virtual void fax(vector<Document*> docs) = 0;
};
cs

 

 

이제 각 기능을 따로 구현 할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
class Printer : IPrinter {
    void print(vector<Document*> docs) override;
};
 
class Scanner : IScanner {
    void scan(vector<Document*> docs) override;
};
 
class Fax : IFax {
     void fax(vector<Document*> docs) override;
};
cs

 

이제 복합기 전체를 나타내는 IMachine 인터페이스를 만들 수 있다.

그리고 이 인터페이스로 복합기를 구현 하면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
#include<iostream>
#include<string>
#include<vector>
 
using namespace std;
 
class Document{/* */};
 
class IMachine {
public:
    virtual void print(vector<Document*> docs) = 0;
    virtual void fax(vector<Document*> docs) = 0;
    virtual void scan(vector<Document*> docs) = 0;
};
 
 
class IPrinter {
public:
    virtual void print(vector<Document*> docs) = 0;
};
 
class IScanner {
public:
    virtual void scan(vector<Document*> docs) = 0;
};
 
class IFax {
public:
    virtual void fax(vector<Document*> docs) = 0;
};
 
class Printer : IPrinter {
public:
    void print(vector<Document*> docs) override;
};
 
class Scanner : IScanner {
public:
    void scan(vector<Document*> docs) override;
};
 
class Fax : IFax {
public:
    void fax(vector<Document*> docs) override;
};
 
 
class IMachine : public IPrinter, public IScanner, public IFax {    };
 
class Machine : public IMachine {
public:
    IPrinter & printer;
    IScanner & scanner;
 
    Machine(IPrinter& printer, IScanner& scanner) : printer(printer), scanner(scanner) {    }
 
    void print(vector<Document*> docs) override {
        printer.print(docs);
    }
 
    void scan(vector <Document*> docs) override {
        scanner.scan(docs);
    }
};
cs

 

 

 

즉, 한덩어리로 인터페이스를 구현하지말고 목적에따라 구분하여 나눔으로써 실제 필요한 인터페이스면 부분적으로 구현하도록 한다.

 

 

 

 

 

5.의존성 역전 원칙(Dependency Inversion Principle, DIP)

DIP는 아래와 같다.

고수준 모듈은 저수준 모듈의 구현에 의존해서는 안된다. 저수준 모듈이 고수준 모듈에서 정의한 추상 타입에 의존해야 한다.

전통적인 DIP의 정의는 아래와 같다

A. 상위 모듈이 하위 모듈에 종속성을 가져서는 안된다. 양쪽 모두 추상화에 의존해야한다.

B. 추상화가 세부사항에 의존해서는 안된다. 세부 사항이 추상화에 의존 해야 한다.

  • 종속성은 실 구현 타입이 아닌 인터페이스 또는 부모클래스에 있어야한다.

 

의존 : 객체사이에 연관관계가 있을경우 형성되는 관계

의존관계가 생기는 방법은 다양하다.

  • 클래스 A가 클래스 B의 인스턴스를 포함할 경우
  • 클래스 A의 메서드 매개변수로 클래스 B를 받을경우

B는 A를 몰라도 내부 구현이 가능하지만 A는 B를 참조하여 내부 구현을 하기 때문이다.

그러므로 의존관계를 맺을때는 변화가 잦은것 보다는, 변화가 거의 없는것에 의존해야한다.

 

가령 액션게임에서 캐릭터는 무기를 소지하는데, 이때 캐릭터는 무기에 의존적이게 된다.

동일한 명령이 캐릭터에 주어지더라도 무기에 따라 캐릭터 애니메이션이 바뀌게 된다. 어떤 애니메이션을 실행할지 알기 위해선, 캐릭터는 자신이 든 무기의 종류를 알아야만 하며 이에 따라 캐릭터는 무기에 의존적이게 될 수 밖에 없다.

다만 무기는 자신을 쥔 캐릭터가 무엇이든 무기의 능력치가 변할일이 없다. 물론 무기가 캐릭터를 완전히 의식하지 않을수는 없겠지만, 이상적인 경우에는 캐릭터에 대한 의식을 최소화 하는것이 최선이다.

그런데 캐릭터가 검만 드는것도 아니고 다양한 종류의 무기를 들고싶어한다. 게임에서 무기 교체는 매우 잦은일이기에 위와 같은 설계방식으로는 무기가 바뀔때마다 Character에게 안좋은 영향이 가게 된다.

그렇다고 Sword가 Character에 의존하는것도 말이 안된다. 

이에 대한 해결법은 아래와 같다.

캐릭터가 구체적인 무기가 아닌 추상화 된 Weapon interface에 의존되게 함으로써 어떤 무기를 가지더라도 그 영향을 받지 않는 형태로 구성한다.

이처럼 자신보다 변하기 쉬운 것에 의존하던 것을 추상화된 인터페이스나 상위 클래스를 두어 변하기 쉬운 것의 변화에 영향받지 않게 하는 것 의존 역전 원칙이다.

 

 

 

또다른 예를 들어보자. 자동차에 대한 클래스를 정의하려 한다. 이 자동차는 엔진과 로그기능을 필요로 한다.

자동차는 두 기능 모두에 의존성을 가진다..

자동차가 생산될때마다 생산되었다는 로그를 출력해야하는데, 이 로깅이 일어나는 매체는 콘솔출력, 이메일, 핸드폰 SMS, 프린터등 매우 다양할것이다.

또한 사용자가 원할때마다 자동차의 스펙을 출력할 수 있어야하는데, 이때 자동차의 스펙은 매우 다양할것이다.

이때 Car 클래스는 엔진과 로그의 변화에 의존을 받지 않게끔 하기 위해 추상화된 인페이스를 상위 클래스에 두게 할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
#include<iostream>
#include<memory>
 
using namespace std;
 
class IEngine {
private:
    const float volume;
    const int horse_power;
 
protected:
    void printSpec() {
        cout << "volume : " << volume << ", horse_power : " << horse_power << endl;
    }
 
public:
    IEngine(float volume, int horse_power) : volume(volume), horse_power(horse_power) {}    
    virtual void WriteToStream() = 0;
};
 
class ArkReactor : public IEngine {
public:
    ArkReactor(float volume, int horse_power) : IEngine(volume, horse_power) {}
 
    void WriteToStream() override {
        cout << "<< ArcReactor >>" << endl;
        printSpec();
    }
};
 
class DieselEngine : public IEngine {
public:
    DieselEngine(float volume, int horse_power) : IEngine(volume, horse_power) {}
 
    void WriteToStream() override {
        cout << "<< DieselEngine >>" << endl;
        printSpec();
    }
};
 
class SteamEngine : public IEngine {
public:
    SteamEngine(float volume, int horse_power) : IEngine(volume, horse_power) {}
 
    void WriteToStream() override {
        cout << "<< SteamEngine >>" << endl;
        printSpec();
    }
};
 
class ILogger {
public:
    virtual ~ILogger() {}
    virtual void Log(const string& s) = 0;
};
 
class ConsoleLogger : public ILogger {
public:
    ConsoleLogger() {}
    void Log(const string& s) override {
        cout << "Log : " << s.c_str() << endl;
    }
};
 
class EmailLogger : public ILogger {
public:
    EmailLogger() {}
    void Log(const string& s) override {
        cout << "Email : " << s.c_str() << endl;
    }
};
 
class SMSLogger : public ILogger {
public:
    SMSLogger() {}
    void Log(const string& s) override {
        cout << "SMSLogger : " << s.c_str() << endl;
    }
};
 
class PrinterLogger : public ILogger {
public:
    PrinterLogger() {}
    void Log(const string& s) override {
        cout << "Printer : " << s.c_str() << endl;
    }
};
 
class Car {
public:
    unique_ptr<IEngine> engine;
    shared_ptr<ILogger> logger;
 
    Car(unique_ptr<IEngine> engine, const shared_ptr<ILogger>& logger)
        : engine{ move(engine) }, logger{ logger }{
        logger->Log("making a car");
    }
 
    void printCarSpec() {
        cout << "car's engine : ";
        engine->WriteToStream();
    }
};
 
 
int main(void) {
    Car cars[3= {
        Car(make_unique<ArkReactor>(1010000), make_shared<ConsoleLogger>()), 
        Car(make_unique<DieselEngine>(1001000), make_shared<EmailLogger>()),
        Car(make_unique<SteamEngine>(1000100), make_shared<SMSLogger>())
    };
    cout << endl;
 
    for (int i = 0; i < 3; i++) {
        cars[i].printCarSpec();
        cout << endl;
    }
 
    return 0;
}
cs

위에서 Car의 생성자를  직접 호출하는방법이 아닌 Boost.DI를 사용하는 방법이 있다고는 하는데 아직정확히 이해가 안된다.

 

Comments