9 분 소요

리스코프 치환 원칙자식 개체는 부모 개체를 완전하게 치환할 수 있어야 한다 는 원칙입니다.

조금 풀어 쓰면,

  1. 다형적 동작을 위해 상속받은 자식은 부모와 is-a 관계 여야 하고,
  2. 자식은 부모와 다른 행동을 하지 말고, 같은 행동을 하게 하라.

라는 뜻입니다. 더군다나 !!!완전하게!!!말이죠.

이 원칙을 준수하면,

  1. 코드 분석시 자식 개체의 구현을 신경 쓰지 않아도 되므로 코드 분석 용이성이 향상됩니다.
  2. 코딩 계약 에 의한 코드 구현시 신뢰성이 확보되고, 이에 따른 시스템 안정성도 확보됩니다.

위반 사례 : 부모와 다른 동작을 하는 자식

우리는 개체지향의 다형성을 이용하여 부모 클래스를 상속하여 기능을 확장합니다.

왼쪽/상단 좌표와 크기를 입력받아 자신의 모양을 출력하는 Shape 개체를 생각해 봅시다. 부모 클래스인 Shape에서 m_Left, m_Top, m_Width, m_Height 값을 입력받고, Draw() 함수를 virtual로 작성하여 자식 클래스에서 재구현 하여 그릴 수 있게 했습니다.

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
// 부모 클래스 입니다.
class Shape {
private:
    int m_Left;
    int m_Top;
    int m_Width;
    int m_Height;
public:
    Shape(int l, int t, int w, int h) :
        m_Left(l),
        m_Top(t),
        m_Width(w),
        m_Height(h) {}
    virtual ~Shape() {} // 다형 소멸 하도록 public virtual
public:
    // Get/Set 함수
    int GetLeft() const {return m_Left;}
    int GetTop() const {return m_Top;}
    int GetWidth() const {return m_Width;}
    int GetHeight() const {return m_Height;}

    void SetLeft(int val) {m_Left = val;}
    void SetTop(int val) {m_Top = val;}
    void SetWidth(int val) { m_Width = val;}
    void SetHeight(int val) { m_Height = val;}

    // m_Left, m_Top, m_Width, m_Height로 자식 클래스들이 알아서 그려야 함
    virtual void Draw() const = 0; 
};

하기는 자식 클래스 입니다. Draw()함수를 각각 잘 구현했다 가정하고요.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 자식 클래스 입니다.
class Rectangle : public Shape {
public:    
    Rectangle(int l, int t, int w, int h) : 
        Shape(l, t, w, h) {}
    virtual ~Rectangle() override {}

    virtual void Draw() const override {
        // 사각형을 그립니다.
    }
};

// 자식 클래스 입니다.
class Ellipse : public Shape {
public:
    Ellipse(int l, int t, int w, int h) : 
        Shape(l, t, w, h) {}
    virtual ~Ellipse() override {}

    virtual void Draw() const override {
        // 타원을 그립니다.
    }
};

하기는 테스트 입니다. RectangleEllipse를 생성하고, Draw를 호출하여 자기 자신을 그립니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
TEST(TestPrinciple, LiskovSubstitution) {
    Rectangle rect(0, 0, 10, 20);
    Ellipse ellipse(0, 0, 10, 20);

    // 생성자에 입력한 값이 잘 전달됐는지 확인합니다.
    EXPECT_TRUE(rect.GetLeft() == 0 && rect.GetTop() == 0 && rect.GetWidth() == 10 && rect.GetHeight() == 20);   
    EXPECT_TRUE(ellipse.GetLeft() == 0 && ellipse.GetTop() == 0 && ellipse.GetWidth() == 10 && ellipse.GetHeight() == 20);  

    // 부모 클래스 포인터로 받아서 Draw를 호출해 봅니다.
    std::vector<Shape*> shapes;
    shapes.push_back(&rect);
    shapes.push_back(&ellipse);
    for(std::vector<Shape*>::iterator itr = shapes.begin(); itr != shapes.end(); ++itr) {
        (*itr)->Draw();
    }
}

아직은 좋습니다. Draw()를 잘 구현했다면, 사각형과 타원을 잘 그릴 것입니다. 이제 정사각형을 추가해 봅시다.

