(업데트 완료)경작 14일차 C++ 상속

한정화·2023년 2월 11일
0

#230211 토 / 230319 토~ 230325 토

경작 5일차, 7일차에 이어서 c++을 공부해보겠다. 책은 <열혈 c++ 프로그래밍>에서 <명품 c++ programming>으로 다시 돌아왔다. 열혈 책은 이미 반납하고 없기 때문이다..(=정해진 기한 안에 숙제 안 함^^..)

사실 지금 좀 귀찮다. 오전에 허깅코스하고 지금 cpp 상속하고 끝나면 저녁 먹고 스터디 가서 transformer 해야됨ㅎ 실화냐~?

<목차>

  1. 상속이란?
  2. 상속 선언과 멤버 호출
    1)선언 방법
    2) 부모 클래스의 멤버 호출 방법
    3) 접근 지정자
  3. 생성자, 소멸자 실행 순서
  4. 상속과 객체 포인터
    1) 업 캐스팅
    2) 다운 캐스팅
  5. 오버라이딩과 가상함수, 가상소멸자
    1) 오버라이딩
    2) 오버라이딩과 업캐스팅
    3) 가상함수
    4) 순수 가상 함수와 추상 클래스
    5) 가상소멸자
  6. 다중상속

1. 상속이란?

파생 클래스(자식 클래스)가 생성될 때 기본 클래스(부모 클래스)의 멤버를 포함하도록 하는 것이 상속이다. 여러 기본 클래스를 동시에 상속받는 다중 상속도 가능하다. 상속은 모든 객체 지향 언어에 존재하는(존재하지 않으면 객체 지향 언어가 아니다. 자바스크립트가 일종의 객체를 만드는 것처럼 보이지만 객체 지향 언어가 될 수 없는 이유이다) 본질적인 특성이자 중요한 기능이다. 상속을 이용하면 각각의 클래스에 공통으로 존재하는 코드를 여러 번 작성하지 않아도 된다. 또한, 오류가 발생하면 기본 클래스 코드만 수정하면 된다.

상속은 프로그램에 존재하는 클래스들의 관계를 쉽게 파악하도록 해준다는 장점도 가지고 있다. 무엇보다 기존에 작성된 클래스를 상속받은 후 원하는 기능만 추가하는 방식을 이용하면 새로운 소프트웨어 개발이 용이해진다. 개발자에게 매우 좋은 일이라고 할 수 있다.

하지만 어떤 클래스에 있는 일부 기능이나 변수가 필요하다는 이유로 상속을 받으면, 필요없는 기능이나 변수까지 전부 가져가게 되므로 그럴 때에는 그냥 static 변수나 friend 함수를 사용하는 것이 낫다.

사실 공부하면 공부할수록 c++은 정말 매력적인 언어인 것 같다. 그의 매력에 홀랑 넘어가서 예전에 써본 c와 파이썬을 모두 잊어버렸어.. 거부할 수 없는 매ㄹ. 이 아니라 그냥 c랑 파이썬 너무 안 써서 자꾸 잊어버림;


2. 상속 선언과 멤버 호출

1) 상속 선언

상속은 다음과 같이 선언할 수 있다.

class (파생 클래스) : (public/private/protected) (기본 클래스){

	.....
    
};

예를 들어 기본 클래스(부모 클래스)가 Book이고 파생 클래스(자식 클래스)가 SF면

class SF : public Book {
	
    .....

};

로 선언한다.

2) 멤버 호출

상속을 받으면 자신의 멤버가 되기 때문에 파생 클래스에서 기본 클래스의 멤버를 호출할 때 별다른 키워드가 필요없고, 자기 멤버를 호출하는 방법과 동일하다. main 함수와 같은 외부에서도 기본 클래스의 멤버는 파생 클래스의 멤버처럼 다루어질 수 있다.

class Book{
	string publisher;
public:
	void Set(string p){publisher = p; }
    void show(){cout<<publisher;}
};


class SF : public Publisher{
	int page;
public: 
	void SetPage(int page){this->page = page;}
};


