
※ Effective C++을 읽고 내 생각대로 요약한 내용.
◎ 선언하지 않았음에도 컴파일러가 자동으로 생성하는 함수
◎ 컴파일러가 자동 생성하는 함수가 필요없다면 확실하게 금지하자
◎ 다형성을 가진 기본 클래스는 반드시 가상 소멸자를 선언하자
class BaseClass
{
}
class DerivedA : public BaseClass
{
}
main()
{
Derived* pA = new DerivedA();
... // 계속 사용하다가
BaseClass* pBase = pA;
delete pBase; // 문제 발생!! BaseClass의 소멸자가 가상이 아니라면
// Derived 클래스의 소멸자는 호출되지 않는다.
}
// .h꼭 가상 소멸자 선언, 정의하기
class BaseClass
{
pubiic:
virtual ~BaseClass();
}
// cpp
BaseClass::~BaseClass{}
◎ 예외가 소멸자를 떠나지 못하도록 붙들어놓자.
class A
{
public:
void ThrowF(){throw 1;}
~A()
{
...
ThrowF();// 여기서 예외가 발생할 가능성이 있다면
}
}
-----------------------------------------------------------
// 문제가 발생하는 상황
int main()
{
try
{
func();
}
catch(...)
{
}
}
-----------------------------------------------------------
func()
{
A a;
throw 2; // 에러가 던져진 상태
}
이 상황에선 stackUnwinding으로 인해 에러가 던져진 상태에서 a의 정리->~A가 호출되는데 ~A에서 에러 발생 -> 프로그램 종료 또는 정의되지 않은 행동 (UB) 이 발생한다.
이를 방지하기 위해
- 에러가 발생할 수 있는 작업에 대해선 소멸자가 아닌 다른 함수로 분리해서 사용자가 호출한다.
2-1. 사용자가 호출하는 경우
사용자는 에러에 대한 핸들링까지 해야한다.
2-2. 사용자가 호출하지 않는 경우.
사용자는 소멸자에서 처리하는 방식을 따라야 한다. (자신이 제어를 포기했으므로)
// 변경된 코드
class A
{
public:
...
void ThrowF() {throw 1;}
bool m_bClosed;
~A()
{
if(true == m_bClosed)
return;
// 2-2. 소멸자에서 삼키기 / 종료를 결정한다.
try
{
...
ThrowF();// 여기서 예외가 발생할 가능성이 있다면
}
catch(){...}
}
void Close()
{
...
m_bClosed = true;
}
}
-----------------------------------------------------------
// 2-1. 유저가 핸들링
int main()
{
A a;
...
try
{
a.Close();
}
catch(...)
{
...// 다양한 핸들링
}
}
-----------------------------------------------------------
의 형태로 사용하자.
◎ 객체 생성 및 소멸 과정 중에는 절대 가상 함수를 호출하지 말자.
class Base
{
public :
// 문제 상황
// Base 클래스를 상속받은 클래스가 생성될 때
// 자동으로 상속받은 클래스가 정의한 F()를 호출하고 싶다!!
// 하지만 현실은 내가 원하는 대로 진행되지 않는다!!
Base() { F(); }
// Base의 생성자에서 순수 가상 함수를 바로 호출하는 경우 - 컴파일 에러
// 이렇게 한번 랩핑해서 실행시키면 컴파일에러는 발생하지 않는다. (매우 악독함)
void Init(){F();}
virtual void F() { printf("base\n"); };
};
class Derived : public Base
{
public:
Derived() {}
virtual void F() { printf("derived\n"); }
};
int main()
{
// 위 링크에서 테스트 한 것과 같이
// d가 생성될 때 Base의 생성자 -> Base의 F가 호출된다!!
// 순수 가상 함수의 형태가 아닌 가상 함수를 실행됐을 때
// 정상적으로 작동하는 것이 오히려 더 큰 문제가 될 수 있다!!
Derived d;
}

