언어/C++

Cpp - 상속#2

tsyang 2021. 7. 22. 22:12

2021.07.18 - [언어/C++] - Cpp - 상속#1

 

Cpp - 상속#1

기본적인 내용은 안 씀. 상속 상속은 왜 쓸까? 클래스 간의 관계 : 동물 -> 고양이 사람.. 코드 재사용 : 동물은 잠을 자.. 그럼 고양이 사람도 다 잠을 자 일관적인 클래스 인터페이스 (class interface

tsyang.tistory.com

 

 

Object Slicing


 

오브젝트 슬라이싱은 Cpp 상속에서 발생하는 문제이다. 

 

using namespace std;

class Animal
{
public :
	void virtual Speak()
	{
		cout << "Animal" << endl;
	}
private:
	double animal_data_;
};

class Cat : public Animal
{
public:
	void Speak() override
	{
		cout << "Cat" << endl;
	}
private:
	double cat_data_;
};

int main()
{
	Cat cat;

	Animal & animal_ref = cat;
	animal_ref.Speak();
	
	Animal animal_obj = cat;
	animal_obj.Speak();
	return 0;
}

 

위 코드는 직관적으로 "Cat" , "Cat" 을 출력해야 할 것 같지만, animal_obj를 통한 호출은 "Animal"을 호출한다. 

 

Animal animal_obj = cat; //copy constructor

이유는 위 코드와 같은 할당 때문인데, animal_obj를 cat으로 할당하는 과정에서 복사 생성자가 호출된다. 여기까지는 좋으나... 가상함수테이블(VT)는 특별한 멤버 변수라 복사 생성자에서 복사가 되지 않는다. 따라서 animal_obj의 VT는 Animal 타입의 VT를 가리킨다. 

 

animal_ref와 animal_obj를 비교한 그림은 다음과 같다.

 

오브젝트 슬라이싱(Object Slicing)이란 copy될 때 derived class의 정보가 잘려나가는 것을 말한다. (VT가 제대로 복사 되지 않을 뿐더러, cat_data_도 날아감)

 

오브젝트 슬라이싱은 특히 아래와 같은 함수에서 &를 빼먹어서 발생할 수 있다. 

void AnimalSpeak(Animal & animal)
{
	animal.Speak();
}

 

 

그렇다면 위와 같은 실수를 방지하는 방법은 뭐가 있을까? 

 

우선 아래와 같이 복사 생성자와 복사 할당자를 없애주는 방법이 있다.

Animal(const Animal & other) = delete;
Animal& operator = (Animal other) = delete;

이렇게 되면 컴파일타임에 에러가 나서 실수를 방지할 수 있다. 그러나 derived class끼리의 복사 생성/할당 까지 막아버린다는 단점이 있다.

 

그래서 해결책으로는 다음 중 한 가지 방법을 선택할 수 있다.

  1. copy constructor를 protected로 선언한다.
  2. Clone함수를 명시적으로 구현해준다.
  3. (Best) base class를 순수 abstract class로 만들어준다.

 


 

다운 캐스트


우선 다운 캐스트란 base class -> derived class로 형 변환을 하는 것. 

class Animal
{
public :
	void virtual ShowType() = 0;
private:
	double animal_data_;
};

class Cat : public Animal
{
public:
	void ShowType() override
	{
		cout << "Cat" << endl;
	}
	
	void Meow()
	{
		cout << "Meow~" << endl;
	}
private:
	double cat_data_;
};

class Dog : public Animal
{
	void ShowType() override
	{
		cout << "Dog" << endl;
	}
};

int main()
{
	Animal * animal_ptr = new Cat();
	Cat * cat_ptr = animal_ptr; // implicit downcast
	cat_ptr->Meow();
	delete cat_ptr;
	return 0;
}

Animal * 을 Cat * 으로 변환하였는데 이러한 암시적 다운캐스트는 오류를 발생시킨다.

 

반면 명시적 다운캐스트는 어떨까?

int main()
{
	Animal * animal_ptr = new Cat();
	Cat * cat_ptr = static_cast<Cat*>(animal_ptr); //explicit downcast
	cat_ptr->Meow();
	delete cat_ptr;
	return 0;
}

위 코드의 경우 아무 문제없이 수행된다.

 

그러나 다음의 경우는 어떨까?

int main()
{
	Animal * animal_ptr = new Dog();
	Cat * cat_ptr = static_cast<Cat*>(animal_ptr); //explicit downcast
	cat_ptr->Meow();
	delete cat_ptr;
	return 0;
}

위 코드의 출력은 다음과 같다..

VT는 Dog의 VT를 가리키는 분명한 Dog 객체인데 Cat의 Meow를 호출할 수 있는 이상한 동작을 한다.

 

Meow() 메서드에서 Cat타입의 필드를 출력하도록 하면, 

 

void Meow()
{
cout << "Meow~" << cat_data_ << endl;
}

위처럼 쓰레기 값이 나온다.

 

만약 Dog에서도 멤버 변수를 정의한다면 해당 멤버 변수의 값이 나오기도 한다. 즉, 비정상적인 메모리 참조가 발생하는 것이다. 

 

이처럼 최악의 경우 다운캐스트를 하면 없는 공간을 참조해서 에러는 안나는데 이상하게 동작하는 상황이 발생할 수 있다. 그러므로 이런 방식의 static_cast 사용은 매우 위험하다.

 


 

 

다이나믹 캐스트(dynamic cast)


다이나믹 캐스트는 RTTI(run time type information)를 사용한다. 많은 C++프로젝트에서는 RTTI를 사용을 금지하고 있다. 또한 구글 스타일 가이드에서도 RTTI를 쓰지 말라고 한다. 그 이유는 애초에 RTTI를 사용하지 않도록 클래스 구조를 짜는 것이 더 좋기 때문. (단, 유닛 테스트의 경우는 예외)

 

다운 캐스트를 사용하면 런타임에 해당 타입의 typeinfo 클래스를 사용하여 안전하게 캐스팅 한다.

 

int main()
{
	Animal * animal_ptr = new Dog();
	Cat * cat_ptr = dynamic_cast<Cat*>(animal_ptr); //explicit downcast
	if (cat_ptr == nullptr)
		cout << "this is an invalid cast" << endl;
	
	// "this is an invalid cast" 출력
	
	delete animal_ptr;
	   	
	return 0;
}

dynamic_cast는 위 코드처럼 이상한 캐스팅의 경우 nullptr을 반환한다. 다시 말하지만 dynamic_cast를 써야하는 상황이라면 코드 구조가 이상한 것이다. 가상 함수와 오버라이드를 사용하여 코드를 짜자. 다이나믹 캐스트는 쓰지 말자. (C#에서는 as 잘 쓰는데 as를 쓰는 것도 좋은 구조는 아닌건가...)

 


 

 

I/O 상속


 

출처 : https://devdocs.programmers.co.kr/references/cpp/en/cpp/io.html

 

I/O 의 상속 다이어그램은 위와 같다. 이런 상속 구조를 이용하면 유연한 코드를 짤 수 있다. 가령 ostream을 사용하면 ofstream와 iostream, stringstream을 동시에 이용할 수 있다!

 

using namespace std;

class SomeClass
{
public :
	explicit SomeClass(string contents) : contents_{std::move(contents)}{}

	void Print(std::ostream & os) const
	{
		os << contents_ << endl;
	}

private:
	string contents_;
};

int main()
{
	SomeClass a{ "a" };
	a.Print(cout);

	SomeClass b("b");
	std::stringstream ss;
	b.Print(ss);
	cout << ss.str() << flush;

	SomeClass c("c");
	ofstream ofs{ "test.txt" };
	if (ofs)
		c.Print(ofs);
	ofs.close();

	return 0;
}

그러면 이처럼 하나의 Print() 메서드로 여러 I/O스트림을 사용할 수 있다.

 

 

참고 :

https://youtube.com/playlist?list=PLDV-cCQnUlIar6Wx3rkXHs7wbfmlz34Fz 

https://devdocs.programmers.co.kr/references/cpp/en/cpp/io.html

'언어 > C++' 카테고리의 다른 글

아토믹 (Atomic, cpp17)  (1) 2022.07.03
Cpp 함수 (C++11 lambda, std::function)  (2) 2021.07.31
Cpp - 상속#1  (0) 2021.07.18
C++ (복사/이동) 생성자, 할당자, Rule of Three(Five)  (0) 2020.11.08
스마트 포인터  (0) 2020.11.08