int main(){
	Book b;
    SF sf;
    
    sf.Set("Giant Books");
    sf.show();   //기본 클래스의 Set 멤버 함수를 파생 클래스 sf로도 접근 가능
    sf.page(300);
}

Set()이나 show()는 기본 클래스 b(Book)의 멤버함수이지만, 파생 클래스 sf(SF)도 b의 멤버들을 상속받았으므로 sf.Set(), sf.show()로 main 함수에서 다루어질 수 있다.

그렇지만 다음은 불가능하다.

class Book{
	string publisher;
public:
	void Set(string p){publisher=p;}
};


class SF : public Publisher{
	int page;
public:
	void SetNew(string p2){publisher=p2;}  //여기가 안 된다!!
};

파생 클래스 SF의 멤버 함수들은 기본 클래스의 private 변수인 publisher에 직접 접근할 수 없기 때문에,

void SetNew(string p2){publisher=p2}

에서 오류가 발생한다.
파생 클래스에서 기본 클래스의 private 변수에 접근하려면 기본 클래스의 public 함수(예제에서는 Set)을 통해서만 간접적으로 접근할 수 있다.

3) 접근 지정자

  • protected 접근 지정자란?
    1) 상속 선언에서 상속은 다음과 같이 선언할 수 있다고 하였다.
class (파생 클래스) : (public/private/protected) (기본 클래스){

	.....
    
};

public이나 private 접근 지정자는 많이 써봤는데 protected는 거의 써본 적이 없어서 당황했다. 누구세요..?

public, private, protected 접근 지정자를 가진 멤버의 차이는 다음과 같다.

publicprivateprotected
멤버를 선언한 클래스ooo
파생 클래스oxo
외부함수나 클래스oxx

2) 멤버 호출 에서 언급하였 듯 기본 클래스의 private 변수는 파생 클래스의 멤버들도 직접 접근할 수 없다. 파생 클래스가 기본 클래스의 private 멤버에 접근하려면 기본 클래스의 public 멤버를 통해 간접 접근할 수만 있었다. 그렇다고 public 변수로 선언하면 main 함수와 같은 외부함수나 다른 클래스도 변수를 접근할 수 있게 되어 좋지 않다. 따라서 파생 클래스와 기본 클래스끼리 공유하려는 변수가 있을 때, protected 변수를 사용하면 편리해진다. 그러면 외부함수나 클래스는 접근할 수 없지만, 파생 클래스와 기본 클래스 간에는 편하게 공유할 수 있다.

  • public 상속 선언
    protected에 대해 알아봤으니 이제 상속 선언으로 다시 돌아가보자.
class Book{
private:
	int author;
protected:
	string publisher;
public:
	int price;
};

class SF : public Book{    //public 상속 
	...
};

SF 클래스가 Book 클래스를 public으로 상속받았다. public으로 상속받으면 접근 지정 변경 없이 그대로 상속이 확장된다. 따라서 Book 클래스의 author 변수는 private, publisher 변수는 protected, price 변수는 public 변수로 그대로 유지된다.

  • protected 상속 선언
    protected로 상속받으면 최대 접근 지정 범위가 protected가 되어 상속된다. 따라서 public 변수였던 price는 SF 클래스에서 protected 변수로 접근 지정자가 변경된다. 단, 기본 클래스인 Book에서 price는 여전히 public이며, 파생 클래스인 SF에서만 price가 protected로 변경되는 것이다. 만약 SF의 파생 클래스 Korean SF가 생긴다면,
class Korean SF : public SF{
	...

public으로 SF를 선언받아도 Korean SF에서 price 변수는 protected일 것이다.

반대로 Book의 또 다른 파생 클래스 Mystery가 선언되고

class Mystery : public Book{

Book을 public으로 상속받으면 Mystery 클래스에서 price는 public 변수일 것이다. Book 클래스의 price 변수의 접근지정자는 변경된 것이 아니기 때문이다.

