Skip to main content

[C++ Primer Plus] Chapter 14. C++ 코드의 재활용 (1) 컨테인먼트와 private, protected 상속

C++ 기초 플러스 책을 읽고 공부한 노트입니다.




코드의 재활용성을 높이는 방법 #

  1. public 상속 (이전 chapter에서 본 것)
  2. 컨테인먼트(containment) = 컴포지션(composition) = 레이어링(layering)
  3. private 상속
  4. protected 상속
  5. 클래스 템플릿



컨테인먼트 - 객체 멤버를 가지는 클래스 #

  • valarray 클래스
    • 수치 값들을 다루는 것이 목표인 클래스이다.
    • 배열에 들어 있는 합, 최대값, 최소값을 구하는 것과 같은 동작들을 지원한다.
valarray<int> v1; // int형의 배열

valarray<int> v2(10); // 10개짜리 배열
valarray<int> v3(3, 10); // 3이 10개인 배열

int arr[15] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15 };
valarray<int> v4(arr, 10); // arr의 첫 10개의 원소로 초기화된 배열

cout << v4.sum() << endl; // 55
cout << v4.min() << endl; // 1
cout << v4.max() << endl; // 10

  • 학생 클래스 만들기
    • 이름과 성적표를 가지고 있는 클래스이다.
    • 이것은 has-a 관계이다.
      • has-a 관계를 모델링하는 일반적인 방법은 컨테인먼트를 사용하는 것이다.
      • 즉, 다른 클래스의 객체들을 멤버로 가지는 클래스를 만드는 것이다.

  • is-a 관계

    • public 상속에서 처럼, 인터페이스와 구현 모두 상속한다.
  • has-a 관계

    • 인터페이스 없이 구현을 획득한다.

  • Student 객체는 stringvalarray객체를 가지고 있다. (has-a 관계)
    • stringvalarray객체의 구현을 획득해서 사용할 수는 있지만,
    • 인터페이스는 상속하지 않는다. 예를 들어, string operator==()함수를 사용하는 인터페이스는 갖지 않는다.
class Student
{
private:
    string name;             // string 객체를 가진다. 
    valarray<double> scores; // valarray 객체를 가진다. 
};

  • 학생 클래스 예제
// Student.h

class Studentc
{
private:
    typedef valarray<double> ArrayDb; // typedef를 사용해서 표기를 단순화하였다.  

    string name;    
    ArrayDb scores; 

    ostream& PrintScores(ostream& os) const;

public:
    Studentc()                         : name("Null Studentc"), scores() {}

    explicit Studentc(const string& s) : name(s), scores() {}
    explicit Studentc(int n)           : name("Nully"), scores(n) {}

    Studentc(const string& s, int n)                   : name(s), scores(n) {}
    Studentc(const string& s, const ArrayDb& a)        : name(s), scores(a) {}
    Studentc(const char* str, const double* pd, int n) : name(str), scores(pd, n) {}

    ~Studentc() {}


    double Average() const;
    const string& Name() const;
    double& operator[](int i);
    double operator[](int i) const;


    friend istream& operator>>(istream& is, Studentc& stu);
    friend istream& getline(istream& is, Studentc& stu);
    friend ostream& operator<<(ostream& os, const Studentc& stu);
};
// Student.cpp

// scores 출력을 위한 private 멤버 함수
ostream& Studentc::PrintScores(ostream& os) const
{
    int i;
    int lim = scores.size();
    if (lim > 0)
    {
        for (i = 0; i < lim; i++)
        {
            os << scores[i] << " ";
            if (i % 5 == 4) os << endl;  // 5개 출력하고 개행하기 위함
        }
        if (i % 5 != 0) os << endl; // 혹시 개행이 안 되었을 때 개행하기 위함
    }
    else os << "빈 배열";

    return os;
}

double Studentc::Average() const
{
    if (scores.size() > 0) return scores.sum() / scores.size();
    else                   return 0;
}

