항목 5. C++가 은근슬쩍 만들어 호출해 버리는 함수들에 촉각을 세우자
- 복사 생성자(const &...), 복사 대입 연산자(operator=), 소멸자(~)는 사용자가 선언하지 않으면 컴파일러가 기본적인 형태로 선언하게 됩니다.
- 만약 class Empty{}; 라는 공백의 클래스를 선언하면 컴파일러에서는 기본적으로 아래와 같은 형태의 기본적인 구조를 만든다고 생각하시면 됩니다.
class Empty{
public:
Empty(){...}
Empty(const Empty& rhs){...}
~Empty(){...}
Empty& operator=(const Empty& rhs){...}
};
- 소멸자는 이 클래스가 상속한 기본 클래스의 소멸자가 가상소멸자로 되어있지 않으면 역시 비가상 소멸자로 만들어 집니다.(항목7 참고)
- 생성자가 선언되어 있으면 컴파일러에서 기본 생성자를 생성하지 않으니 이러한 걱정은 할 필요 없습니다.
**이것만은 잊지말자!**
- 컴파일러는 경우에 딸라 클래스에 대해 기본 생성자, 복사 생성자, 복사 대입 연산자, 소멸자를 암시적으로 만들어 놓을 수 있습니다.
항목 6. 컴파일러가 만들어낸 함수가 필요 없으면 확실히 이들의 사용을 금해 버리자
- 복사를 막고 싶다. 기본적인 문제점 두가지가 있습니다.
1. 복사 생성자나 복사 대입 연산자를 사용자가 생성하지 않으면 컴파일러에서 생성을 합니다.
2. 그렇다고 복사 생성자나 복사 대입 연산자를 선언하면 복사하는것과 마찬가지 입니다.
- 따라서 위의 방법을 막기 위해 private에 선언하고 정의(구현)하지 않는 방법이 있습니다. 이 방법을 사용하게 되면 다른 곳에서 복사생성자를 사용하려하면 링크시 에러를 발생하게 됩니다.
- 링크시 에러 발생이 아닌 컴파일 시점에서 에러가 발생하도록 변경하는 방법은 다음과 같습니다.
private에 선언하되 별도의 class에 선언하고 기존 class에 파생 시키는 방법입니다.(이 때 상속은 public일 필요가 없습니다.)
class Uncopyable{
protected:
Uncopyable() {} //생성과 소멸을 허용합니다.
~Uncopyable(){}
private:
Uncopyable(const Uncopyable&); //복사 방지
Uncopyable& operator=(const Uncopyable&);
복사를 막고싶은 HomeForSale이라는 객체는 Uncopyable로부터 상속받으면 복사를 막을 수 있습니다.
class HomeForSale: private Uncopyable{
};
**이것만은 잊지말자!**
- 컴파일러에서 자동으로 제공하는 기능을 허용치 않으려면, 대응되는 멤버 함수를 private로 선언한 후에 구현은 하지 않은채로 두십시오. Uncopyable과 비슷한 기본 클래스를 쓰는 것도 한 방법입니다.
항목 7. 다형성을 가진 기본 클래스에서는 소멸자를 반드시 가상 소멸자로 선언하자
- 기본 클래스 포인터를 통해 파생 클래스 객체가 삭제될 때 그 기본 클래스에 비가상 소멸자가 들어 있으면 프로그램은 미정의 동작 상황으로 빠지게 됩니다.
ex) TimeKeeper가 기본 클래스인 상황입니다.
TimeKeeper *getTimeKeeper(); //기본 클래스에서 파생된 클래스를 통해 동적으로 할당된 객체의 포인터 반환
class AtomicClock:public TimeKeeper{...}; 일 때
기본 클래스 포인터를 통한 소멸상태에 들어가게되면 AtomicClock 클래스의 소멸자는 호출되지 않음.
이 상태에서 기본 클래스인 TimeKeeper의 소멸자는 동작을 하게되어 부분 소멸이 일어나게 됩니다.
부분 소멸은 자원의 심각한 낭비가 됩니다.
- 위의 문제의 해결 방법은 기본 소멸자를 가상소멸자로 변경하는 방법입니다.
virtual ~TimeKeeper();
- 가상 멤버 함수를 하나라도 가지고 있으면 가상소멸자를 생성해야 합니다.
- 가상 멤버 함수가 하나도 없을 경우에는 가상 소멸자를 생성하지 말아야 합니다.
-> 만약 32비트 아키텍처에서 기본 클래스 class Point가 있고 생성자로 Point(int x, int y) 가 있다고 가정했을 때 가상 소멸자로 생성을 하게 되면 int 두개에 vptr(가상함수 테이블 포인터)하나로 총 96비트가 됩니다. (원래는 64비트)
-> 다른 언어와의 호환성 문제가 있습니다.
**이것만은 잊지말자!**
- 다형성을 가진 기본 클래스에는 반드시 가상 소멸자를 선언해야 합니다. 즉, 어떤 클래스가 가상 함수를 하나라도 갖고 있으면, 이 클래스의 소멸자도 가상 소멸자이어야 합니다.
- 기본 클래스로 설계되지 않았거나 다형성을 갖도록 설계되지 않은 클래스에서는 가상 소멸자를 선언하지 말아야 합니다.
항목 8. 예외가 소멸자를 떠나지 못하도록 붙들어 놓자.
- class를 직접 호출해야하는 설계에서는
-> close를 호출하는 클래스를 생성해서 그 클래스에서 관리하도록 한다.
이 때 소멸자는 두가지 형태로 나누어 지는데 한가지는 프로그램을 바로 끝내는 방법과 나머지 한가지는 예외를 삼키는 방법입니다.
1. 바로 끝내는 방법(abort 사용)
DBConn:~DBConn()
{
try{db.close()};
catch(...)
{
//close 호출 실패 로그
std:abort();
}
}
2. 예외를 삼키는 방법
위의 방법에서 std:abort만 삭제합니다. 이 방법은 발생한 예외를 무시해도 프로그램이 신뢰성 있게 지속될 수 있을 때만 사용합니다.
- 소멸자에서 닫기를 기다리기 전에 사용자가 함수를 호출하여 닫도록 하는 방법도 있습니다.
-> 하지만 이방법도 실패하면 다시 끝내기(abort)를 하거나 삼키기를 해야합니다.
**이것만은 잊지말자!**
- 소멸자에서는 예외가 빠져나가면 안됩니다. 만약 소멸자 안에서 호출된 함수가 예외를 던질 가능성이 있다면, 어떤 예외이든지 소멸자에서 모두 받아낸 후에 삼켜 버리든지 프로그램을 끝내든지 해야합니다.
- 어떤 클래스의 연산이 진행되다가 던진 예외에 대해 사용자가 반응해야 할 필요가 있다면, 해당 연산을 제공하는 함수는 반드시 보통의 함수(즉, 소멸자가 아닌 함수)이어야 합니다.
항목 9. 객체 생성 및 소멸 과정 중에는 절대로 가상 함수를 호출하지 말자
- 파생 클래스 객체 생성시 기본 클래스가 먼저 생성된다.
- 기본 클래스의 생성자가 호출될 동안에는 가상함수는 절대 파생 함수 쪽으로 흐르지 않는다.
- 대처 방법은 가상함수를 기본 클래스의 비가상 멤버 함수로 바꾸고 파생 클래스의 생서자들로 하여금 필요한 정보를 기본 클래스의 생성자로 넘긴다.
**잘못된 예시**
class A{ //기본 클래스
public:
A();
virtual void log() const = 0; //로그기록 생성
....
};
A::A()
{
.....
log();
}
class B: public A{ //A의 파생 클래스
public:
virtual void log() const;
......
};
여기서 B b; 이런식으로 b를 선언하게되면 B의 생성자가 호출되긴 하지만 그 이전에 A의 생성자가 먼저 호출됩니다.
따라서 현재 여기서 불리는 log()는 클래스 A의 log()가 호출되는 것입니다.
**위의 코드에 대한 대처 방법**
class A{
public:
explicit A(const std::string& logInfo); //explicit -> 암시적 형변환을 방지하기 위함
void log(const std::string& logInfo); //이제는 비가상 함수 입니다.
......
};
A::A(const std::string& logInfo)
{
....
log(logInfo);
}
class B: public A{
public:
B(parameters)
: A(createLog(parameters))
{.....}
....
private:
static std::string createLog(parameters);
};
**이것만은 잊지 말자!**
- 생성자 혹은 소멸자 안에서 가상 함수를 호출하지 마세요. 가상 함수라고 해도 , 지금 실행중인 생성자나 소멸자에 해당되는 클래스의 파생 클래스 쪽으로는 내려가지 않으니까요.
항목 10. 대입 연산자는 *this의 참조자를 반환하게 하자
- 대입 연산은 우측 연관 연산으로 좌변 객체의 참조자를 반환합니다. (이것은 프로그램의 관례로 사용됩니다.)
ex) x=y=z=15;
따라서 대입 연산자를 사용할 때는 아래와 같이 사용합니다.
class A{
public:
.....
A operator=(const A& rhs) //반환 타입은 현재의 클래스에 대한 참조자
{
....
return *this; //좌변 객체(의 참조자)를 반환합니다.
}
- 모든 형태의 대입 연산자에 해당합니다. (+=, -=, *= ...)
**이것만은 잊지 말자!**
- 대입 연산자는 *this의 참조자를 반환하도록 하세요.
항목 11. operator=에서는 자기대입에 대한 처리가 빠지지 않도록 하자.
- 자기 대입이란 어떤 객체가 자신에 대한 대입연산자를 적용하는 것으로
ex) class widget{...};
widget w;
w = w; //자기 대입 연산에 빠지게 됩니다. 이러한 경우에는 쉽게 찾을 수 있지만 아래와 같은 경우에는 쉽게 발견하기가 어렵습니다.
a[i] = a[j]; //i 와 j가 같은 변수일 때
*px = *py; //같은 주소를 참고할 때
- 이렇게 사용되면 여러곳에서 하나의 객체를 참조해서 중복 참조가 발생하게 됩니다.
class A;
class B{
public:
....
B& operator=(const B& rhs)
{
delete rhs.a;
a = new A(*rhs.a);
return *this;
}
private:
A* a;
};
만약 위의 코드에서 자기대입이 일어났다고 가정했을 때 rhs와 *this가 같은 객체일 경우에는 오류가 발생하게 됩니다.
즉 B객체는 자신의 포인터 멤버를 통해 물고있던 객체가 삭제된 상태가 될 수 있습니다.
- 이를 막기위해 함수의 첫부분에 일치성 검사 코드를 넣어줍니다.
ex) if(this == &rhs) return *this; //자기 대입인지 검사 후 자기 대입이면 return
하지만 이 부분은 예외에 안전하지 않습니다. 일치성 검사를 넘어갔다고 해도 new 부분에서 예외가 발생하게되면
B객체는 결국 삭제된 A를 가리키는 포인터를 가지고 홀로 남게 됩니다. 이 포인터는 delete도 안되고 안전하게 읽는것 또한 불가능 합니다.
- 위의 예외 처리보다 더 완벽한 예외 처리 기법이 있습니다.
포인터가 가리키는 객체를 복사한 후 삭제하는 방법입니다.
B& B::operator=(const B& rhs)
{
A *pOrig = a; //원래의 a(위의 private에 선언된)를 어딘가에 기억해 둡니다.
a = new A(*rhs.a); //다음, a가 *a의 사본을 가리키게 됩니다.
delete pOrig; //원래의 a를 삭제합니다.
return *this;
}
위와 같이 코딩을 하게되면 new A 부분에서 예외가 발생하더라도 a는 변경되지 않은 상태가 유지 됩니다.
또한 원본 a를 복사한 후 복사한 사본을 포인터가 가리키게 만든 후 원본을 삭제 하므로 이상이 없습니다.
- 세번째 방법으로 복사 후 맞바꾸기 기법이 있습니다. (자세한 내용은 29항목을 공부할 때 살표보도록 하겠습니다.)
class B{
public:
......
void swap(B& rhs) //*this의 데이터 및 rhs의 데이터를 맞바꿉니다.
{
A* pOrig = a;
a = new A(*rhs.a);
rhs.a = pOrig;
}
B& operator=(const B& rhs)
{
B temp(rhs); //rhs의 데이터에 대해 사본을 하나 만듭니다.
swap(temp); //*this의 데이터를 그 사본의 것과 맞바꿉니다.
return *this;
}
....
};
- 위의 코드를 값에 의한 전달로도 변경할 수 있는데 그 경우는 다음과 같습니다.
B& B::operator=(B rhs) //rhs는 넘어온 원래 객체의 사본입니다.
{
swap(rhs); //*this의 데이터를 이 사본의 데이터와 맞바꿉니다.
return *this;
}
- 하지만 일치성 검사가 들어가면 코드가 커지고, 실행 시간 속력이 줄어들 수 있으니 잘 생각하며 사용하길 권장합니다.
** 이것만은 잊지 말자! **
- operator=을 구현할 때, 어떤 객체가 그 자신에 대입되는 경우를 제대로 처리하도록 만듭시다. 원복 객체와 복사대상 객체의 주소를 비교해도 되고, 문장의 순서를 적절히 조절할 수도 있으며, 복사 후 맞바꾸기 기법을 써도 됩니다.
- 두 개 이상의 객체에 대해 동작하는 함수가 있다면, 이 함수에 넘겨지는 객체들이 사실 같은 객체인 경우에 정확하게 동작하는지 확인해 보세요.
항목 12. 객체의 모든 부분을 빠짐없이 복사하자
- 클래스에 데이터를 추가했으면 추가한 데이터를 처리하도록 복사합수를 다시 작성하자.
void logCall(const std::string& funcName); //로그 기록 내용을 만듭니다.
class Customer{
public:
....
Customer(const Customer& rhs);
Customer& operator=(const Customer& rhs);
....
private:
std::string name;
};
Coustomer::Customer(const Customer& rhs)
: name(rhs.name) //rhs의 데이터를 복사합니다.
{
logCall("Customer copy constructor");
}
Costomer& Customer::operator=(const Customer& rhs)
{
logCall("Customer copy assignment operator");
name = rhs.name; //rhs의 데이터를 복사합니다.
return *this;
}
위의 소스는 문제될 것이 하나도 없습니다. 하지만 데이터 멤버를 하나라도 추가하게되면 문제가 발생합니다.
class Date{...}; //날짜 정보를 위한 클래스
class Customer
{
public:
....
private:
std::string name;
Date lastTransaction;
};
이렇게 데이트 멤버를 추가하게되면 name은 복사를 하지만 lastTrasaction은 복사하지 않는 부분복사가 일어나게 됩니다.
- 파생 클래스 사용시 기본 클래스의 복사 생성자를 호출하고 기본 클래스 부분을 대입한다.
만약 PriorityCustomer라는 클래스가 Customer의 파생 클래스라고 한다면
1. PriorityCustomer의 복사 생성자 호출 시 Customer의 복사 생성자를 호출합니다.
PriorityCustomer::PriorityCustomer(const PriorityCustomer& rhs)
: Customer(rhs), priority(rhs.priority)
{.....}
2. 대입 연산자에서는 기본 클래스 부분을 대입합니다.
PriorityCustomer& PriorityCustomer::operator=(const PriorityCustomer& rhs)
{
logCall("PriorityCustomer copy assignment operator");
Customer::operator=(rhs); //기본 클래스 부분 대입
priority = rhs.priority;
return *this;
}
** 이것만은 잊지 말자! **
- 객체 복사 함수는 주어진 객체의 모든 데이터 멤버 및 모든 기본 클래스 부분을 빠뜨리지 말고 복사해야 합니다.
- 클래스의 복사 함수 두 개를 구현할 때, 한쪽을 이용해서 다른 쪽을 구현하려는 시도는 절대로 하지 마세요. 그 대신, 공통된 동작을 제3의 함수에다 분리해 놓고 양쪽에서 이것을 호출하게 만들어서 해결합시다.
'프로그래밍언어 > C++' 카테고리의 다른 글
[Effective C++] 항목 26~28 (0) | 2018.04.10 |
---|---|
[Effective C++] 항목 22 ~ 25 (0) | 2018.01.23 |
[Effective C++] 항목 18 ~ 21 (0) | 2018.01.23 |
[Effective C++] 항목 13 ~ 17 (0) | 2018.01.23 |
[Effective C++] 항목1 ~ 4 (0) | 2018.01.23 |
댓글