결과 ("derived"를 기대했지만 "base"가 나오는 모습)
◎ 대입 연산자는 *this의 참조자를 반환하게 하자.
int x,y,z;
x = y = z = 15; // 대입이 사슬처럼 이어짐
x = (y = (z = 15)); // 다음과 같은 형태도 가능
// ex)
class MyClass
{
public:
...
MyClass& operator=(const MyClass& other)
{
...
return *this;
}
MyClass& operator+=(const MyClass& other)
{
...
return *this;
}
{
◎ operator= 에서는 자기대입에 대한 처리가 빠지지 않도록 하자.
MyClass my;
...
my = my;
---------------------- 문제 코드 1 ----------------------
Widget& Widget::operator= (const Widget& rhs)
{
delete pb;
pb = new Bitmap(*rhs.pb);
return *this
}
// ① : 자기대입에 대한 검사 없음
// ② : 예외 처리에 대해 안전하지 않음.
// pb가 삭제된 이후 new pb가 예외를 내보내게 되면
// 작업은 pb가 삭제된 채로 종료되고, Widget의 pb는 삭제된 상태로 끝나고 만다.
// 내가 느낀 바로는 예외 발생 시
// operator= 의 작업이 마치 원자적(all or nothing)이지 않은 상태로 진행됨을 의미하는 것 같음.
// (똥을 싸다 끊긴 느낌처럼 pb만 해제되고 다음 작업의 진행없이 종료하는 것이 문제
// - 우리는 이렇게 예외가 발생해도 앞서 말한 문제가 일어나지 않도록 설계해야한다.)
// 일치성 검사를 통해 자기대입을 점검
Widget& Widget::operator= (const Widget& rhs)
{
if(this == &rhs) return *this;
delete pb;
pb = new Bitmap(*rhs.pb);
return *this
}
Widget& Widget::operator= (const Widget& rhs)
{
Bitmap *pOrigin = pb;
pb = new Bitmap(*rhs.pb);
delete pOrigin;
return *this;
}
// new에서 예외가 발생해도 안전하다고 할 수 있음.
// 자기자신이 들어와도 복사 후 삭제
// 가장 효율적인 방법은 아니지만, 동작함.
위 코드를 C++가 가진 두 가지 특징을 이용해 구현할 수 있음
class Widget
{
...
void swap(Widget& rhs);
...
// 복사 생성자를 통해 임시로 생성된 객체를
// 기존의 객체와 교체
// 생성 과정에서 예외가 발생해도 예외에 안전하다.
// 자기대입 상황에서도 동일하게 복사가 진행됨
Widget& Widget::operator=(Widget rhs)
{
swap(rhs);
return *this;
}
}
◎ 객체의 모든 부분을 빠짐없이 복사하자.
복사 함수 (복사 생성자와 복사 대입 연산자)에 대해서 모든 데이터에 대해 복사를 완전하게 하도록 하자 (멤버가 추가됐다면 추가된 멤버에 대한 복사 로직 추가 필요)
상속 관계에서의 복사 함수는 명시적으로 기본 클래스에 대한 복사 함수를 호출해줘야한다.
이게 무슨 말이냐?
class BaseCustomer
{
public:
BaseCustomer() { printf("BaseCustomer()\n"); }
BaseCustomer(const BaseCustomer& other) { printf("BaseCustomer(const BaseCustomer&)\n"); }
BaseCustomer& operator= (const BaseCustomer& other)
{
printf("BaseCustomer operator= ()\n");
return *this;
}
private:
int x = 0;
};
// 파생 클래스의 복사 함수에서 명시적으로 기본 클래스의 복사 함수를 호출한 경우
class DerivedCustomerWithCall : public BaseCustomer
{
public:
DerivedCustomerWithCall() { printf("DerivedCustomerWithCall()\n"); }
DerivedCustomerWithCall(const DerivedCustomerWithCall& other) : BaseCustomer(other){
printf("DerivedCustomerWithCall(const DerivedCustomerWithCall& other)\n");
}
DerivedCustomerWithCall& operator= (const DerivedCustomerWithCall& other)
{
BaseCustomer::operator=(other);
printf("DerivedCustomerWithCall operator= ()\n");
return *this;
}
};
// 파생 클래스의 복사 함수에서 명시적으로 기본 클래스의 복사 함수를 호출하지 않은 경우
class DerivedCustomerWithoutCall : public BaseCustomer
{
public:
DerivedCustomerWithoutCall() { printf("DerivedCustomerWithoutCall()\n"); }
DerivedCustomerWithoutCall(const DerivedCustomerWithoutCall& other) {
printf("DerivedCustomerWithCall(const DerivedCustomerWithoutCall& other)\n");
}
DerivedCustomerWithoutCall& operator= (const DerivedCustomerWithoutCall& other)
{
printf("DerivedCustomerWithoutCall operator= ()\n");
return *this;
}
};
// 명시적으로 호출하는 객체
DerivedCustomerWithCall dcWithCall;
// 명시적으로 호출하지 않는 객체
DerivedCustomerWithoutCall dcWithoutCall;
printf("\n\n------------------------ 생성 완료 ------------------------\n\n");
printf("\n\n-------------------- 명시적 호출 버전 ---------------------\n\n");
DerivedCustomerWithCall dcWithCall2 = dcWithCall;
dcWithCall2 = dcWithCall;
printf("\n\n------------------- 명시적 호출 X 버전 --------------------\n\n");
DerivedCustomerWithoutCall dcWithoutCall2 = dcWithoutCall;
dcWithoutCall2 = dcWithoutCall;
printf("\n\n--------------------------- 종료 --------------------------\n\n");

파생 클래스의 복사 함수에서 기반 클래스의 복사 함수를 명시적으로 호출하지 않으면 기반 클래스의 복사 함수는 호출되지 않는다.
명시적으로 호출하지 않는 버전을 보자.
복사 생성자 호출 시 기대한 것 : 파생 클래스의 복사 생성자 호출 -> 기본 클래스의 복사 생성자가 호출되는 것이지만 기본 클래스의 기본 생성자가 호출됨
복사 대입 연산자 호출 시 기대한 것 : 파생 클래스의 복사 대입 연산자 호출 -> 기본 클래스의 복사 대입 연산자가 호출되는 것이지만 기본 클래스의 복사 대입 연산자는 호출되지 않는다.
상속 관계에서 파생 클래스의 복사 함수를 정의할 때 기본 클래스에 대한 복사를 명시하도록 하자
😈 예외로부터 안전한 코드를 만들도록 하자
◎ 자원 관리에는 객체가 그만!
파일 / 동적 메모리 / 뮤텍스 / GUI 등 시스템에 반환해야하는 자원의 관리는 객체 생성으로 하도록 하자 (Resource Acquisition is Initialization)
객체의 생성자에서 할당 / 소멸자에서 해제함으로써 예외와 메모리 누수에서 안전하도록 설계한다.
◎ 자원 관리 클래스의 복사 동작에 대해 진지하게 고찰하자.
◎ 자원 관리 클래스에서 관리되는 자원은 외부에서 접근할 수 있도록 하자.
<typedef int FontHandle;
void ReleaseFont(FontHandle f) {}
// API 수준에서 자원을 사용해 작업하는 함수
// 자원이 필요하다!
void UseFontHandle(FontHandle f) {}
class Font
{
public:
Font(FontHandle f) : _f(f)
{}
~Font() { ReleaseFont(_f); }
private:
FontHandle _f;
};
class Font
{
public:
Font(FontHandle f) : _f(f)
{
printf("생성자 호출 \n");
}
Font(const Font& other) : _f(other._f){ printf("복사 생성자 호출 \n"); }
// 명시적 변환 함수.
FontHandle get() const { printf("명시적 변환 함수 호출 \n"); return _f; }
// 암시적 변환 함수
operator FontHandle() { printf("암시적 변환 함수 호출\n"); return _f; }
~Font() { ReleaseFont(_f); }
private:
FontHandle _f;
};
Font f(3);
// 명시적 변환
UseFontHandle(f.get());
// 암시적 변환
UseFontHandle(f);
// 암시적 변환으로 제공, 만약 f가 소멸되면 f2는 이미 소멸되어버린 f의 FontHandle을 가짐
FontHandle f2 = f;
다만, 암시적 변환은 실수를 저지를 여지가 많기 때문에 명시적 변환을 제공할 것인지 / 암시적 변환을 허용할 것인지에 대한 결정은 조심스럽게 하자.
또한 RAII 클래스에서 객체에 대한 접근을 열어주는 것이 캡슐화 위배가 아닌가 ?
=> 그렇게 생각할 수도 있지만 애초에 RAII 의 핵심은 "데이터 은닉"이 목적이 아닌 "자원 해제"임
◎ new / delete를 사용할 때는 형태를 반드시 맞추자.
또한 여기선 []에 대해 "기본 제공 타입이라 해도 이들의 배열에 대해 []로 해제하지 않으면 UB가 나타날 수 있다"라고 설명하고 있음
◎ new로 생성한 객체를 스마트 포인터에 저장하는 코드는 별도의 한 문장으로 만들자.
int priority();
void processWidget(std::tr1::shared_ptr<Widget> pw, int priority)
main()
{
...
processWidget(std::tr1::shared_ptr<Widget>(new Widget), priority())
}
를 실행하게 되면 컴파일러는 processWidget 호출 코드를 만들기 위해 각 인자를 평가하게 되고, 함수의 호출 전에 3개의 연산을 위한 코드를 만들어야한다.
여기서 컴파일러 제작사마다 다른 연산 실행 순서가 정해질 수 있음.
다만, shared_ptr의 생성자는 new Widget후에 실행되어야 할 것임.
만약
① new Widget 실행
② priority() 호출
③ shared_ptr 생성자 호출
시점에 new 생성 -> priority() 호출 과정에서 예외 발생한다면
new로 생성한 메모리에 대한 누수가 발생할 수 있다.
(즉,
자원이 생성되는 시점 ---> 예외가 발생할 수 있다! ---> 자원 관리 객체로 넘어가는 시점
그렇기 때문에 이런 식으로 사용하자
std::tr1::shared_ptr<Widget> pw(new Widget);
processWidget(pw, priority());
😈 결국 자원 관리도 예외에 대한 처리가 중요하다?
◎ 인터페이스 설계는 제대로 쓰기엔 쉽게, 엉터리로 쓰기엔 어렵게 하자.
---- 실수 코드 ----
class Date {
public:
// 월, 일, 년 순
Date(int month, int day, int year);
};
// 사용할때 실수로 일과 월이 잘못 들어갔다!
// 이런 실수를 방지하자.
Date d(30, 3, 1995);
---- 실수 방지 코드 ----
// 일 / 월 / 년을 새롭게 정의
struct Day {
explicit Day(int d) : val(d) {}
int val;
};
struct Month {
explicit Month(int m) : val(m) {}
int val;
};
struct Year {
explicit Year(int y) : val(y) {}
int val;
};
class Date {
public:
Date(const Month& m, const Day& d, const Year& y);
};
// 순서를 명확하게 만든다.
// 순서가 바뀌었다면 컴파일 에러로 명확하게 알 수 있다.
Date d(Month(3), Day(30), Year(1995));
기본 제공 타입(int 등)처럼 동작하도록 만들자.
□ 사용자는 기본 제공 타입의 사용에 익숙 -> 우리가 만들 인터페이스도 일관성을 지키자.
shared_ptr를 반환해 사용자가 해제할 일이 없도록 하자.
◎ 클래스 설계는 타입 설계와 똑같이 취급하자.
클래스 설계 전에 이 항목들을 고려하자.
- 객체의 생성 및 소멸을 어떻게 설정할 것인가?
- 객체 초기화(복사 생성자)와 객체 대입(복사 대입)에 대한 처리를 어떻게 할 것인가?
- '값에 의한 전달'을 처리하는 복사 생성자를 어떻게 처리할 것인가?
- 객체 값에 대한 제약을 명확히 해야한다.
- 명시적 / 암시적 타입 변환을 허용할 것인가?
- 상속을 염두에 두고 클래스를 설계하였는가? (virtual)
- 어떤 연산자와 함수가 의미있을까?
- 표준 함수들 중 금지할 함수가 존재하는가?
- 멤버에 대해 접근 제한자(private, protect, public)을 설정하고, friend로 권한을 줄것인가?
- 예외 및 자원 사용 등에 대해서 안전한가?
- 동일 계열의 타입군 전체에 대해서 적용해야하나?
(그렇다면 클래스 템플릿을 활용하자)- 정말로 필요한가?
(간단하게 비멤버함수나 템플릿으로도 정의가 가능한데 굳이 새로운 클래스를 만들었나?)
◎ '값에 의한 전달'보다는 '상수객체 참조자에 의한 전달' 방식을 택하는 편이 더 낫다.
기본 제공 타입 및 STL 반복자, 그리고 함수 객체 타입에는 '값에 의한 전달'이 더 적절하다.
C++는 기본적으로 함수에 객체를 전달할 때 '값에 의한 전달(call by value)'을 사용 -> 다른 방식을 지정하지 않는 한 함수 매개변수는 실제 인자의 '사본'을 통해 초기화되며, 호출한 쪽도 '사본'을 돌려받고, 사본을 만들어내는 원천은 복사 생성자임
함수에 객체가 전달될 때 객체의 생성 (복사 생성자), 소멸 (소멸자)에 호출에 대한 작업이 계속 일어남
struct Str
{
Str(const char* s) :str{ s } { printf("Str() : %s\n",str.c_str()); }
Str(const Str& s) :str{ s.str } { printf("Str(const Str&) : %s\n",str.c_str()); }
~Str() { printf("~Str() : %s\n", str.c_str()); }
// std::string의 생성자 호출도 일어나지만 코드 수준에서 확인을 위해 랩핑
std::string str;
};
class Person
{
public :
Person() : name{ "name" }, address{"address"} { printf("Person()\n"); };
Person(const Person& other) : name{ other.name }, address{other.address} { printf("Person(const Person&)\n"); }
virtual ~Person() { printf("~Person()\n"); }
// ...
private:
Str name;
Str address;
};
class Student : public Person
{
public :
Student() : schoolName{ "schoolName" }, schoolAddress{ "schoolAddress" } { printf("Student()\n"); }
Student(const Student& other) : Person(other), schoolName{ other.schoolName}, schoolAddress{ other.schoolAddress } { printf("Student(const Student&)\n"); }
~Student() { printf("~Studentt()\n"); }
private:
Str schoolName;
Str schoolAddress;
};
bool validateStudent(Student s) { return true; }
int main()
{
printf("\n----------------------- 시작 -----------------------\n\n");
Student plato;
printf("\n------------------- 함수 호출 -------------------\n\n");
bool platoIsOk = validateStudent(plato);
printf("\n----------------------- 종료 -----------------------\n\n");
}

함수 호출 시 객체의 사본이 생성/소멸 되며 노란색 박스만큼의 작업이 이루어진다.
// 복사 손실 문제
class Person
{
public :
// 기존 코드는 동일
virtual void Print() const
{
printf("Person::Print()\n");
}
class Student : public Person
{
public :
// 기존 코드는 동일
virtual void Print() const override
{
Person::Print();
printf("Student::Print()\n");
}
};
bool copySlicing(Person p) { p.Print(); return true; }
int main()
{
printf("\n----------------------- 시작 -----------------------\n\n");
Student plato;
printf("\n------------------- 함수 호출 -------------------\n\n");
copySlicing(plato);
printf("\n----------------------- 종료 -----------------------\n\n");
}

복사손실이란?
파생 클래스 객체를 기본 클래스 타입의 객체로 전달할 때, 파생 클래스의 데이터가 소실되어 기본 클래스 부분만 복사되는 현상
=>함수 호출 부분을 보면 Student 객체를 Person 객체로 전달할때 Person의 사본만 만들어짐 -> Student 객체의 기능을 할 수 없다!!
// 참조에 의한 전달 방식으로 생성
// 기존과 동일
// 참조로 받는다! const& : 변경하지 않겠다!
bool validateStudent(const Student& s) { return true; }
printf("\n----------------------- 시작 -----------------------\n\n");
Student plato;
printf("\n------------------- 함수 호출 -------------------\n\n");
bool platoIsOk = validateStudent(plato);
printf("\n----------------------- 종료 -----------------------\n\n");

복사가 생기지 않음을 확인할 수 있음.
◎ 함수에서 객체를 반환해야 할 경우에 참조자를 반환하려고 들지 말자
절대 해선 안된다.
- 지역 스택 객체에 대한 포인터나 참조자를 반환하는일 (함수 호출 과정에서 스택에서 벌어지는 일을 생각하면.. 당연히 UB)
- 힙에 할당된 객체에 대한 참조자를 반환하는 일 ( Delete 어떻게 할건데.. )
- static 변수로 할당하면? ( 스레드 안전성 문제, 논리 오류 존재한다)
-> 객체로 반환해야 할 경우엔 그냥 객체로 반환하자!
◎ 데이터 멤버가 선언될 곳은 private 영역임을 명심하자.
문법적 일관성을 지킬 수 있으며 데이터에 대한 접근 권한을 설정할 수 있다. (외부 접근에 대한 getter / setter 설정으로)
이렇게 함으로써 캡슐화를 시킨다 (함수를 통해 데이터 멤버에 접근할 수 있도록 하면 구현과 수정이 용이하며, 사용자는 내부 구현을 신경쓸 필요가 없음)
쓸모 있는 구분은 결국 private이냐 (캡슐화) / private이 아니냐 (캡슐화x) 이다.
protected 변수도 public 변수와 마찬가지로 변수가 바뀌었을 때 변수에 의존하는 코드들을 다 바꿔야함 => 결국 이런 구조에선 코드도 다 망가진다. public이든 protected든 getter / setter 로 데이터에 접근하도록 하고 멤버는 private으로 가지자
◎ 멤버 함수보다는 비멤버 비프렌드 함수와 더 가까워지자.
◎ 타입 변환이 모든 매개변수에 대해 적용되어야 한다면 비멤버 함수를 선언하자
// --------------------------- 문제 ---------------------------
class Rational
{
public:
Rational(int n = 0, int d = 1) : num{ n }, den{d} {}
int numerator() const { return num; }
int denominator() const { return den; }
const Rational operator*(const Rational& other)
{
return Rational(num * other.num, den * other.den);
}
private:
int num;
int den;
};
int main()
{
Rational oneHalf(1, 2);
Rational result;
// 성공
// oneHalf.operator*(2)
// -> 2는 Rational(2)로 암시적 변환
result = oneHalf * 2;
//실패
// 2.operator*(oneHalf)
// -> int(2)에는 클래스 멤버함수가 없음. this 자리는 변환 안 됨.
result = 2 * oneHalf;
}
// --------------------- 비멤버 함수로 옮긴다 ---------------------
const Rational operator*(const Rational& lhs, const Rational& rhs)
{
printf("전역 호출\n");
return Rational(lhs.numerator() * rhs.numerator(), lhs.denominator() * rhs.denominator());
}
// 위와 같은 형태는 1, 2 모두 성공
-> 이렇게 사용할 경우는 암시적 변환이 잘 적용되도록 비멤버 함수로 만든다.
◎ 예외를 던지지 않는 swap에 대한 지원도 생각해보자.
namespace std
{
// 기본 std::swap의 전형적인 구현
template <typename T>
void swap(T& a, T& b)
{
T temp(a);
a = b;
b = temp
}
}
- 외부 함수로는 private한 데이터에 대한 접근이 불가함 -> 멤버 함수 제작 필요
- 문법적 제약 (함수 템플릿의 부분 특수화가 금지됨) -> 비멤버 오버로딩 필요
- 사용자가 swap 호출 시 std::swap이 아닌 custom swap에 대한 우선순위 부여 -> ADL : Argument-dependent lookup (인자 기반 탐색) 활용
- 인자 기반 탐색이란?
어떤 함수에 어떤 타입의 인자가 있으면, 그 함수의 이름을 찾기 위해 해당 타입의 인자가 위치한 네임스페이스 내부의 이름을 탐색하는 규칙
// ------------------ 일반 클래스의 경우 ------------------
namespace MyNamespace
{
class Widget
{
public:
// 1. 멤버 함수 (private 접근용)
void swap(Widget& other)
{
// custom swap
}
private:
WidgetImpl *pImpl;
};
// 2. 비멤버 함수 (ADL용)
void swap(Widget& a, Widget& b) {
a.swap(b);
}
}
// 3. std::swap 특수화 (가능함!)
namespace std
{
// std안에서 Widget에 대한 완전 특수화 (타입 고정)
template<>
void swap(Widget& a, Widget& b) {
a.swap(b);
}
}
// ------------------ 템플릿 클래스의 경우 ------------------
namespace MyNamespace
{
template<typename T>
class Widget
{
public:
// 1. 멤버 함수 (private 접근용)
void swap(Widget& other)
{
// custom swap
}
private:
WidgetImpl *pImpl;
};
// 2. 비멤버 함수 (ADL용) - ★이게 유일한 방법★
template<typename T>
void swap(Widget<T>& a, Widget<T>& b) {
a.swap(b);
}
// 3. std::swap 특수화 -> [컴파일 에러!]
// C++은 함수 템플릿의 부분 특수화를 금지함.
// namespace std
// {
// template<typename T>
// void swap<Widget<T>>(Widget<T>& a, ...) { ... } // 불가능
// }
}
사용하는 측에선 해당 형태로 사용하도록 한다.
// std::swap을 일단 넣어두되, 찾을만큼 찾고 (ADL (인자 기반 탐색)으로 먼저 찾고), 없으면 그때 사용해줘! 라는 의미.
using std::swap;
swap(a, b);
// std::swap(a,b); 이것은 바로 std에서 제공되는 swap을 사용해달라는 의미
// 일반 클래스에서는 템플릿 완전 특수화를 통해 특수화된 swap (3번) 찾을 수 있음,
// 클래스 템플릿의 경우는 기본 제공 std::swap사용
그리고 멤버함수의 swap은 예외에 대한 안전성 보장을 무조건 하도록 만들어야한다.
| 구분 | ① 멤버 함수 swap | ② 비멤버 swap (ADL) | ③ std::swap 특수화 | 비고 |
|---|---|---|---|---|
| 일반 클래스 | 필수 (private 접근용) | 권장 (택1) (일관성 유지) | 권장 (택1) ( std::swap 직접 호출 대응) | std::swap의 완전 특수화가 문법적으로 허용됨. 비멤버 함수가 없어도 특수화 덕분에 동작함. |
| 템플릿 클래스 | 필수 (private 접근용) | 필수 (★) (ADL을 위한 유일한 해법) | 불가능 ❌ (문법적 금지) | C++은 함수 템플릿의 부분 특수화(Partial Specialization)를 금지함. 따라서 std::swap 특수화가 불가능하며, 비멤버 오버로딩이 유일한 방법임. |
⭐ 컴파일러를 잘 모르니 정말 어렵다. 몇 번이고 계속 읽어보고 사용해봐야 사용법이 익을 듯하다
◎ 변수 정의는 늦출 수 있는 데까지 늦추는 근성을 발휘하자.
변수를 정의하게 되면 생성자와 소멸자의 호출에 대한 비용은 무조건 존재
함수에서 미리 객체를 정의해놓고 활용하면 사용하지 않더라도 객체의 생성/소멸에 대한 비용이 들어가기 때문에 필요할때 정의하며, 생성자로 초기화할 수 있다면 그렇게 진행하도록 한다.
// --------- 비효율적인 코드 1 ---------
std::string encryptPassword(const std::string & password)
{
const size_t MinimunPasswordLength = 10;
// password.length() < MinimunPasswordLength 상황엔 사용하지 않는데 미리 정의되어있음 -> 쓸데없는 생성자 / 소멸자의 호출이 이루어진다.
string encrypted;
if (password.length() < MinimunPasswordLength)
{
throw logic_error("Password is too short");
}
return encrypted;
}
// --------- 비효율적인 코드 2 ---------
std::string encryptPassword(const std::string & password)
{
const size_t MinimunPasswordLength = 10;
if (password.length() < MinimunPasswordLength)
{
throw logic_error("Password is too short");
}
// 사용할 때 정의하도록 만들었지만
// 생성자 -> 대입 연산자에 의해 대입이 이루어진다.
string encrypted;
encrypted = password;
return encrypted;
}
// --------- 수정 코드 ---------
std::string encryptPassword(const std::string & password)
{
const size_t MinimunPasswordLength = 10;
if (password.length() < MinimunPasswordLength)
{
throw logic_error("Password is too short");
}
// 필요할 때 생성하며, 기본 생성자 + 대입으로 이루어지던 작업을
// 복사 생성자 한번으로 처리하였다.
string encrypted(password);
return encrypted;
}
◎ 캐스팅은 절약, 또 절약! 잊지 말자
기존 C 스타일 캐스트
(T) 표현식 또는 T (표현식) 의 형태
C++ 스타일 캐스트
static_cast(표현식)
dynamic_cast(표현식)
const_cast(표현식)
reinterpret_cast(표현식)
C 스타일의 캐스트보단 C++ 스타일의 캐스트를 활용하자
캐스팅은 "컴파일러야 이 타입으로 처리해줘"가 아니다.
의도대로 동작하지 않을 수 있다.
// -------------------- 문제 코드 --------------------
class Window
{
public:
virtual void onResize() {}
};
class SpecialWindow : public Window
{
public:
virtual void onResize()
{
//우리는 이 객체에서 Window클래스의 onResize를 실행하고 싶다.
// 잘못된 방식!! 새로운 Window 임시객체가 생성되고, 그것의 onResize를 호출한다.
static_cast<Window>(*this).onResize();
}
};
// -------------------- 정상 코드 --------------------
class Window
{
public:
virtual void onResize() {}
};
class SpecialWindow : public Window
{
public:
virtual void onResize()
{
// 정상적으로 *this에서 Window::onResize()를 호출한다.
Window::onResize();
}
};
기반 클래스 포인터(Base*)를 가지고 있는데, 파생 클래스(Derived)에만 있는 함수를 써야 해서 캐스팅을 해야 한다면 dynamic_cast를 하고 싶어진다면 다시 생각해보자 (dynamic_cast를 하지 않는 구조로).
(대안 1) : 애초에 파생 클래스 포인터를 관리해서 사용하도록 한다.
(대안 2) : 기반 클래스에 가상 함수를 달아 가상 함수를 호출하도록 하자
// ----------------- 나쁜 예시 -----------------
vector<Window*> winPtrs;
// 루프 도는 중..
if (SpecialWindow* sw = dynamic_cast<SpecialWindow*>(winPtrs[i])) {
sw->blink(); // 캐스팅 성공 시 호출
}
// ----------------- 대안 1 -----------------
vector<SpecialWindow*> specialWinPtrs; // 애초에 전용 컨테이너 사용
// 루프 도는 중..
specialWinPtrs[i]->blink(); // 캐스팅 필요 없음! 빠르고 안전함.
// ----------------- 대안 2 -----------------
vector<Window*> winPtrs;
// 루프 도는 중..
winPtrs[i]->blink(); // 애초에 blink를 가상 함수로!!
◎ 내부에서 사용하는 객체에 대한 '핸들'을 반환하는 코드는 되도록 피하자.
'핸들'이란? 다른 객체에 손을 댈 수 있게 하는 매개자 (참조자, 포인터 및 반복자를 의미)
class Point
{
public:
Point(int x, int y);
...
void SetX(int newVal);
void SetY(int newVal);
private:
int x,y;
};
struct RectData
{
Point ulhc; // upper left handle corner
Point lrhc; // lower right handle corner
}
class Rectangle {
private:
shared_ptr<RectData> pData;
public:
// 문제 1. upperLeft가 상수함수여도 호출한 쪽에서 객체를 수정 가능하다.
Point& upperLeft() const { return pData->ulhc; }
// 문제 2. 문제 1은 해결되었지만 무효 참조 핸들이 생길 수 있다.
const Point& lowerRight() const { return pData->lrhc; }
};
class GUIObject { ... };
const Rectangle boundingBox(const GUIObject& obj){ ... }
int main()
{
GUIObject *pgo;
...
// 문제2 !! 임시객체가 갖고 있는 데이터에 대한 연결
// 임시객체는 바로 소멸됨!! ->
// 현재 pUpperLeft는 사라진 임시객체가 반환한 주소를 들고 있음.. UB
const Point *pUpperLeft = &boundingBox(*pgo).upperLeft());
}
◎ 예외 안전성이 확보되는 그날 위해 싸우고 또 싸우자!
예외 안전성을 가진 함수란?
- 예외가 발생했을 때 자원이 새도록 만들지 않아야 한다.
- 예외가 발생해도 자원에 대한 반환은 이루어져야함.
- 예외가 발생했을때 자료구조가 더럽혀지지 않아야 한다.
- 아마도.. 예외가 발생하더라도 사용하는 데이터에 대한 처리는 일관되게 처리해야 한다는 의미인 것으로 느껴짐
예외 안전성을 갖춘 함수란 아래의 3가지 보장 중 하나를 제공한다.
// 절대 예외를 던지지 않겠다 (x)
// 이 함수에서 예외가 발생되면 unexpected() 함수가 호출되어야함 (o)
// set_unexpected 함수로 실제 unexpected함수 호출 시 실행 함수 지정
int doSomething() throw;
강력한 보장을 제공하는 방법 (Copy - And - Swap)
struct PMImpl
{
std::shared_ptr<Image> bgImage;
int imageChanges;
};
class PrettyMenu
{
std::shared_ptr<PMImpl> pImpl;
Mutex mutex;
public:
void changeBackground(std::istream& imgSrc)
{
using std::swap;
// 1. 자원 관리 (RAII)
std::lock_guard<Mutex> guard(mutex);
// 2. 사본 생성 (Copy)
// 여기서 예외가 나면? 원본 pImpl은 건드리지 않았으므로 안전!
std::shared_ptr<PMImpl> pNew(new PMImpl(*pImpl));
// 3. 사본 수정 (Modify)
// 여기서 예외가 나도 pNew만 사라질 뿐, 원본은 안전!
pNew->bgImage.reset(new Image(imgSrc));
++pNew->imageChanges;
// 4. 맞바꾸기 (Swap)
// 포인터 swap은 절대로 예외를 던지지 않음 (Nothrow)
swap(pImpl, pNew);
// 종료 시 맞바꾼 데이터 제거
}
// 다만..
void SomeFunc(...)
{
...
// f1 실행 후 f2에서 예외가 발생한다면
// f1 과 f2가 예외에 대해 강력한 보장을 한다고 해도
// SomeFunc은 강력한 보장을 할 수 없음
// 이미 f1()에 대해 내부가 변경되었기 때문에..
// 기본적인 보장을 해야한다.
f1();
f2()
...
}
};
◎ 인라인 함수는 미주알고주알 따져서 이해해 두자.
✨ inline은 컴파일러에게 하는 '요청'이지 '명령'이 아니다.
- 컴파일러가 판단하기에 복잡한 함수 (loop, 재귀 등..)가 존재하는 형태
- 가상 함수의 호출을 하는 형태
암시적 inline
class Person
{
public:
...
// 암시적 inline 요청
int age() const {return theAge;}
};
생성자 / 소멸자에서는 웬만하면 inline하지 마라
class Base
{
public :
...
private:
std::string bm1, bm2;
}
class Derived : public Base
{
public:
// inline
Derived() {}
...
private:
std::string dm1, dm2, dm3;
}
// 생성자 , 소멸자에선 멤버에 대해 자동으로 생성 / 소멸되도록 코드가 만들어진다.
// inline이 된다는 것은 이 거대한 코드가 다 박혀버리기 때문에 웬만해서는 생성자 / 소멸자를 inline으로 작성하지 말아라.
// 생성자에 대한 개념적인 코드
Derived::Derived()
{
Base::Base();
try{ dm2.std::string::string(); }
catch(...)
{
Baes::~Base();
throw;
}
....
}
◎ 파일 사이의 컴파일 의존성을 최대로 줄이자.
컴파일러는 객체의 크기를 컴파일 시점에 알아야 한다 -> 클래스 설계 시 그 클래스의 구현 세부사항에 다른 클래스의 구현을 알아야하는 경우 컴파일 의존성이 발생한다. (컴파일 시간이 매우 길어짐)
이에 대한 해결로 인터페이스 / 구현의 분리가 필요.
컴파일 의존성을 최소화하는 핵심 원리 : 정의부에 대한 의존성을 선언부에 대한 의존성으로 변경해야함
⭐ 다음의 방법들은 구현과 인터페이스를 분리함으로써 파일들 간의 컴파일 의존성을 완화시킴
⭐ 하지만 헤더 파일에 함수 본문이 위치하지 않기 때문에 인라인 최적화가 되기는 어려움.
방법 1. Pimpl 관용구 (pointer to implementation) 을 활용
<// ---- Person.h 파일 ----
class PersonImpl;
class Date;
class Address;
// 이러한 형태의 클래스를 핸들 클래스라고 함
class Person {
public:
Person(const std::string& name, const Date& birthday, const Address& addr);
std::string name() const;
std::string birthDate() const;
std::string address() const;
//...
private:
std::tr1::shared_ptr<PersonImpl> pImpl;
};
---- Person.cpp 파일 ----
#include "Person.h"
#include "PersonImple.h" // 실제 PersonImpl클래스 정의도 include해야함
Person:: Person(const std::string& name, const Date& birthday, const Address& addr){...}
std::string Person::name() cosnt
{
return pImpl->name();
}
방법 2. 인터페이스 클래스를 활용
추상 클래스 또는 인터페이스 클래스를 통해 인터페이스만 제공하고, 팩토리 함수를 통해 파생 클래스의 인스턴스를 생성해서 활용하도록 한다.
(2-1) 가상 함수 구현을 통한 활용
- 대부분의 함수가 가상 함수로 이루어짐 -> 가상 함수 호출의 비용이 존재 (vptr -> 함수 호출)
// ---- Person.h ----
class Person
{
public:
virtual ~Person();
virtual std::string name() const = 0;
virtual std::string birthDate() const = 0;
virtual std::string address() const = 0;
static std::tr1::shared_ptr<Person> create(const std::string& name, const Date& birthday, const Address& addr);
// ...
}
// ---- Person.cpp ----
// 인터페이스 클래스 Person을 상속한 RealPerson 클래스
class RealPerson : public Person
{
public:
RealPerson(...);
virtual ~RealPerson();
virtual std::string name() const override {...};
virtual std::string birthDate() const override {...};
virtual std::string address() const {...};
// ...
private :
std::string theName;
Date therBirthDate;
Address theAddress;
}
std::tr1::shared_ptr<Person> create(const std::string& name, const Date& birthday, const Address& addr)
{
return std::tr1::shared_ptr<Person>(new RealPerson(...));
}
// 사용자는 이렇게 사용하면 된다.
std::tr1::shared_ptr<Person> pp(Person::create(name, dateofBirth,address));
// ...
- (2-2) 다중 상속을 통한 활용 (항목 40 예정)
◎ public 상속 모형은 반드시 "is-a" (..는 ...의 일종이다)를 따르도록 만들자.
✨public 상속은 "is-a (..는 ...의 일종이다)"를 의미한다. 잊지 말자.
Derived : public Base는 이런 의미를 갖는다.
Base의 모든 상태와 동작은 Derived에서도 유효하게 적용되어야한다.
class Bird
{
// ...
}
class FlyingBird : public Bird
{
public :
virtual void fly();
// ...
}
class Penguin : public Bird
{
///
}
int main()
{
Penguin p;
// 항목 18의 내용과 같이 사용자의 실수를 명확히 알 수 있도록
// 컴파일 타임에 에러를 뱉는 것이 좋은 설계
p.fly(); // 컴파일 에러!
}
// ----- 문법적 하자는 없지만 논리적으로 설계가 잘못된 상태 -----
class Rect
{
public :
virtual void SetHeight(int newHeight);
virtual void SetWidth(int newWidth);
virtual int height() const;
virtual int width() const;
}
// 직사각형의 가로만 늘리는 형태
// 정사각형이 들어간다면 정사각형은 정사각형이 아니게 된다.
void makeBigger(Rect& r)
{
int oldHeight = r.height();
r.setHeight(r.width() + 10);
assert(r.height() == oldHeight);
}
class Square : public Rect{...}
main()
{
Square s;
assert(s.width() == s.height());
makeBigger(s);
assert(s.width() == s.height());
}