C언어

C++ 프로그래밍 - 클래스의 상속(Inheritance)과 다형성(polymorphism)

머리큰개발자 2021. 5. 6. 00:38

목차

    상속

     

    객체지향이 절차지향과 아주아주아주 다른 포인트 중 하나인 상속이다.

    상속은 말 그대로 누군가에게 물려받는 것을 의미한다. 

    상속의 개념은 결국 공통점을 묶어서 한 번에 유지,보수하기 편하게 만들기 위함이기 때문에

    경제적인 요인과 편의성을 고려한 개념이라고 생각하면 될 것 같다.

     

    클래스에서의 상속은 자신의 멤버 변수와 멤버 함수를 물려주는 것을 의미한다.

    이 때, 상속 해주는 클래스는 상위, 기초(base), 슈퍼(super), 부모(parent) 클래스라고 부르고,

    상속 받는 클래스는 하위, 유도(derived), 서브(sub), 자식(child) 클래스라고 부른다.

     

    상속의 특징은, 부모의 모든 멤버들을 자식이 물려받는다는 것이다. 

    또다른 특징은, 부모의 모든 멤버를 자식이 물려받되 온전히 자식의 소유는 아니라는 것이다.

     

    이게 무슨 뜻일까?

    #include <iostream>
    
    using namespace std;
    
    class parent {
    	int x, y;
    public:
    	parent(const int& a, const int& b) {
    		cout << "parent constructed\n";
    		x = a;
    		y = b;
    	}
    	~parent() { cout << "parent destructed\n"; }
    	int getX() const{ return x; }
    	int getY() const{ return y; }
    	void setX(const int& a) { x = a; }
    	void setY(const int& b) { y = b; }
    };
    
    class child : public parent {
    	int z;
    public:
    	child(const int& a, const int& b, const int& c ) :parent(a, b) {
    		cout << "child constructed\n";
    		z = c;
    	}
    	~child() { cout << "child destructed\n"; }
    	void print() const {
    		//cout << x << y << z;
    		cout << z << "\n";
    		cout << getX() << " " << getY() << "\n";
    	}
    };
    
    int main() {
    	child c(10,20,30);
    	c.print();
    
    
    }

    위 코드를 보자.

    상속의 사용법은 자식 클래스명 : 부모 클래스명으로 한다.

    여기서 parent의 멤버 변수는 private int x,y 이다. 

    child의 멤버 변수는 private int z 이다. 

    생성자는 잠시 후에 보고, child를 하나 생성하자. 

    그다음 c.print() 함수를 호출 했을 때, 주석 처리한 문장은 실행이 가능할까?

    언뜻 생각하면 child는 parent의 모든 멤버들을 상속받으므로 child 안에는 private int x,y; 가 있을테니까 바로 접근해서 사용하는 것이 가능해보인다. 

     

    하지만! 부모의 private는 온전히 자식의 멤버가 아니다. 부모의 private 멤버 변수는 부모의 멤버 함수로만 접근할 수 있다!!

    이러한 특성 때문에 온전히 자식의 소유가 아니라는 것이다.

    또한 위의 코드안에는 많은 것이 함축되어 있는데 하나씩 살펴보도록 하자.

     

    상속의 특징

     

    1. 자식 클래스의 생성자는 부모 클래스의 멤버까지 초기화해주어야 한다.

    -부모의 멤버 변수는 부모의 멤버 함수로 접근해야하기 때문에(private일 경우, 그리고 멤버 변수는 거의 항상private이지)

    부모의 생성자를 호출해주어야하고, 이는 멤버 이니셜라이저로 생성자를 호출하는 것이 좋다. 

    또한 항상 부모 클래스의 생성자가 먼저 호출되고 그 다음 자식 클래스의 생성자가 호출된다.

     

    2. 자식 클래스를 생성할 경우 부모 클래스의 생성자는 반드시 호출된다.

    -부모 클래스의 생성자를 명시하지 않으면 기초 클래스의 기본 생성자가 호출된다. 

    다시 말해 멤버 변수는 항상 생성자에 의해 초기화된다.

     

    3. 소멸자의 호출 순서는 역순이다.

    -자식이 먼저 소멸되고 그 후에 부모가 소멸된다. 이는 할당 받은 멤버 변수를 해당 클래스의 소멸자에서 할당 해제해줘야한다는 것을 의미한다. 

     

    좀 이상하지 않은가? 결국에 하나로 취급되는게 아니라, 2개를 하나처럼 취급한다는 소리니까.. 뭐 받아들이자.

     

    4. 접근제어 지시자를 통한 상속은 멤버에 선언한 것과 별개로 처리된다.

    -위의 코드에서 child는 public parent 를 상속받았다. 이것은 parent 의 멤버들을 전부 public으로 바꿔서 받으라는 뜻이 절대 아니다. 오히려 반대의 뜻을 가진다.

    즉, public으로 상속받으면 public 보다 넓은 공개 범위를 가지는 멤버를 public으로 만들라는 뜻이고,

    protected로 상속받으면 protected 보다 넓은 공개 범위를 가지는 멤버를 protected로 만들라는 뜻(public->protected)

    private 로 상속받으면 public과 protected를 private 멤버로 만들어준다!

     

    대다수의 상속이 public 임을 감안하면, protected와 private는 거의 쓸모가 없다. 특히 private로 받으면.. 아무것도 접근도 못할텐데 어디에 쓰려나...

     

    5. 상속은 범위가 작은 것으로부터 받는다.

    -공통점을 묶어서 상위 클래스로 보통 구성하기 때문에, 상위 클래스는 하위 모든 클래스의 공통된 특징만을 가지고, 결국 범위가 항상 하위 클래스보다 같거나 작게 된다.

    공통점을 상속받고 개개인의 특성을 더한 것이 하위 클래스가 된다.

    즉 포함관계에 있는 상태가 주로 상속의 대상이다. 

     

    생명과학부에서 항상 구호로 외치는 종속과목강문계가 그 예시이다. 

    굳이 그림까지 붙여놓는 예시

     생명으로 갈수록 생명체 전반적으로 공통된 특징이 몇 개 남지 않으며, 종으로 갈수록 다양하고 개별적인 특징을 가진다는 것은 강아지만 봐도 알 수 있을 것이다.

    이러한 포함관계를 가지지 않는다면 상속을 잘못하고 있을 가능성이 높으므로 주의해서 살펴봐야 한다.

     

    그렇다면 나에게 포함된 물건은 어떨까?

    내가 옷을 입고 있다면 그 옷은 나와 상속 관계로 묶일 수 있을까?

    물론 가능은하다. 옷이란 클래스를 선언하고 나라는 클래스가 상속받으면 옷의 멤버를 그대로 사용할 수 있기 때문이다. 

    하지만 만약 안경이라던지, 핸드폰이라던지를 추가하면 어떻게 되는가? 

    다중상속을 받을 수도 있겠지만, 다중상속은 굉장히 골치 아플 뿐더러, 또다른 문제가 발생한다.

     

    만약 내가 옷을 갖고 있지 않다면 어떻게 될까?

    옷을 상속받지 않은 새로운 클래스를 정의해야할까? 

     

    이렇게 어려운 문제들이 추가적으로 발생하기 때문에 소유관계는 해당 물건을 클래스로 선언한 뒤, 멤버 변수로 넘기는 것이 훨씬 낫다는 것을 알 수 있다. 

     

    그렇기 때문에 항상 상속은 포함관계에 유의해서 사용하도록 하자.

     

    다형성(polymorphism)

    C++ 에서는 놀랍게도 자식 클래스를 가리키는 포인터를 부모 클래스 포인터로 선언할 수 있다.

    이게 무슨 뜻이냐면, 위의 코드에서 parent * ptr=  new child(10,20,30); 으로 선언하여 ptr을 사용할 수 있다는 것이다!

    이것이 놀랍지 않다면.. 프로그래밍 감수성을 잃어버린 사람이 아닐까..?

     

    지금까지 클래스를 알아봤다면 단번에 parent 보다 child가 메모리 상에 더 큰 범위를 차지하고 있는 객체라는 것을 알 수 있었을 것이다. 

    그런데 포인터는 parent가 차지하는 메모리의 크기만큼만 가리킬텐데, 어떻게 child를 가리키게 할 수 있고 그것을 이용할 수 있는 것일까?

     

    여기에서부터 type casting 과 function overriding 이해가 필요하다.

     

    함수 오버라이딩(functino overriding) 은 함수 오버로딩(overloading)과 살짝 다르다.

    함수 오버로딩은 같은 이름의 다른 기능을 구현하는 것이었다면, 함수 오버라이딩은 같은 함수를 덮어 쓰는 것을 의미한다. 

    위 코드를 살짝 수정한 아래의 코드를 보자.

    #include <iostream>
    
    using namespace std;
    
    class parent {
    	int x, y;
    public:
    	parent(const int& a, const int& b) {
    		x = a;
    		y = b;
    	}
    	int getX() const{ return x; }
    	int getY() const{ return y; }
    	void print() const {
    		cout << "parent x y " << x << " " << y << "\n";
    	}
    };
    
    class child : public parent {
    	int z;
    public:
    	child(const int& a, const int& b, const int& c ) :parent(a, b) {
    		z = c;
    	}
    	void print() const {
    		//cout << x << y << z;
    		cout << "child : ";
    		cout << z <<" " ;
    		cout << getX() << " " << getY() << "\n";
    	}
    };
    
    int main() {
    	parent* ptr = new child(10,20,30);
    	ptr->print();
    
    
    }

    ptr->print(); 의 결과가 무엇이 child의 print() 결과가 나올 것이라 예상했을 수도 있다. 

    실제로 ptr은 child를 가리키는 포인터이므로 child의 print()가 나오길 원했지만, 실제로는 parent 타입을 가리키는 포인터이므로 parent class의 print()가 호출됨을 알 수 있다. 

     

    그럼 C의 방식으로 강제로 타입을 변경하면 어떻게 될까? ((child *)ptr) -> print(); 를 호출하면 실제로 이렇게 나온다.

    오! 이번엔 우리가 원하는대로 child의 print()가 호출되었다.

    하지만 C++에서는 C 방식의 type casting은 절대 지양해야한다.

    왜냐하면, C++은 컴파일 시에 문제가 발생하지 않으면 런타임에 문제가 발생하지 않도록 보장해주는 언어이기 때문이다. 

    C++의 방식만을 사용하면 이 특성이 유지되지만, C언어 방식의 type casting 을 사용하면 컴파일러가 사용자를 믿고 강제적으로 변경해주기 때문에 런타임에 에러가 발생하지 않음을 보장해 줄 수 없고, 이는 런타임 중 치명적인 오류를 발생시킬 수 있지만 컴파일러는 그 오류가 어디서 나왔는지 찾지 못하기 때문에 디버깅이 "매우" 어려워진다.

    C++방식의 type casting 은 후술하도록 하고, 이런 형변환을 하지 않고도 자동으로 자신이 가리키는 클래스를 파악하고 해당 멤버 함수를 호출할 수 있도록 설계하면 굉장히 편하지 않겠는가? 

    하나하나 type casting 할 필요가 없으므로 신경쓸 것도 굉장히 줄어들고 그에 따라서 오류도 줄어들 것이다.

     

    이런 방식을 객체 다형성(object polymorphism)이라고 부른다.

    부모의 속성을 여러 자식에서 공통적으로 사용할 수 있지만, 자식마다 다른 속성을 가질 수도 있는 것.

    공통적인 부분에서 부터 뻗어 나오는 다양성을 다형성이라 부른다.

     

    그렇다면 어떤 방식으로 실제 객체를 구분하고 찾을 수 있을까?

    포인터의 선언은 동일하게 부모 클래스를 가리켜 편하게 사용할 것이므로 건들면 안된다. 

    또한 ptr->child::print(); 의 방식도 사용할 수 없다. (부모 클래스로 자료형을 상정하기 때문에 실제 객체는 상관없다)

     

    바로 이럴 때 사용되는 것이 가상함수(virtual function)이다.

     

    가상함수(virtual function)

    아주 대단히 대단하게도, 가상함수를 선언하고 그것을 호출했을 경우, 포인터는 해당 자료형의 멤버 함수를 호출하기 전에 자신이 가리키는 객체에 해당 함수가 오버라이딩 되어있는지를 파악한다. 

    즉, 선언한 자료형으로 포인터가 고정되는 것이 아니라, 실제 가리키는 객체를 파악하고 해당 자료형으로 포인터를 바꾸어서 판단하게 해준다는 것이다. 정말 멋져~!

     

    가상함수는 멤버 함수의 앞에 virtual 키워드를 선언해서 사용한다.

     

    위의 코드를 또다시 변형한 다음의 코드를 보자.

    #include <iostream>
    
    using namespace std;
    
    class parent {
    	int x, y;
    public:
    	parent(const int& a, const int& b) {
    		x = a;
    		y = b;
    	}
    	int getX() const{ return x; }
    	int getY() const{ return y; }
    	virtual void print() const {
    		cout << "parent x y " << x << " " << y << "\n";
    	}
    };
    
    class child1 : public parent {
    	int z;
    public:
    	child1(const int& a, const int& b, const int& c ) :parent(a, b) {
    		z = c;
    	}
    	void print() const {
    		//cout << x << y << z;
    		cout << "child1 : ";
    		cout << z <<" ";
    		cout << getX() << " " << getY() << "\n";
    	}
    };
    class child2 : public parent {
    	int z;
    public:
    	child2(const int& a, const int& b, const int& c) :parent(a, b) {
    		z = c;
    	}
    	void print() const {
    		//cout << x << y << z;
    		cout << "child2 : ";
    		cout << z << " ";
    		cout << getX() << " " << getY() << "\n";
    	}
    };
    int main() {
    	parent* ptr = new child2(10,20,30);
    	ptr->print();
    
    }

    이번엔 parent 를 상속받은 자식 클래스가 child1, child2 두 개가 되었다. 

    parent * ptr 는 child2를 가리키는 포인터고, 이 때 ptr->print(); 를 호출하면 어떤 결과가 나올까?

    아마도 virtual 을 선언하지 않았을 때는 위와 같은 결과로 parent의 print()가 호출되었을 것이다.

     

    하지만 virtual을 선언하고 실행시에 어떻게 바뀌는가?

    세상에 마상에, 포인터가 virtual 함수를 만나니 선언된 자료형이 아닌 실제 가리키는 객체를 파악하고 그 객체의 멤버 함수를 호출하는 것을 볼 수 있다!! 

    같은 함수를 호출해 다른 결과를 얻는 것 또한 다형성의 일부이다

    이제 우리는 강제적인 형변환에서 벗어나 오버라이딩을 통해, 공통된 클래스를 가리키는 포인터 하나만으로 수많은 자식들의 멤버에 접근할 수 있게 되었다! 짞ㅉ까짞짞짞짞짞

     

    하나 알아둬야 할 것은, 부모 클래스에서 virtual로 선언한 것은 자식 클래스에서 모두 virtual로 선언된다. 

    만약 내가 명시적으로 적지 않았어도 컴파일러에 의해 자동으로 선언된다. 

    하지만, 자식 클래스에서 또 상속을 할 수 있기 때문에, 코드의 명확성을 위해서 virtual을 꼭 명시하도록 하자.

     

    순수 가상함수(Pure Virtual Function)

    순수 가상함수는 무엇인가?

    위의 코드에서 parent와 child를 실제 부모와 자식1,2로 본다면, 보다 한단계 위의 포함관계인 사람으로 묶을 수 있다는 것을 알 수 있다.

    그렇다면 person 클래스를 만들고, 공통적으로 사용되는 함수들을 virtual 함수로 모두 선언해놓으면 나는 항상 person 포인터만을 사용해 모든 것을 컨트롤 할 수 있을 것이라 기대할 수 있다. 

     

    하지만 실제로 내가 person이라는 클래스를 객체로 생성할 일이 있을까? 

    실제 객체로 존재하는 것들은 부모와 자식1,2 중 하나일 것이다. 

    사람이라는 종을 객체로 만들 이유가 없기 때문에, person 클래스는 객체생성을 목적으로 정의되지 않고,

    오로지 하위 클래스들의 기능들만을 내포하게 만든다. 

    이 때 사용되는 것이 순수 가상함수이다. 

     

    순수 가상함수는 함수의 몸체가 정의되지 않은 함수를 의미한다. 

    즉 순수 가상함수는 virtual로 선언만 되고 정의되지 않은 상태이므로, 하위 클래스에서 잊지말고 반드시 정의해줘야 하는 상태로 남길 수가 있다. 

    또한 몸체가 정의되지 않았기 때문에 객체로 생성할 수 없으므로 순수 가상함수가 하나라도 포함된 클래스를 

    추상 클래스(abstract class)라고 명칭한다. 

     

    순수 가상함수는 위의 두 가지 장점, 1. 하위 클래스에서 반드시 정의하도록 에러를 발생시켜 실수를 방지한다, 2. 같은 이름의 함수를 사용하여 각 객체에 맞는 결과를 얻는다. 를 위해 사용하고 사용법은 다음과 같이 person class에 선언하면 된다.

    virtual void print() const =0 ;

    몸체가 들어갈 {} 자리에 대신 =0 이 들어가고 종료되면 순수 가상함수로 인식한다.

     

    순수 가상함수를 학습하면서 얻은 2번째 장점이 다형성의 한 예시다.

    가상함수를 사용함으로써 같은 함수 다른 결과를 얻을 수 있었는데, child1을 가리킬 경우와 child2를 가리킬 경우 똑같은 print()를 사용해도 각 객체에 맞는 print()가 호출됨을 볼 수 있었다. 

    물론 오버로딩과 템플릿 등 다양한 예시가 남았지만 가상함수에서 다형성의 한 예를 찾을 수 있다.

     

    가상 함수를 사용할 때는 주의할 점이 있다!

    위의 코드에서 parent 를 가리키는 포인터를 사용해서 child 2에 접근한 상황을 다시 보자.

    이 때 실제로 생성된 객체는 child2 클래스였다. 

    child2가 생성될 때를 다시 한 번 생각해보자.

    child2는 parent의 생성자를 호출한 후 child2의 생성자를 호출하여 초기화했었다.

    그리고 child2가 소멸할 때는 child2의 소멸자를 호출하고 parent의 소멸자를 호출했다. 

     

    하지만 ptr을 delete로 소멸시키면 어떻게 될까?

    ptr은 delete 되면서 소멸자를 호출한다. 이 때 호출되는 소멸자는 ptr이 선언된 자료형인 parent의 소멸자를 호출하고 종료된다. 

    따라서 parent는 소멸하지만 child2는 남아있게 된다!

    이런 상황에서 메모리 누수가 발생하므로 child2의 소멸자를 호출하게 하려면 어떻게 해야할까?

     

    소멸자를 가상함수로 선언하면 된다.

    이를 가상 소멸자라고 부르는데, 가상 소멸자는 일반적인 가상 함수와 다르게 해당 객체의 가상 소멸자를 호출한 후 차례대로 상위 클래스의 가상 소멸자를 호출한다. 

    따라서 최상위 클래스의 소멸자를 가상 소멸자로 선언하면 최하위 클래스의 소멸자부터 호출되며 차례대로 모두 호출하게 되므로 메모리 누수 없이 안전하게 소멸시킬 수 있다. 

    그러므로 항상 가상함수를 이용해 다형성을 구현할 때는 소멸자도 가상 함수로 선언하도록 하자

     

    따라서 위의 코드는 다음과 같이 짜야 완전히 구현했다고 할 수 있다.

    #include <iostream>
    
    using namespace std;
    
    class parent {
    	int x, y;
    public:
    	parent(const int& a, const int& b) {
    		x = a;
    		y = b;
    	}
    	virtual ~parent(){}
    	int getX() const{ return x; }
    	int getY() const{ return y; }
    	virtual void print() const {
    		cout << "parent x y " << x << " " << y << "\n";
    	}
    };
    
    class child1 : public parent {
    	int z;
    public:
    	child1(const int& a, const int& b, const int& c ) :parent(a, b) {
    		z = c;
    	}
    	virtual ~child1() {}
    	virtual void print() const {
    		//cout << x << y << z;
    		cout << "child1 : ";
    		cout << z <<" ";
    		cout << getX() << " " << getY() << "\n";
    	}
    };
    class child2 : public parent {
    	int z;
    public:
    	child2(const int& a, const int& b, const int& c) :parent(a, b) {
    		z = c;
    	}
    	virtual ~child2() {}
    	virtual void print() const {
    		//cout << x << y << z;
    		cout << "child2 : ";
    		cout << z << " ";
    		cout << getX() << " " << getY() << "\n";
    	}
    };
    int main() {
    	parent* ptr = new child2(10,20,30);
    	ptr->print();
        
        
    	child1 c(10, 20, 30);
    	parent& ref = c;
    	ref.print();
    
    
    }

     

    사실 위 코드는 동적으로 할당 받은 것이 없어서 문제가 될 것은 없지만, 

    동적 할당을 사용하기 위해서 반드시 염두에 두자.

     

    위의 예시 마지막 꼬다리에 붙여놓은 parent& ref= c; 를 보면, 참조자를 이용해서도 똑같이 가상함수를 사용할 수 있다는 것을 보여준다.

     

     

    가상함수는 그럼 어떻게 동작하는 것인가?

     

    가상함수가 포함된 클래스에 대해서 컴파일러가 가상함수 테이블(Virtual Table)을 생성하고, 함수 포인터를 사용해 실제 호출되어야 할 함수가 있는 위치를 테이블에 저장한다. 

    테이블은 해쉬 테이블처럼 key:value로 구성되어있고, key는 함수를 구분시켜주는 역할, value는 함수의 위치를 저장한다.

    만약 하위 클래스에서 같은 함수를 오버라이딩할 경우, 먼저 저장된 함수의 key값은 사라지고 오버라이딩 된 함수만이 key값으로 테이블에 남게 된다. 그러므로 특정 클래스의 객체에 가상함수 테이블에 접근하여 호출하고, 상위 클래스에서 정의된 가상함수 정보는 아예 찾을 수 없다. 

    이 과정에서 테이블이 데이터에 생성되고, 참조하는데 시간이 아주 조금 더 발생하기 때문에 C에 비해 살~짝 속도가 딸린다. 

     

     

    다중상속(Multiple Inheritance)

    사실 다중상속은 굉장히 난해하고 문제가 많이 발생되기 때문에 잘 쓰이지 않는다. 

    특히 한국인이 가장 많이쓰는 JAVA에서는 이러한 문제들 때문에 애초에 지원하지 않는다.

    그럼에도 조금만 살펴보자면, 기본적인 방식은 같다. class classname : modifier parent1, modifier parent2 {...} 처럼

     ,연산자를 이용해 연속으로 상속받는다.

     

    이렇게 간단함에도 문제가 발생하는 이유는 다음과 같다.

    1. 두 상위 클래스에 동일한 이름의 멤버 함수들이 존재할 경우 - 호출의 모호성

    해결책1 상위클래스::멤버함수();직접 호출,

    해결책2 동일한 이름을 가지는 멤버를 virtual로 선언한다. 

    이럴 경우 하나의 멤버만을 상속받아 멤버로 갖게 된다. (뭐를 받을진 잘 모르겠어용 -> 선언된 시기가 빠른애가 들어온대용)

     

    이러한 해결책에도 불구하고, 구조가 복잡하고, 그럼에도 불구하고 모호하기 때문에 거의 안쓴다..고 한다!

     

    이렇게 이번 글까지 해서 클래스를 (거의) 모두 알아보았다.

    다음 글부터는 다형성의 확립을 위해 필요한 연산자 오버로딩과 템플릿, 그리고 예외처리를 위한 try catch를 알아보도록 하겠다.