[ c++ ] 상속과 다형성에 대해 알아보자

채명석·2022년 1월 23일
4

cpp

목록 보기
10/11

상속이란?

상속이란 말 그대로 무엇을 물려받는다는 말 입니다.
좀 더 자세히 말 하면 부모클래스의 멤버변수 및 멤버함수들을 자식클래스에서 상속받아서 사용할 수 있도록 하는 게 객체지향에서 말하는 상속입니다.
상속하는 방법은 다음과 같습니다.

class Base
{
	...
};

class Derived : public Base
{
	...
};

자식 클래스의 이름 옆에 :기호와 함께 상속 방식과 상속받을 클래스의 이름을 넣어주면 됩니다.

접근제어지시자

접근제어 지시자는 public, protected, private 이렇게 세 가지가 있으며 클래스 내부에 선언되어있다면 해당 범위의 멤버변수 및 메소드의 접근 범위를 지정해주는 것이고, 상속을 선언할 때 부모클래스의 이름 옆에 사용한다면 상속의 방식을 지정해주는 것으로 사용됩니다.
하지만 상속을 할 때 사용되는 접근제어 지시자는 대부분 public을 사용하며 다중상속 등 아주 제한적인 상황에서만 다른 방식을 사용합니다.

public

  • 멤버변수 및 메소드
    어디에서든 멤버변수와 메소드에 접근이 가능합니다.
class Base
{
public:
	int m_num;
};

int main(void)
{
	Base base;
  
	std::cout << base.m_num << std::endl; // OK!
}
  • 상속
    만약 상속 선언 시 사용한다면 private을 제외하고 모든 멤버들을 그대로 상속받겠다는 뜻입니다.
class Base
{
private:
	int m_num_1;
protected:
	int m_num_2;
public:
	int m_num_3;
};

class Derived : public Base
{
//접근불가: 상속은 받지만 해당 변수에 직접적인 접근이 불가능하다.
	int m_num_1;
protected:
	int m_num_2;
public:
	int m_num_3;
};

protected

  • 멤버변수 및 메소드
    자식 클래스에서만 해당 멤버에 대한 접근이 가능하고, 외부에서는 접근이 불가능하게 막는다는 뜻 입니다.
class Base
{
protected:
	int m_num;
};

class Derived : public Base
{
public:
	void	printBaseNum(void)
    {
		std::cout << m_num << std::endl; // OK!
	}
};

int main(void)
{
	Derived derived;
  
	std::cout << derived.m_num << std::endl; // 불가능!
    derived.printBaseNum(); // OK!
}
  • 상속
    상속 선언 시 사용하게 된다면 public 멤버를 protected로 변경해서 상속하겠다는 뜻 입니다.
class Base
{
private:
	int m_num_1;
protected:
	int m_num_2;
public:
	int m_num_3;
};

class Derived : public Base
{
//접근불가: 상속은 받지만 해당 변수에 직접적인 접근이 불가능하다.
	int m_num_1;
protected:
	int m_num_2;
protected:
	int m_num_3;
};

private

  • 멤버변수 및 메소드
    해당 범위안에 있는 멤버는 외부에서 접근이 불가능 하다는 뜻 입니다.
class Base
{
private:
	int m_num;
};

int main(void)
{
	Base base;
    
	std::cout << base.m_num << std::endl; // m_num에 접근불가능!
}

만약 외부에서 부모 클래스의 m_num에 접근하고 싶다면 따로 접근 함수를 public으로 둬서 해당 함수를 통해서 접근해야 합니다.

  • 상속
    상속 선언 시 사용하게 된다면 private을 제외한 모든 멤버를 private으로 상속하겠다는 뜻이 됩니다.
class Base
{
private:
	int m_num_1;
protected:
	int m_num_2;
public:
	int m_num_3;
};

class Derived : public Base
{
//접근불가: 상속은 받지만 해당 변수에 직접적인 접근이 불가능하다.
	int m_num_1;
private:
	int m_num_2;
private:
	int m_num_3;
};

부모 클래스와 자식 클래스의 생성