const string& Studentc::Name() const
{
    return name;
}

double& Studentc::operator[](int i)
{
    return scores[i];
}

double Studentc::operator[](int i) const
{
    return scores[i];
}

istream& operator>>(istream& is, Studentc& stu)
{
    is >> stu.name;
    return is;
}

istream& getline(istream& is, Studentc& stu)
{
    getline(is, stu.name);
    return is;
}

ostream& operator<<(ostream& os, const Studentc& stu)
{
    os << stu.name << " 학생의 성적표: "  << endl;
    stu.PrintScores(os);
    return os;
}

  • explicit 사용 이유
    • 암시적으로 변환하는 것을 막아서 실수를 예방한다.
Student homer("Homer", 10);

homer = 5; // homer[0] = 5; 로 점수를 바꾸려고 했는데 실수로 오타가 났다. 
           // explicit이 없다면 맞는 문장이다. 
           // Student(int n) 생성자가 불려서 이름이 Nully가 된다.

  • 멤버 초기자 리스트 문법을 사용해서 내포된 객체를 초기화 했다.
    • 만약 멤버 초기자 리스트 문법을 사용하지 않았다면, stringvalarray 객체의 디폴트 생성자를 사용한다.
    • 순서는 멤버 초기자 리스트 순서가 아니다. 선언된 순서이다.
class Student
{
private:
    typedef valarray<double> ArrayDb;
    
    string name;    // name이 먼저 선언되었다. 
    ArrayDb scores; 
    
public:
    Student(const char* str, const double* pd, int n) 
        : scores(pd, n), name(str) {}
    
    // scores를 먼저 써도, 선언된 순서가 name 먼저라서 name 먼저 초기화된다. 
};    

  • 클래스 메서드 안에서 내포된 객체의 인터페이스를 사용한다.
double Student::Average() const
{
    // valarray의 sum(), size() 사용
    if (scores.size() > 0) return scores.sum() / scores.size();
    else                   return 0;
}

ostream& operator<<(ostream& os, const Student& stu)
{
    // string의 operator<<()를 사용
    os << stu.name << " 학생의 성적표: "  << endl;
    
    //...
}



private 상속 #

  • public 상속

    • 기초 클래스의 public 메서드가 파생 클래스의 public 메서드가 된다.
    • 즉, 기초 클래스의 인터페이스를 상속한다.
    • is-a
  • private 상속

    • 기초 클래스의 public 메서드가 파생 클래스의 private 클래스가 된다.
    • 즉, 기초 클래스의 인터페이스를 상속하지 않는다.
    • has-a
    • 디폴트가 private 상속이다.

  • 컨테인먼트와의 차이
    • 컨테인먼트는 객체를 이름이 있는 멤버 객체로 클래스에 추가한다.
    • 반면에 private 상속은 객체를 이름이 없는 멤버 객체로 클래스에 추가한다.

// Student.h

class Studenti : private string, private valarray<double>
{
private:
    typedef valarray<double> ArrayDb;

    ostream& PrintScores(ostream& os) const;

public:
    Studenti()                         : string("Null Student"), ArrayDb() {}

    explicit Studenti(const string& s) : string(s), ArrayDb() {}
    explicit Studenti(int n)           : string("Nully"), ArrayDb(n) {}

    Studenti(const string& s, int n)                   : string(s), ArrayDb(n) {}
    Studenti(const string& s, const ArrayDb& a)        : string(s), ArrayDb(a) {}
    Studenti(const char* str, const double* pd, int n) : string(str), ArrayDb(pd, n) {}

    ~Studenti() {}


    double Average() const;
    double& operator[](int i);
    double operator[](int i) const;
    const string& Name() const;


    friend istream& operator>>(istream& is, Studenti& stu);
    friend istream& getline(istream& is, Studenti& stu);
    friend ostream& operator<<(ostream& os, const Studenti& stu);
};
// Student.cpp