정사각형은 m_Widthm_Height가 동일해야 합니다. 그래서 부모 클래스인 ShapeSetWidth()SetHeight()를 재구현하여 동기화할 필요가 있습니다.

먼저 부모 클래스의 SetWidth()SetHeight()virtual로 만들어 재구현 할 수 있게 합니다.

1
2
3
4
5
6
7
8
// 부모 클래스 입니다.
class Shape {
    ...
    // 자식이 재구현할 수 있도록 virtual로 변경합니다.
    virtual void SetWidth(int val) {m_Width = val;}
    virtual void SetHeight(int val) {m_Height = val;}
    ...
};

다음으로 정사각형을 그리는 Square 클래스를 구현합니다. 생성자에서 h 값은 무시하고 w 값을 사용하도록 조정하고, SetWidth()SeitHeight() 에서도 m_Width 값을 사용하도록 재구현했습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 자식 클래스입니다. 생성자를 손보고, SetWidth와 SetHeight를 손봤습니다.
class Square : public Shape {
public:
    // h 는 무시하고 w만 사용합니다.
    Square(int l, int t, int w, int /*h*/) : 
        Shape(l, t, w, w) {}
    virtual ~Square() override {}

    // m_Width를 바꾸면 m_Height도 바꿉니다.
    virtual void SetWidth(int val) override {
        Shape::SetWidth(val);
        Shape::SetHeight(val);
    }
    // m_Height를 바꾸면 m_Width를 바꿉니다. 어차피 SetWidth()랑 똑같이 동작하니 SetWidth를 불러줍니다.
    virtual void SetHeight(int val) override {
        SetWidth(val);
    }

    virtual void Draw() const override {
        // 정사각형을 그립니다.
    }
};

하기는 테스트 코드입니다. Square를 생성하고 Draw()합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
TEST(TestPrinciple, LiskovSubstitution) {
    Rectangle rect(0, 0, 10, 20);
    Ellipse ellipse(0, 0, 10, 20);
    Square square(0, 0, 10, 20); // m_Height는 내부적으로 10으로 설정됩니다.
 
    // 생성자에 입력한 값이 잘 전달됐는지 확인합니다.
    EXPECT_TRUE(rect.GetLeft() == 0 && rect.GetTop() == 0 && rect.GetWidth() == 10 && rect.GetHeight() == 20);   
    EXPECT_TRUE(ellipse.GetLeft() == 0 && ellipse.GetTop() == 0 && ellipse.GetWidth() == 10 && ellipse.GetHeight() == 20);  
    // 정사각형은 m_Width와 m_Height가 같아야 합니다.
    EXPECT_TRUE(square.GetWidth() == square.GetHeight());  

    // 부모 클래스 포인터로 받아서 Draw를 호출해 봅니다.
    std::vector<Shape*> shapes;
    shapes.push_back(&rect);
    shapes.push_back(&ellipse);
    shapes.push_back(&square);
    for(std::vector<Shape*>::iterator itr = shapes.begin(); itr != shapes.end(); ++itr) {
        (*itr)->Draw();
    }
}

악취가 나실까요? 아직은 동작하는데 큰 이상이 없습니다만, Square의 구조를 모르는 다른 동료들이 테스트하다가 낭패를 경험할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
TEST(TestPrinciple, LiskovSubstitution) {
    Rectangle rect(0, 0, 10, 20);
    Ellipse ellipse(0, 0, 10, 20);
    Square square(0, 0, 10, 20); // m_Height는 내부적으로 10으로 설정됩니다.
 
    // 생성자에 입력한 값이 잘 전달됐는지 확인합니다.
    std::vector<Shape*> shapes;
    shapes.push_back(&rect);
    shapes.push_back(&ellipse);
    shapes.push_back(&square);
    for(std::vector<Shape*>::iterator itr = shapes.begin(); itr != shapes.end(); ++itr) {
        EXPECT_TRUE((*itr)->GetWidth() == 10);
        EXPECT_TRUE((*itr)->GetHeight() == 20); // Fail. Square의 m_Height는 m_Width인 10입니다.
    }
}

그렇죠. Squarem_Width 값을 m_Height에 세팅하니 당연히 테스트를 실패하겠죠. 이제 Square의 특성을 알았으니 주의해서 사용하면 될까요? 이런 자식 개체가 또 뭐가 있을까요? 단순하게 Circle이 있겠고요, 또 더 있을 수 있을까요? 겁이 나서 모든 자식들의 소스코드와 테스트케이스들을 확인해 봐야 할까요? 이크!!! 라이브러리라 소스코드를 못보네요?