  • private 상속 선언
    private로 상속받으면 최대 접근 지정 범위를 private로 하여 상속이 확장된다. 따라서
class Book{
private:
	int author;
protected:
	string publisher;
public:
	int price;
};

class SF : private Book{    //private 상속 
	...
};

에서 SF 클래스의 author, publisher, price는 모두 접근지정자가 private가 된다.


3. 생성자, 소멸자 실행 순서

1) 부모-자식 상속

한 클래스를 다른 클래스에 상속하면 부모 클래스가 먼저 생성되고, 자식 클래스가 다음으로 생성된다. 소멸자 실행 순서는 (당연히) 반대이다.

#include <iostream>
using namespace std;

class Parent{
    int pdata;
public:
    Parent(){cout<<"parent 생성자 실행"<<endl;}
    ~Parent(){cout<<"parent 소멸자 실행"<<endl;}
};

class Child : public Parent{
    int cdata;
public :
    Child(){cout<<"Child 생성자 실행"<<endl;}
    ~Child(){cout<<"child 소멸자 실행"<<endl;}
};

int main(){
    Child a;
}

2) 부모-자식-그 다음 자식 상속

Parent를 Child에, Child를 Baby 클래스에 상속했다. Parent, Child, Baby 클래스 순서로 생성된다. 소멸자 실행 순서는 역시 당연히 반대이다.

#include <iostream>
using namespace std;

class Parent{
    int pdata;
public:
    Parent(){cout<<"parent 생성자 실행"<<endl;}
    ~Parent(){cout<<"parent 소멸자 실행"<<endl;}
};

class Child : public Parent{
    int cdata;
public :
    Child(){cout<<"Child 생성자 실행"<<endl;}
    ~Child(){cout<<"child 소멸자 실행"<<endl;}
};

class Baby : public Child{
    int bdata;
public:
    Baby(){cout<<"baby 생성자 실행"<<endl;}
    ~Baby(){cout<<"baby 소멸자 실행"<<endl;}
};

int main(){
    Baby b;
}

3) 부모-자식-그 다음 자식 1, 2 상속

Parent을 Child로, Child를 Baby와 Dog 클래스에 상속했다.

#include <iostream>
using namespace std;

class Parent{
    int pdata;
public:
    Parent(){cout<<"parent 생성자 실행"<<endl;}
    ~Parent(){cout<<"parent 소멸자 실행"<<endl;}
};

class Child : public Parent{
    int cdata;
public :
    Child(){cout<<"Child 생성자 실행"<<endl;}
    ~Child(){cout<<"child 소멸자 실행"<<endl;}
};

class Baby : public Child{
    int bdata;
public:
    Baby(){cout<<"baby 생성자 실행"<<endl;}
    ~Baby(){cout<<"baby 소멸자 실행"<<endl;}
};

class Dog : public Child{
    int ddata;
public:
    Dog(){cout<<"dog 생성자 실행"<<endl;}
    ~Dog(){cout<<"dog 소멸자 실행"<<endl;}
};


int main(){
    Baby b;
    Dog d;
}

메인함수에서 Baby 클래스 b를 먼저 선언하고 Dog 클래스 d를 나중에 선언했으므로
1) Parent, Child, Baby 클래스 생성자 순서대로 실행
2) Parent, Child, Dog 클래스 생성자 순서대로 실행 ( Parent와 Child 생성자가 한 번 더 실행된다.)
4) Dog, Child, Parent 클래스 소멸자 순서대로 실행
5) Baby, Child, Parent 클래스 소멸자 순서대로 실행


4. 상속과 객체 포인터

1) 업 캐스팅과 다운 캐스팅

class One{
public:
    void showOne(){
        cout<<"One Class"<<endl;
    }
};

class Two : public One{
public:
    void showTwo(){
        cout<<"Two Class"<<endl;
    }
};

int main(void){
    One* op = new Two;  //업캐스팅 : 자료형(?)이 One인 포인터 op가 Two 객체를 가리킴 ->기본 클래스 포인터로 파생 클래스를 가리킴 
    op->showOne();      //부모 객체 포인터이므로 부모 객체의 멤버함수에 접근할 수 있다. 
    //op->showTwo();  <-이건 오류 발생(부모 객체 포인터로 자식 객체는 가리킬 수 있지만 자식 객체의 멤버함수에는 접근할 수 없다)
    
    // Two*tp = op;   <- 오류 발생 (op의 자료형(One)과 tp의 자료형(Two)가 다르기 때문에 )
    Two*tp =(Two*)op;  //다운캐스팅 : 위의 오류를 방지하기 위해 op가 가리키는 객체의 자료형은 Two임을 컴파일러에게 다시 명시해줌 
    tp->showOne();     //자식 객체 포인터이므로 부모 객체의 멤버함수와 자식 객체의 멤버함수에 모두 접근할 수 있다 
    tp->showTwo();
}

