항목 9 : 객체 생성 및 소멸 과정에서는 자신의 가상 함수를 호출하면 위험하다.
객체 생성 시점에서는 조상 클래스의 생성자부터 호출되어 아래로 내려간다. 한 후손 클래스가 생성된다고 하자. 그러면 최상위 조상의 생성자부터 호출하며 내려갈것이다. 이때 중간의 한 조상이 생성자에서 자신의 가상함수를 호출하면 그 생성이 후손 클래스의 생성과정임에도 불구하고 호출한 자기자신의 실제 함수가 호출되어 버린다. 이는 아직 조상 자신까지의 생성만 진행되고 후손 부분의 생성은 아직 진행되지않아 현재 생성 시점에서는 실제 타입이 조상 자신의 타입으로 인식되기 때문이다. 만약 이것이 노린 것이라면 모르겠지만 보통 가상함수는 실제 타입의 함수를 호출하려는 목적이 대부분이므로 이를 생각하며 사용하는 것이라면 피해야한다. 소멸자도 비슷한데 후손 클래스의 소멸자부터 위로 올라가면서 후손부분이 소멸되고 조상 소멸자가 호출될때는 소멸된 부분을 빼고 조상 자신의 타입으로 인식되기에 마찬가지로 조심해야한다.
class parent
{
public:
parent()
{
VFunc1();
}
virtual void VFunc1() {...}
}
class child : parent
{
public:
virtula void VFunc1() override {...};
}
void main()
{
child c = child(); // 생성 과정에서 parent의 VFunc1이 호출됨.
return 0;
}
항목 10 : 대입 연산자는 자신의 레퍼런스를 리턴하자
이것은 관례이다. 보통은 a = b = c같은 연속 대입을 위해 사용된다. vector등의 표준 라이브러리 클래스들도 기본적으로 이를 따른다. 그러니 특별한 이유가 없다면 관례를 따르자. 코드는 자신 혼자가 아닌 다른 사람이 쓸 수도 있으므로 이런 기본적인 관례는 따르는 것이 서로에게 편할 것이다.
Widget& operator=(const Widget& rhs)
{
...
return *this;
}
항목 11 : 대입 연산자에서는 자기 대입을 처리하자.
대입 연산자에서는 자신에게 자기를 대입하는 경우를 항상 신경써야한다. 만약 힙에 있는 데이터를 가르키는 포인터를 멤버로 가진 클래스가 있다고하자. 복사 대입을 받을 경우 자신의 포인터를 delete하고 매개 변수가 가진 자원을 깊은 복사하여 그 포인터를 넣을 것이다. 근데 만약 대입한 것이 자신이라면 delete에 의해 자신의 자원이 삭제될 것이고 이후 삭제된 그 자원을 이용하여 깊은 복사를 수행한 뒤 그것을 받게 될텐데 정상적으로 처리되지 않을 것이다.
Widget& Widget::operator=(const Widget& rhs)
{
delete pb;
pb = new Bitmap(*rhs.pb); //자기 대입 시 에러
return *this;
}
이를 방지하기 위해선 맨앞에 자기 대입에 대한 if return 처리문을 넣거나 로직의 순서를 바꾸면 된다. 로직의 순서를 바꾼다는 것은 기존 포인터를 지역 포인터에 보관해두고 깊은 복사 후 멤버에 포인터를 갱신한 뒤 기존 포인터를 삭제하는 것이다.
Widget& Widget::operator=(const Widget& rhs)
{
Bitmap* oldPb = pb;
pb = new Bitmap(*rhs.pb);
delete oldPb;
return *this;
}
예외 상황 고려
여기서 자기 대입과 별걔로 예외에 대한 처리도 해주면 좋다. 깊은 복사의 경우 새로운 메모리를 할당하기 때문에 예외 발생 가능성이 있는 구문이다. 이러한 구문을 위에서 전자의 방식으로 처리하다 예외가 발생할경우 포인터를 삭제만하고 새로 갈아끼우지 못해 삭제된 자원을 가르키는 상태가 되어 위험하다. 따라서 후자인 로직의 순서를 바꾸는 선택을 하면 예외 에 대한 처리와 자기 대입 두가지가 동시에 처리된다.
여기서 예외 상황 처리까지 고려하기위해 로직의 순서를 바꾸는 것은 알겠으나 이러면 if return을 쓸때에 비해 자기 대입때 필요없는 복사가 일어나 성능이 좋지않다고 생각할 수도 있다. 이를 위해 if return과 로직 순서 바꾸기를 같이 쓰는게 좋지 않을까 생각 할수도있지만 자기 대입이라는 것이 정상적이지 않은 상황에만 발생함에 비해 if return을 넣으면 정상 대입마다 if문을 처리해야하며 파이프라인등의 성능도 저하된다. 따라서 이러한 점을 고려하여 if return을 넣을지 말지 고려하는 것이 좋다.
항목 12 : 대입 및 생성시 멤버를 빠트리지 말자
기존에 클래스를 설계하고 그에 대한 대입 및 생성자를 만들었다고하자. 이후 설계가 확장되어 클래스에 멤버가 추가되었다 하자. 이때 이러한 추가에 대해 대입 및 생성 코드도 추가해줘야함을 잊지말자.
또한 상속을 받은 클래스의 경우 생성자에서는 부모의 적절한 생성자를 적절한 매개변수로 호출해줘야하며 대입 연사자의 경우 부모의 대입 연산자를 호출하여 부모에서 선언된 멤버들도 빼먹지 말아야한다.
복사 생성자, 대입 연산자에서 서로를 사용하지 말자
추가적으로 대입 및 생성이 유사한 로직을 가진다고해서 대입에서 생성자를, 또는 생성자에서 대입 연산자를 호출해서는 안된다. 전자의 경우 애초에 생성자를 그렇게 호출하는것은 불가능하며, 후자의 경우 디자인이나 의미적으로 좋지 못하므로 피하는 것이 좋다. 또한 복사 생성자에서는 생성자 리스트에서 성능 이점을 보아야 하는데 대입 연산으로 복사 생성을 하면 생성, 대입 과정이 모두 발생해 성능 이점을 볼 수 없다. 이러한 경우에는 다른 멤버 함수를 별도로 만들어 코드 관리를 하는 것이 좋을 것이다.
참고서적
Effective C++
'프로그래밍 언어 > Effective C++' 카테고리의 다른 글
[Effective C++]항목 23~25 : 클래스에 대한 비멤버 함수 (0) | 2023.04.10 |
---|---|
[Effective C++]항목 18~22 : 클래스 설계 기초 (0) | 2023.04.10 |
[Effective C++]항목 13~17 : 자원 관리 (0) | 2023.03.29 |
[Effective C++]항목 5~8 : 생성자, 소멸자 및 대입 연산자 1 (0) | 2023.03.24 |
[Effective C++]항목 1~4 : C++ 기초 사고방식 (0) | 2023.03.22 |