부모 클래스와 자식 클래스의 생성 순서

우리가 자식클래스를 하나 만든다고 가정했을 때 그러면 그 클래스의 생성과정은 어떻게 진행이 될까요?

간단한 테스트를 통해서 확인해 봅시다.

class Base
{
public:
	Base()
	{
		cout << "base" << endl;
	}
	~Base()
	{
		cout << "~base" << endl;
	}
};

class	Derived : public Base
{
public:
	Derived(void)
	{
		cout << "Derived" << endl;
	}
	~Derived()
	{
		cout << "~Derived" << endl;
	}
};

int	main(void)
{
	Derived	derived;
}

부모클래스와 자식클래스는 생성자와 소멸자만 갖고 있으며 각각 생성과 소멸시 알 수 있도록 출력을 해주고 있습니다.

출력을 확인해 보면

base
Derived
~Derived
~base

단순히 자식클래스만 만들었는데 부모클래스 또한 만들어지고 소멸되는 걸 볼 수 있습니다.

왜 그럴까요?

이유는 단순합니다. 부모 클래스의 멤버가 생성이 돼야 자식 클래스에서도 해당 멤버에 접근이 가능하기 때문입니다.
상속은 자식 클래스 안에 부모클래스의 멤버가 새롭게 생성되는게 아니라 부모클래스의 멤버에 접근이 가능하도록 허락해주는 것일 뿐이죠.
그렇기 때문에 우리는 자식 클래스를 만들어도 부모 클래스 먼저 생성이 돼야 하는 겁니다.

그와 반대로 소멸자는 생성자의 역순으로 호출되는 것을 볼 수 있습니다. 그 이유는 생성자와 비슷하죠.
만약 부모 클래스가 먼저 소멸된다고 생각해봅시다.
그러면 부모의 멤버도 모두 소멸되겠죠?? 하지만 자식 클래스는 부모클래스의 멤버에 접근할 수 있다고 말씀드렸습니다.
만약 자식 클래스에서 소멸된 부모 클래스의 멤버에 접근하는 부분이 있다면 큰 문제가 발생할 수 있겠죠.

그렇기 때문에 생성자와 반대로 소멸자는 자식 클래스 먼저 호출이 되고, 부모 클래스의 소멸자가 호출되는 것 입니다.

자식 클래스에서 부모 클래스의 생성자를 호출하는 방법

간단하게 이니셜라이저를 통해서 부모 클래스의 생성자를 호출할 수 있습니다.
만약 명시해주지 않는다면 부모 클래스의 기본 생성자를 자동으로 호출합니다.

class	Derived : public Base
{
public:
	Derived(void) : Base() // 부모 클래스의 생성자 호출
	{
		cout << "Derived" << endl;
	}
	~Derived()
	{
		cout << "~Derived" << endl;
	}
};

다형성

다들 아시다시피 클래스도 포인터를 사용할 수 있죠.

Base*	ptr;
ptr = new Base();

여기서 중요한 부분은 부모 포인터는자식 클래스도 가리킬 수 있다는 사실입니다.

Base*	ptr;
ptr = new Derived(); //부모 클래스의 포인터 ptr이 자식 클래스 Derived를 가리키고 있다.

조금 더 이해하기 쉽게 상속의 IS-A 관계를 통해서 이해해 봅시다.

간단하게 사람, 학생, 근로학생간의 관계를 봅시다.
"학생은 사람이다."
"근로학생은 학생이다."
"근로학생은 사람이다."

조금 더 다르게 표현해보면

"학생은 사람의 일종이다."
"근로학생은 학생의 일종이다."
"근로학생은 사람의 일종이다."

즉 사람이라는 틀 안에는 학생, 근로학생이 있고 그걸 클래스로 본다면
사람 클래스는 자식 클래스인 학생뿐만 아니라 간접적으로 상속하고있는 근로학생까지 포함하고 있다. 즉 사람은 학생과 근로학생을 가리킬수 있다라고 생각하면 기억하기 쉬울 것 같습니다.