업캐스팅을 통해 부모 객체 포인터로 자식 객체를 가리킬 수 있지만, 자식 객체의 멤버함수에는 접근할 수 없다고 하였다. 그러면 대체 업캐스팅은 뭐하러 필요한 걸까? 그건 오버라이딩과 가상함수를 사용하면 부모 객체 포인터로 자식 객체의 함수에도 접근할 수 있기 때문이다. 지금은 알 수 없지만.. 오버라이딩과 가상함수를 공부하면 업캐스팅의 쓸모를 이해할 수 있다. (스포 : 자식 객체의 함수를 가상 함수로 작성하면 부모 객체 포인터도 이 가상 함수에 접근할 수 있다. 또한, 오버라이딩을 통해 부모 객체의 멤버 함수를 자식 객체에서 재정의할 수도 있다.)


5. 오버라이딩과 가상함수, 가상소멸자

1) 오버라이딩

부모 클래스의 멤버 함수를 자식 클래스에서 재정의하는 것을 오버라이딩이라고 한다.

#include <iostream>
using namespace std;

class One{
public:
    void show(){
        cout<<"One class"<<endl;
    }
};

class Two : public One{
public:
    void show(){   //오버라이딩 
        cout<<"Two class"<<endl;
    }
};

int main(){
    One o; Two t;
    o.show();    //One 클래스의 show() 호출
    t.show();    //Two 클래스의 show() 호출
    t.One::show();  //One 클래스의 show() 호출
}

2) 오버라이딩과 업캐스팅

#include <iostream>
using namespace std;

class One{
public:
    void show(){
        cout<<"One class"<<endl;
    }
};

class Two : public One{
public:
    void show(){   //오버라이딩 
        cout<<"Two class"<<endl;
    }
};

int main(){
    One *op = new Two();  //부모 클래스의 포인터로 자식 클래스를 가리킴
    op->show();  //부모 클래스의 멤버함수 실행  
}

이 코드를 실행하면 One 클래스의 show()가 실행되어 "One class"가 출력된다. 그 이유는 앞서 업캐스팅의 개념에서 말했듯, 부모 객체 포인터로 자식 객체를 가리킬 수 있지만 자식 객체의 멤버함수에는 접근할 수 없기 때문이다. 따라서 포인터가 자식 객체를 가리키고 있더라도 함수는 부모 객체의 멤버함수가 호출된다. 그렇다면 부모 객체의 포인터로 자식 객체의 멤버함수를 가리킬 수는 없을까? 그 방법은 바로 가상함수에 있다.

3) 가상함수

#include <iostream>
using namespace std;

class One{
public:
    virtual void show(){    //virtual 키워드를 추가해 가상함수 선언 
        cout<<"One class"<<endl;
    }
};

class Two : public One{
public:
    void show(){   //가상 함수를 오버라이딩한 함수는 virtual 키워드 없이도 가상함수가 된다
        cout<<"Two class"<<endl;
    }
};

int main(){
    One *op = new Two();  //부모 클래스의 포인터로 자식 클래스를 가리킴
    op->show();  //자식 클래스의 멤버함수 호출 
}

가상함수는 함수가 호출 될 때 포인터의 자료형과 관계 없이 실제 객체 타입에 맞는 오버라이딩 함수를 호출한다. 이게 무슨 소리냐고요? 지금 위의 One 클래스의 show()함수와 Two 클래스의 show() 함수는 모두 가상함수이다. 포인터 op는 자료형이 One 클래스이고, 가리키고 있는 실제 객체 타입은 Two 클래스이다. 두 함수가 가상함수이기 때문에 포인터 op는 실제 객체 타입인 Two 클래스의 함수를 가져오게 된다.