ostream& Studenti::PrintScores(ostream& os) const
{
    int i;
    int lim = ArrayDb::size();
    if (lim > 0)
    {
        for (i = 0; i < lim; i++)
        {
            os << ArrayDb::operator[](i) << " ";
            if (i % 5 == 4) os << endl;
        }
        if (i % 5 != 0) os << endl;
    }
    else os << "빈 배열";

    return os;
}

double Studenti::Average() const
{
    if (ArrayDb::size() > 0) return ArrayDb::sum() / ArrayDb::size();
    else                     return 0;
}

const string& Studenti::Name() const
{
    return (const string&)*this;
}

double& Studenti::operator[](int i)
{
    return ArrayDb::operator[](i);
}

double Studenti::operator[](int i) const
{
    return ArrayDb::operator[](i);
}

istream& operator>>(istream& is, Studenti& stu)
{
    is >> (string&)stu;
    return is;
}

istream& getline(istream& is, Studenti& stu)
{
    getline(is, (string&)stu);
    return is;
}

ostream& operator<<(ostream& os, const Studenti& stu)
{
    os << (const string&)stu << " 학생의 성적표: " << endl;
    stu.PrintScores(os);
    return os;
}

  • 멤버 초기자 리스트 문법을 사용해서 내포된 객체를 초기화 했다.
    • 컨테인먼트는 멤버 이름을 사용했다.
    • 하지만 private 상속은 클래스 이름을 사용한다.
// 컨테인먼트 - name과 같은 멤버 이름을 사용함.
Studentc::Studentc() : name("Null Student"), scores() {}

// private 상속 - string과 같은 클래스 이름을 사용함. 
Studenti::Studenti() : string("Null Student"), ArrayDb() {}

  • 클래스 메서드 안에서 기초 클래스의 인터페이스를 사용한다.
    • 컨테인먼트는 멤버의 메서드를 사용한다.
    • 하지만 private 상속은 클래스 이름과 사용 범위 결정 연산자를 사용한다.
// 컨테인먼트 - scores.sum() 처럼 멤버의 메서드를 사용한다. 
double Studentc::Average() const
{
    if (scores.size() > 0) return scores.sum() / scores.size();
    else                   return 0;
}

// private 상속 - ArrayDb::sum() 처럼 클래스 이름과 사용 범위 결정 연산자를 사용한다. 
double Studenti::Average() const
{
    if (ArrayDb::size() > 0) return ArrayDb::sum() / ArrayDb::size();
    else                     return 0;
}

  • 기초 클래스 객체에 접근하기
    • 컨테인먼트는 멤버를 사용한다.
    • 하지만 private 상속은 데이터형 변환을 사용해서 기초 클래스로 변환한다.
// 컨테인먼트 - 멤버인 name을 사용한다. 
const string& Studentc::Name() const
{
    return name;
}

// private 상속 - 자기 자신을 기초 클래스 string로 형변환한다. 
const string& Studenti::Name() const
{
    return (const string&)*this;
}

  • 기초 클래스 프렌드에 접근하기
    • 컨테인먼트는 멤버를 사용한다.
    • 하지만 private 상속은 데이터형 변환을 사용해서 기초 클래스로 변환한다.
      • 명시적으로 변환해야 한다.
      • private 상속에서는 명시적인 형변환이 없으면, 파생 클래스에 대한 참조/포인터를 기초 클래스에 대한 참조/포인터에 대입할 수 없다.
// 컨테인먼트 
ostream& operator<<(ostream& os, const Student& stu)
{
    // string 멤버인 name의 operator<<()를 사용
    os << stu.name << " 학생의 성적표: "  << endl;
    
    //...
}