이런 경우가 리스코프 치환 원칙을 위반한 것입니다. 자식이 부모와 다른 동작을 하게 되면 부모 개체를 안심하고 사용할 수 없게 됩니다. 자식 코드를 다 파악하고 있어야지만, 안심하고 사용할 수 있게 되죠.

이 경우엔 부모가 잘못한 것이라 생각합니다. 모든 Shapem_Widthm_Height를 가진다는 잘못된 가정을 한겁니다. 그래서 자식은 어쩔수 없이 SetWidth()SetHeight()를 재구현할 수 밖에 없었고요. 이러다 보니 자신이 사용하지 않는 것에 강제로 의존하게 되어 인터페이스 분리 원칙을 위반했고, 이에 따라 리스코프 치환 원칙도 위반하게 된 것입니다.

사실 정사각형은 길이 하나만 가지고 있는 편이 좋고, 원은 지름을 가지고 있는 편이 좋습니다. 직선은 시작점과 끝점을 가지고 있는 편이 좋고, 호는 반지름과 각도를 가지고 있는 편이 좋고요. 이 정보로부터 나중에 너비와 높이를 계산해 낼 수 있죠. 즉, 부모가 쓸데없이 m_Widthm_Height를 가진 거라 보면 됩니다. 자식들이 헷갈리게요.

준수 방법 : 부모 클래스 간소화

부모 클래스가 불필요한 인터페이스를 강제하므로, 부모를 수정하여 자식들이 억지로 불필요한 구현을 하지 않도록 해야 합니다.

먼저 Shape에서 m_Widthm_Height를 뺍니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 부모 클래스 입니다. m_Width와 m_Height를 뺐습니다.
class Shape {
private:
    int m_Left;
    int m_Top;
public:
    Shape(int l, int t) :
        m_Left(l),
        m_Top(t) {}
    virtual ~Shape() {} // 다형 소멸 하도록 public virtual
public:
    // Get/Set 함수
    int GetLeft() const {return m_Left;}
    int GetTop() const {return m_Top;}

    void SetLeft(int val) {m_Left = val;}
    void SetTop(int val) {m_Top = val;}

    // m_Left, m_Top으로 자식 클래스들이 알아서 그려야 함
    virtual void Draw() const = 0; 
};

다음으로 자식인 RectangleEllipsem_Widthm_Height를 구현합니다.

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
// 자식 클래스 입니다. m_Width와 m_Height를 제공합니다.
class Rectangle : public Shape {
private:
    int m_Width;
    int m_Height;
public:    
    Rectangle(int l, int t, int w, int h) : 
        Shape(l, t),
        m_Width(w),
        m_Weight(h) {}
    virtual ~Rectangle() override {}   
    
    // Get/Set 함수
    int GetWidth() const {return m_Width;}
    int GetHeight() const {return m_Height;}

    void SetWidth(int val) {m_Width = val;}
    void SetHeight(int val) {m_Height = val;}

    virtual void Draw() const override {
        // m_Left, m_Top, m_Width, m_Height로 사각형을 그립니다.
    }
};

// 자식 클래스 입니다. m_Width와 m_Height를 제공합니다.
class Ellipse : public Shape {
private:
    int m_Width;
    int m_Height;
public:
    Ellipse(int l, int t, int w, int h) : 
        Shape(l, t),
        m_Width(w),
        m_Height(h) {}
    virtual ~Ellipse() override {}

    // Get/Set 함수
    int GetWidth() const {return m_Width;}
    int GetHeight() const {return m_Height;}

    void SetWidth(int val) {m_Width = val;}
    void SetHeight(int val) {m_Height = val;}

    virtual void Draw() const override {
        // m_Left, m_Top, m_Width, m_Height로 타원을 그립니다.
    }
};

다음으로 Square를 구현합니다. 이제 억지로 m_Width, m_Height를 사용하지 않고, m_Length로 구현했습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 자식 클래스입니다. m_Length를 제공합니다.
class Square : public Shape {
public:
    int m_Length;
public:
    // width, height없이 length 하나만 사용합니다.
    Square(int l, int t, int length) : 
        Shape(l, t),
        m_Length(length) {}
    virtual ~Square() override {}