이런 식으로 동일한 형태를 갖고있는데 실질적으로 갖고있는 내용물이나, 그에따른 결과물이 다르게 나오는 것을 다형성이라고 합니다.
그럼 우리는 이 다형성을 어떻게 사용해야 하는지 알아봅시다.

포인터를 통한 하위 객체들의 참조

좀 전에 말 했듯이 부모 클래스의 포인터는 자식 클래스를 가리킬 수 있다고 했습니다.

그러면 실제로 이게 가능한지 한번 알아봅시다.

class	Base
{
public:
	void	PrintBase(void)
	{
		std::cout << "Base" << std::endl;
	}
};

class	Derived : public Base
{
public:
	void	PrintDerived(void)
	{
		std::cout << "Derived" << std::endl;
	}
};

이렇게 부모 클래스와 자식클래스가 있고 각각 어떤 클래스인지 출력해주는 Print*()함수가 있습니다.

그리고 메인은 다음과 같습니다.

int	main(void)
{
	Base*	base_ptr = new Derived(); // OK!
	base_ptr->PrintDerived(); // 컴파일 에러!
}

컴파일을 해 보면 두 번째 줄에서 에러가 나는 걸 볼 수 있습니다.

왜? 실제 객체는 Derived니까 그 함수를 사용할 수 있는 거 아니야?

하지만 아쉽게도 c++컴파일러는 실제 객체의 자료형으로 판단하지 않고 포인터 변수의 자료형으로 판단을 합니다.
즉 컴파일러는 Base로 실행시켰으니까 당연히 Base겠지! 라고 생각하고 확인해보니 Base클래스에는 해당 함수가 없어서 에러를 뱉는거죠.

다른 상황을 한번 봅시다.

int	main(void)
{
	Base*		base_ptr	= new Derived();
	Derived*	derived_ptr = base_ptr;
}

base_ptr을 객체의 실제 자료형의 포인터인 derived_ptr로 형변환을 시도하고 있습니다.
하지만 이것 또한 되지 않습니다!
예외적으로 부모 클래스가 가상함수를 사용한 클래스이고, 형변환을 dynamic_cast를 통해서 한다면 가능하지만 일반적으로는 컴파일 에러가 납니다.

이것 또한 이전에서 나온 에러와 같은 이유입니다.
컴파일러는 포인터의 자료형을 보기 때문에 자식 클래스는 부모 클래스를 가리킬 수 없다고 생각해서 에러가 나는거죠.

하지만 반대로 한번 해 봅시다.

int	main(void)
{
	Derived*	derived_ptr = new Derived();
	Base*		base_ptr	= derived_ptr;
}

이전과는 반대로 자식 클래스의 포인터를 만들어서 그걸 부모 클래스로 넘겨줍니다.
이건 컴파일이 잘 됩니다.
이유는 다형성의 기본인 부모 클래스는 자식 클래스를 가리킬 수 있다.에 부합하기 때문이죠.

정리해 봅시다.

class	First
{
...
};

class	Second : public First
{
...
};

class	Third : public Second
{
...
};

이렇게 상속관계로 놓여진 클래스들이 있고, 내부에는 단순히 Print*()함수들을 갖고 있다고 합시다.
그러면 이 클래스들의 포인터는 어디까지 접근이 가능할까요?

int	main(void)
{
	Third*	third_ptr	= new Third();
	Second*	second_ptr	= third_ptr;
	First*	first_ptr	= second_ptr;

	third_ptr->PrintFirst();	//OK!
	third_ptr->PrintSecond();	//OK!
	third_ptr->PrintThird();	//OK!

	second_ptr->PrintFirst();	//OK!
	second_ptr->PrintSecond();	//OK!
	second_ptr->PrintThird();	//안돼!!!

	first_ptr->PrintFirst();	//OK!
	first_ptr->PrintSecond();	//안돼!!!
	first_ptr->PrintThird();	//안돼!!!
}

보면 실제 객체는 제일 하위 객체인 Third인데도 불구하고 접근의 허용 범위포인터의 자료형에 따라서 결정되는 걸 볼 수 있습니다.