ostream& operator<<(ostream& os, const Studenti& stu)
{
    // (const string&)stu으로 매개변수를 형변환 하여 string의 operator<<()를 사용
    os << (const string&)stu << " 학생의 성적표: " << endl;
    
    //...
}

  • has-a를 구현하기 위해서 어떤 것을 사용할까?
  • 컨테인먼트 vs private 상속
    • 컨테인먼트의 장점
      • 사용하기 쉽다.
      • private의 경우에 하나 이상의 기초 클래스를 상속하면 문제가 발생할 수 있다.
      • 같은 클래스를 여러개 내포할 수 있다. (string 멤버가 여러 개)
    • private 상속의 장점
      • 기초 클래스의 protected 멤버에 접근할 수 있다.
      • 가상 함수를 재정의할 수 있다.



protected 상속 #


  • 각 상속의 특성
특성 public 상속 protected 상속 private 상속
기초 클래스의
public 멤버
public protected private
기초 클래스의
protected 멤버
protected protected private
기초 클래스의
private 멤버
접근 불가 접근 불가 접근 불가
기초 클래스로
암시적 업캐스팅
가능하다 파생 클래스 안에서만 가능하다 불가능하다
  • 처음 두 행의 설명
    • 예를 들어, 기초 클래스의 public 멤버의 경우 public 상속일 때는 파생 클래스 안에서 public 멤버가 된다는 말이다.
  • 기초 클래스의 private 멤버의 경우, 기초 클래스의 인터페이스를 통해서만 접근이 가능하다.

  • protectedprivate 상속 일 때, 기초 클래스의 메서드를 파생 클래스에서 public으로 사용하는 방법
    • (1) 메서드를 새롭게 하나 만든다.
    • (2) using 선언을 사용해서 지정한다.
      • 이것은 컨테인먼트일 때는 적용되지 않는다.
class Studenti : private string, private valarray<double>
{
public:
    // 방법 2
    using valarray<double>::sum;
    
    // 방법 1
    double Studenti::max() const
    {
        return valarray<double>::max();
    }
    
    //...
};

int main()
{
    Studenti s("Kim", {1, 2, 3, 4, 5});
    cout << s.max() << endl;  // (O) private 상속이지만 max를 public처럼 사용 가능하다. 
}



다중 상속 #

  • 다중 상속 (multiple inheritance; MI)

    • 직계 인접한 기초 클래스를 하나 이상 가지는 클래스이다.
    • public, protected, private 키워드는 각각의 기초 클래스에 명시해서 제한해야 한다.
    • 명시하지 않으면 디폴트인 private 상속이 된다.
  • 여기서는 public 상속을 중심으로 설명한다.


  • 다중 상속은 조심스럽게 절제하여 사용해야 한다.

    • 하나의 조상을 공유하는 다중 상속은 위험하기 때문이다.
  • 예를 들어, 다음과 같은 클래스 구조가 있을 때 문제가 발생한다.

          Worker
    ↙              ↘
Singer                Waiter
    ↘               ↙
       SingingWaiter
SingingWaiter sw;
Worker * ptr = &sw;  // (X)
  • 이 경우 선택지가 두 개가 존재한다.

    • 하나는 Waiter에 있는 Worker이며,
    • 다른 하나는 Singer에 있는 Worker이다.
  • 또한 Worker 객체의 복사본을 두 개나 가지게 된다.


  • 해결방법: 가상 기초 클래스
    • 공통 조상의 유일한 객체를 상속하는 방식으로 객체를 파생시킨다.
    • virtual 키워드를 사용해서 상속한다.
    • 그러면, SingingWaiter는 하나의 Worker 객체를 내포한다.
class Worker {};

class Waiter : public virtual Worker {}; // public과 virtual의 위치는 상관 없다. 

class Singer : virtual public Worker {}; // 즉, 이렇게 써도 된다. 

