ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Effective C++] 상속, 그리고 객체 지향 설계
    프로그래밍 기초/C++ 2022. 8. 10. 10:56

      상속, 그리고 객체 지향 설계 

    public 상속 모형은 반드시 "is-a(...는 ...의 일종이다)"를 따르도록 만들자

    public 상속의 is-a 관계

    모든 사람은 학생이 아니지만 학생은 사람이다. 이 것의 관계가 public 상속이라고 할 수 있다.

    이를 코드로 만들면 다음과 같다.


    class Person {

        ...

       void eat() { ... }

    };

    class Student : public Person {

        ...

        void study() { ... }

    };


     

    사람의 일종인 학생은 사람이 할 수 있는 모든 것을 하지만 모든 사람이 공부하지는 않는다.

     

    그런데 이러한 설계가 잘못되면 문제가 생길 수 있다.

     

    모든 새는 난다고 가정해서 Bird 클래스에 fly 함수를 만들었다면 날지 못하는 새인 펭귄은 Bird를 상속받았을 때 fly 동작을 할 수 있게 될 것이다.

     

    따라서 이 경우엔 새를 구분하는 Bird 클래스와 날 수 있는 새를 구분하는 FlyingBird 클래스를 구분해 다음과 같이 구현할 수 있을 것이다.


    class Bird { ... };

    class FlyingBird : public Bird {

        ...

        void fly() { ... }

    };

    class Penguin : public Bird { ... };


     

    다만 자신이 사용하려는 정보에 날아다닌다는 게 필요가 없다면 단순히 Bird 클래스와 Penguin 클래스로 구분하고 fly 함수를 구현하지 않을 수도 있다.

     

    ※ 제작하려는 소프트웨어 시스템이 기대하는 바에 따라 설계를 달리해야 한다.

     

    상속된 이름을 숨기는 일은 피하자

    C++의 유효 범위 탐색 과정과 문제

    만약 프로그램 실행 중 특정 함수가 발견됐다면 해당 함수를 찾기 위해 컴파일러는 다음의 과정을 밟게 된다.

    1. 지역의 유효 범위 탐색
    2. Derived 클래스의 유효 범위 탐색
    3. Base 클래스의 유효 범위 탐색
    4. 네임 스페이가 있으면 네임 스페이스 내부 범위 탐색
    5. 전역 유효범위 탐색

     

    이 과정에서 Base 클래스에 존재하는 함수와 동일한 이름의 함수를 Derived 클래스에서 생성했다면 함수의 매개 변수가 다르거나 비가상 함수라고 하더라도 Base 클래스의 함수의 이름이 가려지게 된다.


    class Base {

    private:
        int x;

    public:

        virtual void mf1() = 0;

        virtual void mf1(int);

        virtual void mf2();

        void mf3();

        void mf3(double);

        ...

    };

    class Derived: public Base {

    public:

        virtual void mf1();

        void mf3();

        void mf4();

        ...

    };

     

    Derived d;int x;...d.mf1(); // Derived의 mf1 호출

    d.mf1(x); // 에러, Base::mf1이 가려짐

     

    d.mf2();

     

    d.mf3(); // Derived의 mf3 호출

    d.mf3(x); // 에러, Base::mf3이 가려짐


     

    이름 가리기 문제 해결 방법

    이러한 문제를 해결하는 방법은 Derived 클래스에서 Base 클래스의 함수를 사용한다는 것을 using으로 선언해주는 것이다.


    class Derived: public Base {

    public:

        using Base::mf1;

        using Base::mf3;

     

        virtual void mf1();

        void mf3();

        void mf4();
        ...

    };

     

    Derived d;

    int x;

    ...

    d.mf1(); // Derived의 mf1 호출

    d.mf1(x); // Base의 mf1 호출

     

    d.mf2();

     

    d.mf3(); // Derived의 mf3 호출

    d.mf3(x); // Base의 mf3 호출


     

    그런데 상속받은 함수 중 하나의 함수만 상속받고 싶을 때는 using은 이름 단위로 처리를 하기 때문에 using으로는 처리가 불가능하다.

     

    따라서 이런 경우에는 전달 함수를 사용해 처리할 수 있다.


    class Derived: private Base {

    public:

        virtual void mf1()

        {

            Base::mf1();

        }

        ...

    };


     

    인터페이스 상속과 구현 상속의 차이를 제대로 파악하고 구별하자

    함수의 종류

    C++에는 다음과 같이 함수를 구분해 사용할 수 있다.

    • 순수 가상 함수 함수를 선언만 하고 구현을 하지 않음으로 파생 클래스에 구현 의무를 넘겨주는 인터페이스 역할을 한다.
    • 단순 가상 함수 함수의 기본적인 구현을 하여 파생 클래스에서 필요한 경우에만 함수의 구현을 수정할 수 있도록 한다.
    • 비 가상 함수 파생 클래스에서 구현을 변경할 여지를 주지 않는 일반적인 구현 방식이다.

     

    단순 가상 함수의 문제점

    단순 가상 함수는 기본적인 구현이 존재하기 때문에 파생 클래스를 구현할 때 구현이 필요하다는 것을 잊을 수 있다.

    따라서 순수 가상 함수를 함께 활용하여 구현 의무를 전달할 수 있다.

     


    class Airplane {

    public:

        // 순수 가상 함수로 구현 의무를 전달한다.

        virtual void fly(const Airport& destination) = 0;

        ...

    protected:

        // 기본 구현을 다른 함수에 구현한다.

        void defaultFly(const Airpoort& destination) { ... }

    };

    class ModelA: public Airplane {

    public:

        // 순수 가상 함수를 구현하면서 기본 구현을 사용한다.

        virtual void fly(const Airport& destination)

        {

            defaultFly(destination);

            ...

        }

    };


     

    이러한 구현이 함수가 나뉘기 때문에 문제라고 보는 시각이 존재하며 다음과 같이 구현하기도 한다.


    class Airplane {

    public:

        virtual void fly(const Airport& destination) = 0;

        ...

    };

    // 기본 클래스에서 순수 가상 함수의 구현을 마련한다.

    void Airplane:fly(const Airport& destination) { ... }

     

    class ModelA: public Airplane {

    public:

        virtual void fly(const Airport& destination)

        {

            // 다른 함수를 사용하는 대신 기본 클래스의 구현을 사용한다.

            Airplane::fly(destination);

            ...

        }

    };


     

    ※ 클래스의 함수는 필요에 따라 선언의 종류를 나눠서 활용해야 한다.

     

    가상 함수 대신 쓸 것들도 생각해 두는 주세를 시시때때로 길러 두자

    가상 함수를 대체할 수 있는 방법들

    어떤 공통적인 연산을 수행하는 함수를 구현할 때 가상 함수 대신 다음과 같은 구현도 가능하다.

    • 비가상 함수 인터페이스 관용구 비가상 함수로 가상 함수를 감싸서 가상 함수를 감추는 방법
    • 함수 포인터 전략 패턴 함수 포인터를 이용해 외부 함수를 사용하는 방법
    • tr1::function 전략 패턴 STL 라이브러리를 이용해 일반적인 형태의 함수를 사용하는 방법
    • 고전적인 전략 패턴 공통 연산을 수행하는 함수를 가진 클래스 계열을 생성해 사용하는 방법

     

    비가상 함수 인터페이스 관용구 예제와 특징

    비가상 함수를 호출할 수 있도록 하고 private 가상 함수를 만들어 상속된 클래스에 따라 구현을 달리할 수 있다.


    class GameCharacter {

    public:

       int healthValue() const

        {

            ...

            int retVal = doHealthValue();

            ...

        }

    private:

        virtual int doHealthValue const { ... };

    };


     

    ※ 자유롭게 계산 함수를 구현할 수 있으나 호출 순서는 최상위 함수에서 결정 되어 있다.

     

    함수 포인터 전략 패턴 예제와 특징

    생성자에서 계산에 사용할 함수의 포인터를 받아 실제 계산 시에 사용한다.


    // 실제 계산을 위한 함수

    int defaultHealthCalc(const GameCharacter& gc);

     

    class GameCharacter {

    public:

        // 계산에 사용할 함수 포인터 타입 정의

       typedef int (*HealthCalcFunc)(const GameCharacter&);

     

       int healthValue() const { return healthFunc(*this); }

    private:

        HealthCalcFunc healthFunc;

    };


     

    ※ 같은 타입의 인스턴스도 계산 함수를 다르게 가질 수 있으며, 게임 실행 도중 처리 함수를 바꿀 수도 있다.

     

    tr1::function 전략 패턴 예제와 특징

    함수 포인터 전략 패턴과 동일한 구현이지만 STL 라이브러리를 사용하여 조금 더 범용성을 높였다.


    class GameCharacter {

    public:

        // 기존과 같지만 타입이 tr1::function으로 바뀌었다.

       typedef std::tr1::function<int (const GameCharacter&)> HealthCalcFunc;

     

       int healthValue() const { return healthFunc(*this); }

    private:

        HealthCalcFunc healthFunc;

    };


     

    ※ STL 라이브러리를 사용하여 단순히 HealthCalcFunc와 같은 함수뿐만 아니라 그러한 형식을 가진 모든 함수를 사용할 수 있다.

     

    고전적인 전략 패턴 예제와 특징

    가상 계산 함수를 가지는 클래스를 선언해 필요에 따라 상속해 새로운 클래스를 만들어 사용하고 해당 클래스를 전달해 사용하는 방식이다.


    class GameCharacter;

    class HealthCalcFunc {

    public:

        ...

        virtual int calc(const GameCharacter& gc) const { ... }

        ...

    };

    HealthCalcFunc defaultHealthCalc;

     

    class GameCharacter {

    public:

        explicit GameCharacter(HealthCalcFunc *phcf = &defaultHealthCalc)

                : pHealthCalc(phcf) { }

        int healthValue() const { return pHealthCalc->calc(*this); }

    private:

        HealthCalcFunc* pHealthCalc;

    };


     

    ※ 쉽게 이해할 수 있고 계산 클래스의 파생 클래스로 함수를 조절할 수 있는 가능성을 열어뒀다.

     

    상속받은 비가상 함수를 파생 클래스에서 재정의하는 것은 절대 금물!

    문제가 되는 이유

    다음과 같이 상속된 클래스의 비가상 함수를 재정의하는 경우 derived 클래스의 해당 함수는 부모의 함수를 호출할 수도, derived 클래스의 함수를 호출할 수도 있는 상태가 된다.


    class B {

    public:

        void mf();

    };

    class D : public B {

    public:

        // 상속된 비가상 함수 mf를 재정의

        void mf();

    };

     

    B x;

    B* pB = &x;

    pB->mf(); // B의 mf 실행

     

    D* pD = &x;

    pD->mf(); // B의 mf가 실행될 수도 있고 D의 mf가 실행될 수도 있다.


     

    이러한 문제가 발생하는 원인은 비가상 함수가 정적 바인딩으로 묶이기 때문에 클래스가 생성될 때 함수가 결정되게 되기 때문이다. 반면에 가상 함수는 동적 바인딩으로 함수 실행 시에 동작을 결정한다.

     

    ※ 이러한 문제를 예방하기 위해서는 public 상속 클래스가 is-a 관계가 맞는지 확인해야 한다.

     

    어떤 함수에 대해서도 상속받은 기본 매개변수 값은 절대로 재정의하지 말자

    가상 함수와 기본 매개변수의 바인딩 차이

    가상 함수의 경우 동적으로 바인딩되는 반면 기본 매개변수는 정적으로 바인딩된다.

    즉, 파생 클래스의 가상 함수에서 기본 매개변수를 바꿨더라도 기본 클래스의 기본 매개변수 값을 사용하게 될 수도 있다.

     

    이런 식의 도작이 발생하는 이유는 컴파일러의 메커니즘이 속도 유지와 구현 간편성에 무게를 두고 있기 때문이다.

     

    ※ 비가상 인터페이스 관용구를 사용해 기본 매개변수를 고정할 수 있다.

     

    "has-a(...는 ...를 가짐)" 혹은 "is-implemented-in-terms-of(...는 ...를 써서 구현됨)"를 모형화할 때는 객체 합성을 사용하자

    객체 합성이란

    어떤 타입이 다른 타입의 객체를 포함할 때의 관계를 말하며 다음과 같은 예를 들 수 있다.


    class Address { ... };

    class PhoneNumber { ... };

    class Person {

    public:

        ...

    private:

        // 아래 객체들은 Person 클래스와 has-a 관계를 가진다.

        std::string name;

        Address address;

        PhoneNumber voiceNumber;

        PhoneNumber faxNumber;

    };


     

    ※ 합성이란 용어 대신 레이어링, 포함, 통합 또는 내장이라고도 부른다.

     

    is-implemented-in-terms-of 예제

    이러한 관계는 일반적으로 특정 클래스를 상속받아 새로운 기능을 추가하거나 다르게 만들려고 할 때 발생한다.

     

    가령 list를 상속받아 Set 클래스를 만든다고 하면 두 클래스의 관계가 is-a 관계가 될 수 없으므로 list를 Set 클래스 내부에 데이터 변수로 사용하게 되는데 이때 Set 객체는 list 객체를 써서 구현되는 형태가 된다.


    template<class T>

    class Set {

    public:

       ...

    private:

        std::list<T> rep;

    };


     

    private 상속은 심사숙고해서 구사하자

    private 상속의 의미

    private 상속은 is-implemented-in-terms-of를 의미하며 컴파일러는 상속 관계가 private이면 파생 클래스 객체를 기본 클래스 객체로 변환하지 않는다.

     

     

    ※ 기본적으로 private 상속은 설계 중에는 아무 의미가 없으며 구현 중에만 의미를 가진다.

     

    객체 합성의 장점

    is-implemented-in-terms-of는 객체 합성의 의미 이기도 한데, 이 두 구현 중 가능하면 객체 합성을 사용하는 게 좋다.

    객체 합성의 장점은 다음과 같다.

    1. 파생은 가능하게 하되, 파생 클래스에서 가상 함수를 재정의 할 수 없도록 할 수 있다.
    2. 컴파일 의존성을 최소화할 수 있다.

     

    ※ 빈 클래스를 상속받는 특수한 상황이 아니면 객체 합성을 사용하는 편이 일반적으로 낫다.

     

    다중 상속은 심사숙고해서 사용하자

    다중 상속의 위험성과 해결 방법

    기본적으로 다중 상속은 둘 이상의 동일한 이름의 함수를 물려받을 가능성이 존재하고 C++에서는 이 함수를 구분해주지 않는다. 이 것은 C++가 최적 일치 함수를 찾은 뒤 접근 가능성을 체크하기 때문인데 이 때문에 다음과 같이 명확하게 호출 가능한 게 하나밖에 없더라도 함수 호출 모호성 에러가 발생하게 된다.


    class BorrowableItem {

    public:

        void checkOut();

    }

    class ElectronicGadget {

    private:

        bool checkOut() const;

    };

    class MP3Player : public BorrowableItem, public ElectronicGadget { ... };

     

    MP3Player mp;

    mp.checkOut(); // 모호성 에러 발생


     

    다중 상속의 또다른 문제점은 상속받은 클래스들이 특정 기본 클래스에서 파생된 경우 기본 클래스의 멤버 변수가 중복해서 만들어질 수 있다는 것이다.

     

    C++에서는 기본적으로 이러한 중복을 허용하고 중복을 막을 수 있도록 가상 상속을 허용하고 있으나 가상 상속은 크기가 더 크고 가상 기본 클래스의 멤버 접근 동작도 느리다는 단점을 가지고 있다.

     

    ※ 가상 기본 클래스를 사용해야 하는 경우 기본 클래스에 멤버 변수를 사용하지 않도록 해야 한다.

     

    다중 상속이 유용하게 사용될 수 있는 예로는 순수 가상 함수를 public 인터페이스로 상속받고 is-implemented-in-terms-of 관계의 클래스를 private 상속으로 구현해야 할 때를 들 수 있다.

     

    ※ 단일 상속으로 구현하는 편이 더 나을 가능성이 높다.

     

    댓글

Designed by Tistory.