자 이제부터 오버라이딩과 가상함수를 통해서 다형성을 본격적으로 쓸 수 있도록 해 봅시다.

가상함수 (virtual Function)

자 우리는 여태까지 클래스 안에 단순히 어떤 클래스인지 알 수 있게 Print*() 멤버함수만을 사용해 왔습니다.
하지만 그럴 필요가 없어요! 부모 클래스에서 멤버함수 Print를 만들어서 상속시키면 모두 Print를 갖고 있을 수 있거든요.
하지만 그러면 모두 똑같은 출력만을 하겠죠? 그렇기 때문에 오버라이딩을 통해서 각 클래스는 자신만의 Print를 갖도록 해 봅시다.

class	First
{
public:
	void	Print(void)
	{
		std::cout << "First" << std::endl;
	}
};

class	Second : public First
{
public:
	void	Print(void)
	{
		std::cout << "Second" << std::endl;
	}
};

class	Third : public Second
{
public:
	void	Print(void)
	{
		std::cout << "Third" << std::endl;
	}
};

이렇게 모든 클래스는 자신만의 Print를 갖게 되었습니다!
그러면 어디 한번 출력해볼까요?

int	main(void)
{
	Third*	third_ptr	= new Third();
	Second*	second_ptr	= third_ptr;
	First*	first_ptr	= second_ptr;

	third_ptr->Print();
	second_ptr->Print();
	first_ptr->Print();
}
Third
Second
First

뭐야 이거 실제 객체인 Third의 Print가 아니라 그냥 여전히 포인터의 자료형을 따르고 있잖아?


맞아요 이러면 의미가 없어요. 하지만 여기서 virtual을 사용해 Print함수가상함수로 만들어준다면??

class	First
...
	virtual void	Print(void) //virtual을 통해 가상함수로 변신!
...

class	Second : public First
...
	virtual void	Print(void)
...

class	Third : public Second
...
	virtual void	Print(void)
...

virtual 키워드는 부모 클래스에서 사용하면 자식클래스모두 적용되기 때문에 굳이 적을 필요는 없지만 명확하게 인식할 수 있도록 자식 클래스에도 사용하는게 좋다고 합니다

자 그럼 출력을 볼까요?

Third
Third
Third

이렇게 실제 객체의 Print가 나오는 걸 볼 수 있습니다.
그 이유는 가상함수는 일반 멤버함수완 달리 실제 객체를 참조해서 그에따른 함수를 호출하기 때문이죠.

그렇기 때문에 우리는 부모 클래스에 자식 클래스를 모두 담아두고 사용해도 계속 다른 결과물을 낼 수 있는 겁니다.

이제 메인을 바꿔서 보기좋게 다형성을 사용해 봅시다.

int	main(void)
{
	First*	first_ptr[3] = {
		new First(),
		new Second(),
		new Third()
	};

	for (int i = 0; i < 3; i++)
		first_ptr[i]->Print();	
}

이렇게 가장 상위 클래스First의 포인터배열[3]을 선언해주고 각각 First, Second, Third로 초기화를 했습니다.
그리고 for문을 통해서 Print()함수를 실행해보면?

First
Second
Third

이렇게 똑같이 First_ptr의 Print()를 실행해도 각각 다른 결과물이 나오는 걸 볼 수 있습니다!
이걸 통해서 좀 더 재사용하기 좋고 확장성이 높은 코드를 짤 수 있게 되는거죠.

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

지금까지 우리는 다형성을 통해서 좀 더 사용하기 좋은 코드를 만들 수 있게 되었죠.

하다보면 부모 클래스를 인스턴스화 시킬 필요가 없을때가 있습니다.
예를 들어서 무기를 만든다고 할 때 Weapon클래스를 만들고 그 안엔 공격력, 공격 범위, 공격 속도 등이 있고 이 클래스를 부모 클래스로 두고 각각 Sword, Gun, Axe 등등을 상속할 때 딱히 Weapon은 인스턴스화 시켜서 객체로 만들 필요가 없을 때가 있어요.
그냥 단순히 그 안에 멤버함수들을 정의할 필요도 없고 함수가 있더라도 자식 클래스에서 정의하고 싶게 할 수 있어요.
괜히 잘못 하다가 에러날 수도 있구요.
그럴 때 사용하는게 순수 가상함수 입니다.