    // Get/Set 함수
    int GetLength() const {return m_Length;}

    void SeLength(int val) {m_Length = val;}

    virtual void Draw() const override {
        // m_Left, m_Top, m_Length로 정사각형을 그립니다.
    }
};

이제 자식인 Square가 부모인 Shape과 다른 동작을 하지 않습니다. ShapeSquare 모두 GetWidth(), GetHeight() 를 아예 제공하지 않으니까요.

다음과 같이 테스트 해야 합니다.

1
2
3
4
5
6
7
8
9
10
TEST(TestPrinciple, LiskovSubstitution) {
    Rectangle rect(0, 0, 10, 20);
    Ellipse ellipse(0, 0, 10, 20);
    Square square(0, 0, 10); // length를 10으로 설정합니다.

    // 생성자에 입력한 값이 잘 전달됐는지 확인합니다.
    EXPECT_TRUE(rect.GetWidth() == 10 && rect.GetHeight() == 20);
    EXPECT_TRUE(ellipse.GetWidth() == 10 && ellipse.GetHeight() == 20);
    EXPECT_TRUE(square.GetLength() == 10);
}

한가지 아쉬운 점은 RectangleEllipse구현시 m_Width, m_Height 구현에 코드 중복이 있다는 것입니다. 스스로 반복하지 마라를 위반했습니다.

이는 인터페이스를 분리(인터페이스 분리 원칙 참고)한 뒤, 상속(has-a 관계)하여 개선할 수 있습니다.

먼저 m_Widthm_Height를 제공하는 IResizeable인터페이스를 만듭니다.

1
2
3
4
5
6
7
8
9
10
11
12
// 크기 변경이 가능한 개체입니다.
class IResizeable {
protected : 
    ~IResizeable() {} // 인터페이스여서 protected non-virtual(상속해서 사용하고, 다형 소멸 안함) 입니다.
public:
    // Get/Set을 구현해야 합니다.
    virtual int GetWidth() const = 0;
    virtual int GetHeight() const = 0;

    virtual void SetWidth(int val) = 0;
    virtual void SetHeight(int val) = 0;   
};

다음으로 IResizeable인터페이스를 구현한 ResizeableImpl을 만듭니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// IResizeable 구현한 클래스입니다. 다른 클래스에서 상속받아 기능을 포함합니다.
class ResizeableImpl : public IResizeable {
private:
    int m_Width;
    int m_Height;    
protected:
    ResizeableImpl(int w, int h) :
        m_Width(w), 
        m_Height(h) {}
    ~ResizeableImpl() {} // has-a 관계로 사용되기 때문에, 단독으로 생성되지 않도록 protected입니다.
public:    
    // Get/Set 함수
    virtual int GetWidth() const override {return m_Width;}
    virtual int GetHeight() const override {return m_Height;}

    virtual void SetWidth(int val) override {m_Width = val;}
    virtual void SetHeight(int val) override {m_Height = val;}
};

마지막으로 RectangleEllipsem_Width, m_Height관련 코드를 지우고, ResizeableImpl을 상속받아 has-a 관계 를 만들어 줍니다. 그러면 m_Width, m_Height 코드 중복을 제거할 수 있습니다.

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
// 자식 클래스 입니다. ResizeableImpl을 포함하여 m_Width와 m_Height를 제공합니다.
class Rectangle : 
    public Shape, 
    public ResizeableImpl {
public:    
    Rectangle(int l, int t, int w, int h) : 
        Shape(l, t), 
        ResizeableImpl(w, h) {}
    virtual ~Rectangle() override {}   
  
    virtual void Draw() const override {
        // m_Left, m_Top, IResizeable::GetWidth(), IResizeable::GetHeight()로 사각형을 그립니다.
    }
};

// 자식 클래스 입니다. ResizeableImpl을 포함하여 m_Width와 m_Height를 제공합니다.
class Ellipse : 
    public Shape, 
    public ResizeableImpl {
public:
    Ellipse(int l, int t, int w, int h) : 
        Shape(l, t), 
        ResizeableImpl(w, h) {}
    virtual ~Ellipse() override {}
 
    virtual void Draw() const override {
        // m_Left, m_Top, IResizeable::GetWidth(), IResizeable::GetHeight()로 타원을 그립니다.
    }
};

댓글남기기