class SingingWaiter : public Singer, public Waiter {};

  • 왜 가상 기초 클래스가 디폴트가 아닌가?
    • (1) 기초 클래스 객체의 여러 개의 복사본을 원할 수 있다.
    • (2) 기초 클래스를 가상으로 만드려면 추가적인 작업을 해야한다.
    • (3) 중간 클래스를 통해 기초 클래스에게 자동으로 정보를 전달하는 기능을 못하게 만든다.

  • (3) 가상 기초 클래스를 사용하면 생성자에서 중간 클래스를 통해 기초 클래스에게 자동으로 정보를 전달하는 기능을 못하게 만든다.
    • C(int, int, int)의 호출로 B(int, int)는 호출되지만,
    • 자동으로 A(int)까지 호출하지는 않는다.
    • 따라서 A()의 생성자가 호출된다.
class A
{
public:
    A()
    {
        cout << "A의 디폴트 생성자" << endl;
    }

    A(int a)
    {
        cout << "A" << endl;
    }
};

class B : public virtual A
{
public:
    B(int a, int b) : A(a) // A(a)가 아니라 A()가 호출된다. 
    {
        cout << "B" << endl;
    }
};

class C : public B
{
public:
    C(int a, int b, int c) : B(a, b)
    {
        cout << "C" << endl;
    }
};

int main()
{
    C c(1, 2, 3);
}
A의 디폴트 생성자
B
C

  • 기초 생성자를 명시적으로 호출함으로써 수정가능하다.
class C : public B
{
public:
    // 명시적으로 A(int)의 생성자를 호출한다. 
    C(int a, int b, int c) : A(a), B(a, b)
    {
        cout << "C" << endl;
    }
};
A
B
C

간접적인 가상 기초 클래스를 사용하는 파생 클래스는, 그 파생 클래스의 생성자들이 간접적인 가상 기초 클래스 생성자들을 직접 호출하게 해야 한다.


  • (2) 기초 클래스를 가상으로 만드려면 추가적인 작업을 해야한다.
    • 어느 메서드를 사용하는 가?
class Worker
{
public:
    virtual void Show();
};

class Waiter : virtual public Worker
{
public:
    void Show();
};

class Singer : virtual public Worker
{
public:
    void Show();
};

class SingingWaiter : public Waiter, public Singer
{ };

int main()
{
    SingingWaiter sw;
    sw.Show();  // (X) Singer와 Waiter 중에 어느 메서드인지 모호하다. 
    sw.Singer::Show(); // (O)
}

  • 대안
    • SingingWaiter에서 Show()를 다시 정의하고, 사용할 Show()의 버전을 지정해 준다.
void SingingWaiter::Show()
{
    Singer::Show();
}



  • 가상 기초 클래스와 가상이 아닌 기초 클래스의 혼합
    • 다음 예시에서 BC에 대해서만 A가 가상 클래스이고, XY에 대해서는 가상 클래스가 아니라면.
    • M은 결과적으로 3개의 클래스 A 종속 객체를 내포하게 되겠다.
       A
 ↙  ↙   ↘  ↘
[B] [C]  X   Y
 ↘  ↘   ↙  ↙
       M

  • 가상 기초 클래스와 비교 우위
    • 파생 클래스에 있는 이름은 조상 클래스에 있는 동일한 이름보다 비교 우위를 가진다.
    • 이것은 가시성과는 상관 없다.
    • 아래 예시에서는…
      • AAB()보다 BAB()가 우위를 가진다.
      • BC에 있는 BC()D()의 입장에서 모호하다.
      • 이것은 어느 하나가 private 이든 간에 상관없이 비교 우위를 가지는 것들이다.
      • 즉, BAB()private이라고 해도 AAB()보다 비교 우위를 가진다.
    A
  ↙  ↘
 [B]  [C]
  ↘  ↙
    D
class A
{
public: 
    void AB();
};

class B : public virtual A
{
public: 
    void AB();
    void BC();
};

class C : public virtual A
{
public: 
    void BC();
};

class D : public B, public C
{
public:
    void Test()
    {
        AB(); // B의 AB() 호출
        BC(); // (X) 모호하다
    }
};