순수 가상함수객체 생성을 목적으로 만들지 않은 클래스를 객체로 만들지 않게 막아주는 역할을 하는 녀석입니다.

실수를 미연에 방지하고 다른 사람이 봐도 한번에 알 수 있게 만들어 주는 거죠.

순수 가상함수 만드는 법과 추상 클래스

순수 가상함수를 만드는 법은 간단합니다.
가상함수 뒤에 = 0만 붙여주면 됩니다.
그리고 이 순수 가상함수는 자식 클래스에서 정의하는 걸 기대하고 있다는 뜻이기 때문에 몸체가 없습니다.

class	First
...
	virtual void	Print(void) = 0; // = 0을 붙이면 순수 가상함수!
...

이렇게 순수 가상함수를 포함하고 있는 클래스를 추상 클래스라고 합니다.

가상 소멸자

가상함수가 아닌 일반 함수는 포인터의 자료형에 따라서 함수가 호출된다고 했죠??
여기서 중요한게 소멸자도 함수입니다.

만약 다형성을 이용했는데 자식 클래스 소멸자에 동적 할당을 해제하는 부분이 있다면??
부모 클래스의 소멸자만 불러와 지기 때문에 자식 클래스의 소멸자는 불러와 지지 않아서 잘못하면 메모리 누수가 납니다.

만약 부모 클래스에서 가상 소멸자를 사용한다면 모든 자식 클래스도 가상 소멸자로 선언이 되기 때문에 소멸자를 호출한다면 가장 하위 클래스의 소멸자가 호출이 될 겁니다.
그렇기 때문에 상속 관계에 있는 모든 클래스들이 소멸하게 됩니다.

간단하게 확인해 보죠.

가상 소멸자 사용 안 했을 때


class	First
{
public:
	~First(void)
	{
		std::cout << "~First" << std::endl;
	}
};

class	Second : public First
{
public:
	~Second(void)
	{
		std::cout << "~Second" << std::endl;
	}
};

class	Third : public Second
{
public:
	~Third(void)
	{
		std::cout << "~Third" << std::endl;
	}
};

int	main(void)
{
	First*	first_ptr = new Third();
	delete first_ptr;
}
~First

가상 소멸자 사용 했을 때


class	First
{
public:
	virtual ~First(void)
	{
		std::cout << "~First" << std::endl;
	}
};

class	Second : public First
{
public:
	virtual ~Second(void)
	{
		std::cout << "~Second" << std::endl;
	}
};

class	Third : public Second
{
public:
	virtual ~Third(void)
	{
		std::cout << "~Third" << std::endl;
	}
};

int	main(void)
{
	First*	first_ptr = new Third();
	delete first_ptr;
}
~Third
~Second
~First

참조자의 다형성

참조자도 포인터처럼 모든 자식 클래스를 참조할 수 있습니다.

int	main(void)
{
	Third	third;
	Second&	second_ref	= third;
	First&	first_ref	= second_ref;

	third.Print();
	second_ref.Print();
	first_ref.Print();	
}
Third
Third
Third

상속을 사용하는 이유

  • 클래스의 재사용 및 유지보수의 용이성
  • 코드의 확장성을 높힐 수 있다.

이 내용을 간단하게 코드를 만들어서 확인해 봅시다.

class Zoo
{
public:
	Zoo(void) : m_num(0){}
	~Zoo(void)
	{
		for (int i = 0; i < m_num; i++)
			delete m_animals[i];
	}

	void	addAnimal(Animal* animal)
	{
		m_animals[m_num++] = animal;
	}

	void	showAnimals(void) const
	{
		for (int i = 0; i < m_num; i++)
			m_animals[i]->showCharacteristic();
	}

private:
	Animal*	m_animals[5];
	int		m_num;
};