이것이 가능한 이유는 가상함수는 동적 바인딩을, 일반함수는 정적 바인딩을 하기 때문이다. 함수 바인딩은 "함수 호출 코드를 실행됐을 때 실제로 실행할 함수"를 결정하는 것이다. 정적 바인딩은 "프로그램을 실행 전에" 이 함수를 결정하기 때문에, 2) 에서 살펴봤던 코드에서 op의 자료형에 따라 One class의 show()함수가 호출된다. 동적 바인딩은 "함수 호출 코드가 실행했을 때" 이 함수를 결정하기 때문에, 실제로 가리키는 객체(Two)의 함수가 호출될 수 있다.

4) 순수 가상 함수와 추상 클래스

부모 클래스의 가상 함수를 정의해줄 필요가 없을 수도 있다. 그럴 때에는 선언만 하고 구현하지 않는 순수 가상 함수를 사용한다. 구현은 자식 클래스에서 이루어진다. 순수 가상 함수를 한 개 이상 가지고 있는 클래스를 추상 클래스라고 한다.

class animal{    //추상 클래스 
public:
	virtual void say()=0; //순수 가상 함수 
};

class dog : public animal{
public:
	virtual void say() {cout<<"멍멍"<<endl;}
}

class cat : public animal{
public:
	virtual void say() {cout<<"야옹"<<endl;}
}

5) 가상 소멸자

업캐스팅은 소멸자 호출 문제를 가지고 있다.

class Parent{
public:
    Parent(){cout<<"기본 클래스 생성자 호출\n";}
    ~Parent(){cout<<"기본 클래스 소멸자 호출\n";}
};

class Child : public Parent{
public:
    Child(){cout<<"파생 클래스 생성자 호출\n";}
    ~Child(){cout<<"파생 클래스 소멸자 호출\n";}
};

int main(void){
    Parent *p = new Child();
    delete p;
}

를 실행하면

과 같은 결과가 출력된다. 즉, 자식 클래스의 소멸자가 호출되지 않는 것이다. 왜 그럴까? 4.1)에서 말했듯 부모 객체 포인터(Parent *p)로 자식 객체의 멤버함수(소멸자 ~Child())에는 접근할 수 없다. 따라서 delete p;를 실행해도 자식 객체의 소멸자는 실행되지 않고 건너뛰게 된다. 이를 해결하기 위해서 가상 소멸자가 필요하다.

#include <iostream>
using namespace std;

class Parent{
public:
    Parent(){cout<<"기본 클래스 생성자 호출\n";}
    virtual ~Parent(){cout<<"기본 클래스 소멸자 호출\n";} //가상 소멸자
};

class Child : public Parent{
public:
    Child(){cout<<"파생 클래스 생성자 호출\n";}
    ~Child(){cout<<"파생 클래스 소멸자 호출\n";}
};

int main(void){
    Parent *p = new Child();
    delete p;
}

를 실행하면

자식 클래스의 소멸자가 정상적으로 호출된다. 5.3)에서 말했듯 가상 함수는 포인터의 자료형이 아닌 실제 객체 타입에 맞는 오버라이딩 함수를 호출하므로, p의 실제 자료형인 Child 객체의 ~Child()소멸자를 호출한다.


6. 다중 상속과 가상 상속

#include <iostream>
using namespace std;

class super{
protected:
    int s;
};

class father:virtual public super{  //super를 가상상속
protected: 
    int f;
};

class mother:virtual public super{   //super를 가상상속
protected:
    int p;
};

class child:public father, public mother{ //다중 상속
public:
    child(int S, int F, int P){s=S;f=F;p=P;}
    void show(){
        cout<<s<<' ' <<f<<' '<<p;
    }
};

int main(){
    child c(1, 2,3);
    c.show();
}

super를 각각 상속받은 father, mother 객체가 있을 때, father와 mother를 모두 상속받는 child 객체에서는 super의 멤버 s가 중복되어 나타나므로, 가상 상속을 이용해 중복을 방지할 수 있다.

자, 그러면 배울 걸 다 배웠으니 이제 연습 문제로 넘어가보자~

0개의 댓글