간단하게 동물원에 동물을 추가하고, 동물원에 있는 동물을 추가하는 클래스를 만듭니다.

거기에 간단한 부모클래스를 만들어줍니다.

class	Animal
{
public:
	Animal(const std::string& name) : m_name(name){}
	virtual ~Animal(){}
	virtual void	showCharacteristic() const = 0;

protected:
	std::string	m_name;
};

그리고 자식 클래스인 Cat을 만들어줍니다.

class	Cat : public Animal
{
public:
	Cat(const std::string& name, const std::string& characteristic) : 
		Animal(name), m_characteristic(characteristic){}

	virtual void	showCharacteristic() const
	{
		std::cout << m_name << "는 " << m_characteristic << std::endl;
	}

private:
	std::string	m_characteristic;
};

m_name은 동물의 이름이고, m_characteristic은 그 동물의 특징을 알려줍니다.

main과 출력은 다음과 같습니다.

int	main(void)
{
	Zoo handler;

	handler.addAnimal(new Cat("고양이", "귀여움"));
	handler.showAnimals();
}
고양이는 귀여움

이렇게 Zoo클래스를 통해서 동물을 추가하고, 동물들의 이름 및 특징을 출력합니다.

여기서 강아지를 추가하고싶다면??
Cat과 동일한 클래스에서 클래스 이름만 바꿔서 사용하면 됩니다.

int	main(void)
{
	Zoo handler;

	handler.addAnimal(new Cat("고양이", "귀여움"));
    handler.addAnimal(new Dog("강아지", "더 귀여움"));
	handler.showAnimals();
}
고양이는 귀여움
강아지는 더 귀여움

그리고 여기서 호랑이를 추가하고싶다면 어떻게 될까요??
호랑이는 귀엽지만 사람을 찢기 때문에 조금 더 다르게 출력해봅시다.
호랑이는 고양이과니까 Cat을 상속받는걸로 해 봅시다.

class	Tiger : public Cat
{
public:
	Tiger(const std::string& name, const std::string& characteristic) :
		Cat(name, characteristic){}

	virtual void	showCharacteristic() const
	{
		Cat::showCharacteristic();
		std::cout << "하지만 사람을 찢지" << std::endl;
	}
};

고양이의 특징을 모두 갖고있는 대신에 showCharacteristic()에서 고양이의 showCharacteristic()를 통해서 기존 출력방식대로 출력을 하고 "하지만 사람을 찢지"를 추가했습니다.

int	main(void)
{
	Zoo handler;

	handler.addAnimal(new Cat("고양이", "귀여움"));
	handler.addAnimal(new Dog("강아지", "더 귀여움"));
	handler.addAnimal(new Tiger("호랑이", "귀여움"));
	handler.showAnimals();
}
고양이는 귀여움
강아지는 더 귀여움
호랑이는 귀여움
하지만 사람을 찢지

이런 식으로 우리는 기존 Zoo클래스는 그대로 두고 단순히 클래스의 추가만으로 더 다양한 동물들을 출력할 수 있게 됐습니다.

이렇게 상속을 이용하면 프로그램을 조금 더 쉽고 빠르게 확장시킬 수 있고, 기존에 사용하던 코드들도 재사용하기 쉬워집니다.

[ c++ ] namespace
[ c++ ] 클래스, 생성자, 소멸자, 이니셜라이저, this포인터
[ c++ ] c++에서의 const와 static
[ c++ ] 참조자(reference)
[ c++ ] new와 delete
[ c++ ] 함수 오버로딩
[ c++ ] 파일 입출력 (ifstream, ofstream)
[ c++ ] 함수포인터, 멤버 함수포인터
[ c++ ]연산자 오버로딩
[ c++ ] 캡슐화란?
[ c++ ] 상속과 다형성에 대해 알아보자

3개의 댓글

comment-user-thumbnail
2022년 7월 24일

상속 들어가면서 진짜 헷갈렸는데 헷갈렸던 모든 부분에 대해서 설명이 다 있고 또 너무 잘 해놓으셔서 잘 이해하고 갑니다 감사합니다!!

1개의 답글