CS/C++

Effective Modern C++ 3장 : 현대적인 C++에 적응하기 (1)

대니스 2026. 7. 7. 01:03

- C++11, C++14에는 auto, 스마트 포인터, 이동 시멘틱, 람당, 동시성 지원 라이브러리의 기능들이 추가되었다.

 

항목 7 : 객체 생성 시 괄호()와 중괄호{}를 구분하라

- C++11에서는 초기화 값을 괄호로 지정할 수도 있고, 등호로 지정할 수도 있고, 중괄호로 지정할 수도 있다

int x(0); // 초기치를 괄호로 감싼 예
int y=0; // 초기치를 "=" 다음에 지정한 예
int z{0}; // 초기치를 중괄호로 감싼 예
int z={0}; // "="와 중괄호로 초기치를 지정한 예

Widget w1; // 기본 생성자를 호출 
Widget w2=w1; // 배정이 아님. 복사 생성자를 호출
w1=w2; // 배정; 복사 배정 연산자(operator=)를 호출

- 여러 가지 초기화 구문이 주는 혼동을 완화하기 위해, 그리고 그 구문들이 모든 가능한 초기화 시나리오를 포괄하지는 않는다는 사실을 해결하기 위해, C++11은 균일 초기화(uniform initialization)를 도입했다

 - 이것은 어디서나 사용할 수 있고 모든 것을 표현할 수 있는(적어도 개념상으로는) 단 한 종류의 초기화 구문이다

- 균일 초기화 구문은 중괄호를 사용한다. 또한, 비정적(non-static) 자료 멤버의 기본 초기화 값을 지정하는데에도 사용할 수 있다

- 반면, 복사할 수 없는 객체는 중괄호나 괄호로는 초기화할 수 있지만 "="로 초기화할 수 없다.

std::atomic<int> ai1{0}; // ok
std::atomic<int> ai2(0); // ok
std::atomic<int> ai3=0; // 오류!

 

- 중괄호 초기화의 혁신적인 기능 하나는, 내장 기능들 사이의 암묵적 좁히기 변환(narrowing conversion)을 방지해 준다는 것이다.

- 괄호나 "="를 이용한 초기화는 이러한 좁히기 변환을 점검하지 않는다. 

double x,y,z;
int sum1{x+y+z}; // 오류!
int sum2(x+y+z); // ok
int sum3=x+y+z; // ok

 

- C++에서 가장 성가신 구문 해석(most vexing parse)에 자유롭다

 - 가장 성가신 구문 해석은 "선언으로 해석할 수 있는 것은 항상 선언으로 해석해야 한다"는 C++의 규칙에서 비롯된 하나의 부작용이다

Widget w1(10); // 인수 10으로 Widget의 생성자를 호출
Widget w2(); // 가장 성가신 구문 해석! Widget을 돌려주는, w2라는 이름의 함수를 선언한다
Wdiget w3{}; // 인수 없이 Wdiget의 생성자를 호출

 

- 중괄호 초기화의 단점은 중괄호 초기치와 std::initializer_list, 그리고 생성자 중복적재 해소 사이의 괴상하게 뒤얽힌 관계에서 비롯된 것이다.

 

class Widget {
public:
	Widget(int i, bool b);
    Widget(int i, double d);
    ...
};

Widget w1(10,true); // 첫 생성자 호출
Widget w2{10,true}; // 첫 생성자 호출
Widget w3(10, 5.0); // 두번째 생성자 호출
Widget w4{10, 5.0}; // 두번째 생성자 호출

 

- std::initializer_list를 받는 오버로딩 버전을 강하게 선호한다

- std:: initializer_list를 받는 버전의 생성자 호출로 해석할 여지가 조금이라도 있으면, 컴파일러는 반드시 그 해석을 선택한다.

class Widget {
public:
	Widget(int i, bool b);
    Widget(int i, double d);
    Widget(std::initializer_list<long, double> il);
    ...
};

Widget w1(10,true); // 첫 생성자 호출
Widget w2{10,true}; // 세번째 생성자 호출
Widget w3(10, 5.0); // 두번째 생성자 호출
Widget w4{10, 5.0}; // 세번째 생성자 호출

 

- 컴파일러가 자신의 결심을 포기하고 보통의 오버로딩으로 물러나는 경우는 중괄호 초기치의 인수 형식들을 std::initializer_list 안의 형식으로 변환하는 방법이 아예 없을 때분이다.

class Widget {
public:
	Widget(int i, bool b);
    Widget(int i, double d);
    Widget(std::initializer_list<std::string> il);
    ...
};

Widget w1(10,true); // 첫 생성자 호출
Widget w2{10,true}; // 첫번째 생성자 호출
Widget w3(10, 5.0); // 두번째 생성자 호출
Widget w4{10, 5.0}; // 두번째 생성자 호출

 

- 빈 중괄호 쌍은 빈 std:: initializer_list가 아니라 인수 없음을 뜻한다.

- 빈 std:: initializer_list로 std:: initializer_list 생성자를 호출하고 싶다면, 다음처럼 빈 중괄호 쌍을 괄호로 감싸거나 빈 중괄호 쌍을 또 다른 중괄호 쌍으로 감싸면 된다.

class Widget{
public:
	Widget();
    Widget(std::initializer_list<int> il);
    ...
};

Widget w1; // 기본 생성자를 호출
Widget w2{}; // 기본 생성자를 호출
Widget w3(); // 함수 선언

 

- std::vector이기 때문은 직접 영향을 받는 클래스로 컨테이너의 초기 크기와 컨테이너의 모든 초기 요소의 값을 지정할 수 있는 비std:: initializer_list 생성자가 있다. 그런데 컨테이너의 초기 값들을 지정할 수 있는 std:: initializer_list를 받는 생성자도 있다.

std::vector<int> v1(10,20); // 값이 20인 요소 10개짜리 std::vector가 생성됨
std::vector<int> v2{10,20}; // 생성자를 사용 : 값이 각각 10,20인 두 요소를 담음

 

- 클래스를 작성할 때에는 만일 일단의 오버로딩된 생성자 중에 std:: initializer_list를 받는 함수가 하나 이상 존재한다면, 중괄호 초기화 구문을 이용하는 클라이언트 코드에는 std:: initializer_list 오버로딩만 적용될 수 있다

 

- 클래스 사용자로서 객체를 생성할 때 괄호와 중괄호를 세심하게 선택해야 한다는 것이다.

 

- 템플릿 작성자에게는 객체 생성 시 괄호와 중괄호의 긴장 관계가 특히나 짜증스러울 수 있는데, 왜냐하면 일반적으로 둘 중 어느 것을 사용해야 하는지를 판단하는 것이 불가능하기 때문이다.

 - 오직 호출자만이 알 수 있을 뿐이다.

 - 인터페이스의 일부에 문서화함으로써 이 문제를 해결한다.

template<typename T, typename... Ts>
void doSomeWork(Ts&&... params)
{
	params...으로부터의 지역 T객체를 생성한다
}

T localObject(std::forward<Ts>(params)...);
T localObject{std::forward<Ts>(params)...};

 

항목 8: 0과 NULL보다 nullptr를 선호하라

- C++의 기본방침은, 0은 int이지 포인터가 아니다

- 구현(컴파일러)이 NULL에 int이외의 정수 형식(이를테면 long)을 부여할 수 있기 때문이다. 즉, NULL도 포인터 형식이 아니다.

-> 위와 같은 특징으로 0이나 NULL로 오버로딩 함수를 호출했을 때, 포인터를 받는 오버로딩이 호출되는 일은 없다

void f(int);
void f(bool);
void f(void*);

f(0); // v(void*)가 아니라 f(int)를 호출
f(NULL); // 컴파일되지 않을 수도 있지만, 보통은 f(int)를 호출. f(void*)를 호출하는 경우는 없다

- f(NULL)의 행동에 관련된 불확실성은 NULL의 구체적인 형식을 구현의 재량에 맡긴 결과(NULL은 매크로이기 때문에 어떻게 정의되어 있느냐에 따라 다르다.)이다.

- NULL이 이를테면(즉, long 형식의 0)로 정의되어 있다면 이 호출은 중의적이다.

 - long에서 int로의 변환, long에서 bool로의 변환, 0L에서 void*의 변환은 우선순위가 같기 때문이다

- 소스 코드의 외관상 의미(NULL, 즉 널 포인터로 f를 호출한다)와 실제 의미(널 포인터가 아닌 어떤 정수로 f를 호출한다)가 서로 모순된다

-> 즉, 포인터 형식과 정수 형식들에 대해서는 오버로딩을 피해야 한다

 

nullptr의 장점

1. 정수 형식이 아니다

- 포인터 형식도 아니지만 모든 형식의 포인터라고 생각하면 된다

 - 실제 형식은 std::nullptr_t이다.

 - std::nullptr_t는 모든 생(raw) 포인터 형식으로 암묵적으로 변환되며, nullptr는 마치 모든 형식의 포인터처럼 행동한다

2. 코드의 명확성을 높여준다

 - 특히, auto 변가 관여하는 상황과 템플릿이 관여할 때에서도 그렇다

auto result = findRecord();

if(result==0)
{
	// findRecord의 반환 형식을 모른다면, result가 포인터 형식인지 아니면 정수 형식인지를 명확히 X
}

if(result==nullptr)
{
	// result가 포인터 형식임을 명백함
}

 

int f1(std::shared_ptr<Widget> spw); // 이 함수들은 적절한 뮤텍스를 잠그고 호출해야 한다
double f2(std::unique_ptr<Widget> upw);
bool f3(Widget* pw);

std::mutex f1m, f2m, f3m; // f1,f2,f3 뮤텍스들

using MuxGuard = std::lock_guard<std::mutex>;
...
{
	MuxGuard g(f1m); // f1용 뮤텍스를 잠근다. 
    auto result = f1(0); // 0을 널 포인터로서 f1에 전달 
}

{
	MuxGuard g(f2m); // f2용 뮤텍스를 잠근다. 
    auto result = f2(NULL); // NULL을 널 포인터로서 f2에 전달 
}

{
	MuxGuard g(f3m); // f3용 뮤텍스를 잠근다. 
    auto result = f3(nullptr); // nullptr을 널 포인터로서 f1에 전달 
}

-> 위 코드는 작동은 하나 소스 코드 중복(duplication)이 일어난다

 

tempplate<typename FunType, typename MuxType, typename PtrType>
auto lockAndCall(FunType func, MuxType mutex, PtrType ptr) -> decltype(func(ptr))
{
	using MuxGuard = std::lock_guard<MuxType>;
    
    MuxGuard g(mutex);
    return func(ptr);
} // C++11용

tempplate<typename FunType, typename MuxType, typename PtrType>
decltype(auto) lockAndCall(FunType func, MuxType mutex, PtrType ptr)
{
	using MuxGuard = std::lock_guard<MuxType>;
    
    MuxGuard g(mutex);
    return func(ptr);
} // C++14용

auto result1 = lockAndCall(f1,f1m,0); // 오류!
auto result2 = lockAndCall(f2,f2m,NULL); // 오류!
auto result3 = lockAndCall(f3,f3m,nullptr); // OK

-> 2개의 호출은 매개변수와 호환되지 않기 때문에 컴파일 오류가 난다

-> 3번째는 std::nullptr_t는 암묵적으로 모든 포인터 형식으로 변환되기 때문에 정상적으로 작동된다.

 

항목 9: typedef보다 별칭 선언을 선호하라

- typedef와 별칭 선언(C++11에서 존재하며 alias declaration이라고 함)이 하는 일은 정확히 동일하다

typedef void (*FP) (int, const std::string&);
using FP = void(*)(int, const std::string&);
// 둘 다 같은 의미이다.

 

별칭 선언을 선택해야 하는 이유

1. typedef는 템플릿화할 수 없지만 별칭 선언은 템플릿화할 수 있다

- 템플릿화된 별칭 선언을 별칭 템플릿(alias templates)이라고 부른다

- C++98에서는 템플릿화된 struct 안에 내포된 typedef들을 활용하는 편법을 동원해야했다.

template<typename T>
using MyAllocList = std::list<T, MyAlloc<T>>;

template<typename T>
struct MyAllocList {
	typedef std::list<T, MyAlloc<T>> type;
};

- MyAllocList<T>::type은 템플릿 형식 매개변수(T)에 의존적인 형식을 지칭한다

- MyAllocList<T>::type은 의존적 형식(dependent type)이며, 의존적 형식의 이름 앞에 반드시 typename을 붙여야 한다.

 - 컴파일러가 제대로 판단을 하지 못한다.값인지 타입인지 판단을 못한다

- MyAllocList<T> 템플릿 매개변수 T에 의존하는 것 같지만, 컴파일러는 그렇게 여기지 않는다

 - MyAllocList가 형식템플릿이므로, MyAllocList<T>는 반드시 형식의 이름이어야 하기 때문이다

 - MyAllocList<T>는 비의존적 형식(non-dependent type)이며, typename 지정자를 붙일 필요가 없고 붙여서도 안된다.

 

- MyAllocList<T>::type을 만난 컴파일러는 그것이 형식의 이름임을 확신하지 못한다.

 - MyAllocList의 어떤 특수화(specialization)에서 MyAllocList<T>::type이 형식 이외의 어떤 것을 지칭할 가능성이 남아있기 때문이다

class Wind {...};

template<> // T가 Wine인 경우에 대한 MyAllocList의 특수화
class MyAllocList<Wine>{
private:
	enum class WineType {White, Red, Rose};
    WineType type; // 이 클래스에서 type은 자료 멤버
};

- 위의 경우 MyAllocList<Wind>::type의 형식을 지칭하지 않는다

- Wine으로 Widget을 인스턴스화하면 Widget 템플릿 안의 MyAllocList<T>::type은 형식이 아니라 자료 멤버를 지칭한다

- 따라서, Widget 템플릿 안에서 MyAllocList<T>::type이 형식을 지칭하는지는 전적으로 T가 무엇인지에 의존한다. 이 때문에, 그것이 형식임을 컴파일러가 안심하고 받아들이기 위해서 typename을 붙여야 한다.

 

형식 특질

- C++11은 const 한정사를 없애거나 등의 종류의 변환을 형식 특질(type trait)의 형태로 수행할 수 있는 도구들을 제공한다

- 향식 특질은 <type_traits> 헤더 안에 있는 일단의 템플릿들을 말한다.

- <type_traits>은 하나의 예측 가능한 인터페이스를 제공한다

 - 변환을 적용하고자 하는 형식 T가 주어졌을 때, 변환을 적용한 결과로 나온 형식은 항상 std::변환<T>::type이다

std::remove_const<T>::type // const T를 T로 변환
std::remove_reference<T>::type // T&나 T&&를 T로 변환
std::add_lvalue_reference<T>::type // T를 T&로 변환

- 템플릿 안의 형식 매개변수에 "::type" 접미어를 붙인다면, 그 앞에 typename도 반드시 붙여야 한다.

- C++11의 형식 특질들이 모두 템플릿화된 struct 안에 내포된 typedef들로 구현되어있기 때문에 필요하다

- C++11에 있는 std::변환<T>::type 형태의 각 형식 특질에 대해, C++14에는 std::변환_t 형태의 별칭 템플릿이 있다.

std::remove_const<T>::type // C++11
std::remove_const_t<T> // C++14

std::remove_reference<T>::type // C++11
std::remove_reference_t<T> // C++14

std::add_lvalue_reference<T>::type // C++11
std::add_lvalue_reference_t<T> // C++14
template<class T>
using remove_const_t = typename remove_const<T>::type;

template<class T>
using remove_reference_t = typename remove_reference<T>::type;

template<class T>
using add_lvalue_reference_t = typename add_lvalue_reference<T>::type;

 

항목 10: 범위 없는 enum보다 범위 있는 enum을 선호하라

범위 없는(unscoped) enum

- enum으로 선언된 열거자(enumerator) 이름들은 enum을 포함하는 범위에 속하며, 그 범위에 같은 이름이 있으면 안된다.

- 이 열거자 이름들이 자신을 정의하는 enum의 범위로 새어 나간다.

enum Color {black, white, red}; // black, white, red는 Color가 속한 범위에 속함
auto white=false; // 오류! 이 범위에 이미 white가 선언되어 있음

 

범위 있는(scoped) enum

- C++11에서 범위 없는 대응되는 새로운 열거형인 범위 있는 enum이 있다

- enum 클래스라고 부르기도 한다.

enum class Color {black, white, red}; // black, white, red는 Color가 속한 범위에 속함
auto white=false; // Ok: 이 범위에 다른 "white"는 없음
Color c = white; // 오류! 이 범위에 "white"라는 이름의 열거자가 없음
Color c = Color::white; // OK

 

범위 있는(scoped) enum 장점

- 이름공간(namespace) 오염을 줄여준다. 즉, 특정 영역에 독립적으로 존재해야 할 이름(변수명, 함수명 등)들이 사방팔방으로 유출되어서, 다른 이름들과 충돌할 위험을 키우는 것

 

- 열거자들에 형식이 훨씬 강하게 적용된다. 즉, 암묵적으로 다른 형식으로 변환되지 않다.

- 범위 없는 enum의 열거자들은 암묵적으로 정수 형식으로 변환된다.

enum Color {balck, white, red}; // 범위 없는 enum

std::vector<std::size_t> primFactors(std::size_t); // x의 소인수들을 돌려주는 함수

Color c = red;
...

if(c<14.5){ // Color를 double과 비교
	auto factors = primFactors(c); // Color의 소인수들을 계산
    ...
}
enum class Color {balck, white, red}; // 이제는 enum에 범위가 적용됨. 이전과 동일하나, 범위 한정사가 있음

Color c = Color::red;
...

if(c<14.5){ // 오류! Color와 double을 비교할 수 없음
	auto factors = primFactors(c); // 오류! std::size_t를 기대하는 함수에 Color를 전달할 수 없음
    ...
}

 

- 어떤 이유로 Color를 다른 형식으로 변환하고 싶다면 캐스팅을 사용하면 된다.

if(static_cast<double>(c)<14.5){ // 유효함
	auto factors = primFactors(static_cast<size_t>(c)); // 컴파일된다.
    ...
}

 

- 범위 있는 enum은 전방 선언(forward declaration; 또는 선행 선언)이 가능하다

- 열거자들을 지정하지 않고 열거형 이름만 미리 선언할 수 있다.

enum Color; // 오류!
enum class Color; // OK

 

- C++11에서 범위 없는 enum도 전방 선언이 가능하지만 모든 enum의 바탕 형식(컴파일러가 결정하는)이 정수 형식이다.

- 아래의 코드는 표현해야 할 값이 세 개 뿐이므로 컴파일러는 바탕 형식(underlying type)으로 char를 선택할 것이다.

enum Color {black, white, red};

 

- 아래와 같은 값들을 나타내기 위해서는 char보다 큰 정수 형식을 선택해야 한다.

enum Status {good=0, failed=1, incomplete=100, corrupt=200, indeterminate=0xFFFFFFFF};

- 메모리를 효율적으로 활용하기 위해, 컴파일러들은 주어진 enum의 열거가 값들의 범위를 표현할 수 있는 가장 작은 바탕 형식을 선택하는 경향이 있다.

- 그러나 경우에 따라서는 컴파일러가 크기 대신 속도를 위한 최적화를 적용하며, 그런 경우에는 허용되는 가장 작은 바탕 형식을 선택하지 않을 수도 있다.

- 이러한 이유 때문에 C++98에서는 오직 enum 정의만 지원하고 선언은 허용하지 않는다. 그러면 enum이 실제로 쓰이기 전에 컴파일러가 enum의 바탕 형식을 선택할 수 있기 때문이다.

 

- 이렇게 전방 선언할 수 없으면 컴파일 의존 관계가 늘어난다

- 만약 새 열거자를 추가하면, 시스템 전체를 다시 컴파일해야 한다.

-> C++11이 제공하는 enum 전방 선언 능력으로 제거할 수 있는 일이기도 하다.

- enum이 쓰이기 전에 그 크기를 컴파일러가 알아야 한다면, 범위 있는 enum의 바탕 형식은 컴파일러가 언제라도 알 수 있으며(기본 4바이트로 못 박음), 범위 없는 enum의 바탕 형식은 프로그래머가 직접 정의할 수 있다는 것이다.

 

- 범위 있는 enum의 바탕 형식은 기본적으로 int이다. 만약에 기본 형식이 마음에 들지 않는다면 다른 형식을 명시적으로 지정하면 된다.

enum class Status; // 바탕 형식은 int
enum class Status: std::uint32_t; // Status의 바탕 형식은 std::uint32_t(<cstdint>에 있음)

 

- 바탕 형식이 지정된 범위 없는 enum은 전방 선언을 지원한다. enum을 '정의'할 때에도 바탕 형식을 지정할 수 있다

enum Color: std::uint8_t; // 범위 없는 enum의 전방 선언 바탕 형식은 std::uint8_t
enum class Status: std::uint32_t  {good=0, failed=1, incomplete=100, corrupt=200, audited=500,indeterminate=0xFFFFFFFF};

 

- 범위 없는 enum이 유용한 상황은 C++11의 std::tuple 안에 있는 필드들을 지칭할 때이다.

using UserInfo = std::tuple<std::string, std::string, std::size_t>; // 형식 별칭

- 아래와 같은 코드를 본다면 무엇을 뜻하는지 바로 알기 힘들다

UserInfo uInfo; // tuple 형식의 객체
auto val = std::get<1>(uInfo); // 필드 1의 값을 얻는다.

- 범위 없는 enum으로 필드 번호를 필드 이름에 연관시키면 아래와 같이 된다.

enum UserInfoFields {uiName, uiEmail, uiReputation};
UserInfo uInfo; 
auto val = std::get<uiEmail>(uInfo); // 이메일 필드의 값을 얻음이 명확함

- 이것이 가능한 이유는 UserInfoFields에서 std::size_t로의 암묵적 변환 때문이다

 

- 범위 있는 enum 버전은 아래와 같다

enum class UserInfoFields {uiName, uiEmail, uiReputation};
UserInfo uInfo; 
auto val = std::get<static_cast<std::size_t>(UserInfoFields::uiEmail)>(uInfo);

- 열거자 하나를 받아서 그에 해당하는 std::size_t 값을 돌려주는 함수를 작성한다면 좋겠지만 다소 까다롭다. 

- 열거자를 std::size_t로 변환하는 함수는 그 결과를 컴파일 도중에 산출해야한다. 또한 그 함수는 반드시 constexpr 함수이어야 한다. 

 -> 배열의 인덱스(myArray[인덱스])나 템플릿 변수 같은 녀석들은 컴파일하는 시점에 그 숫자가 몇인지 명확히 결정되어 있어야 합니다. 런타임에 실행되는 일반 함수로는 배열의 크기나 템플릿 인자를 결정할 수 없습니다. 따라서 컴파일 타임에 즉시 정수 값을 뱉어내도록 함수 앞에 constexpr을 무조건 붙여야 합니다.

- 더 정확하게 말하면 그러한 함수는 constexpr 함수 템플릿이어야 한다. 그 어떤 종류의 enum에 대해서도 작동해야 하기 때문이다.

 -> 모든 enum 타입을 다 받아낼 수 있도록 템플릿(template<typename E>)으로 구현해야 합니다.

- 만일 일반화를 시도한다면, 반환 형식도 일반화할 필요가 있다. 즉, std::size_t를 돌려주는 것이 아니라 enum 바탕 형식을 돌려주어야 한다. 

 -> 원본 enum이 원래 가지고 있던 진짜 알맹이 타입(바탕 형식)을 컴파일러가 알아서 찾아내어 그 타입 그대로 리턴하게 만들어야 완벽

- 마지막으로, 그 함수는 noexcept로 선언해야 한다. 결코 예외를 던지지 않을 것을 알고 있기 때문이다.

 

template<typenam E>
constexpr typename std::underlying_type<E>::type
toUType(E enumerator) noexcept
{
	return static_cast<typename std::underlying_type<E>::type>(enumerator);
}

 

- C++14에서는 typename std::underlying_type<E>::type을 깔끔한 std::underlying_type_t로 대체해서 toUType을 더욱 단순화할 수 있다.

template<typenam E>
constexpr typename std::underlying_type_t<E>
toUType(E enumerator) noexcept
{
	return static_cast<typename std::underlying_type_t<E>>(enumerator);
}

 

- C++14가 지원하는 auto 반환 형식을 적용하면 아래와 같다

template<typenam E>
constexpr auto
toUType(E enumerator) noexcept
{
	return static_cast<typename std::underlying_type_t<E>>(enumerator);
}

 

어떤 형태로 구현했든, toUType을 이용하면 튜플의 한 필드에 다음과 같이 접근할 수 있다

auto val = std::get<toUType(UserInfoFields::uiEmail)>(uInfo);

- 범위 없는 enum을 사용할 때보다는 여전히 타자량이 많지만, 그래도 이름공간 오염을 피하고 열거자들과 관련된 의도치 않은 변환이 방지된다.

 

항목 11: 정의되지 않은 비공개 함수보다 삭제된 함수를 선호하라

- 다른 개발자에게 제공할 코드를 작성할 때, 가장 흔한 방법은 그냥 그 함수를 선언하지 않은 것이다

- C++이 대신 함수들을 선언하는 경우가 있으며, 그런 경우 클라이언트가 그 함수들을 호출하지 못하게 하기는 그리 쉽지 않다

- 그런 상황은 소위 "특수 멤버 함수들", 즉 C++이 필요에 따라 자동으로 작성하는 멤버 함수들에서만 발생한다

- C++98에서는, 사용을 금지하려는 멤버 함수가 거의 항상 복사 생성자 또는 복사 배정 연사자였다. 그런 함수들의 사용을 방지하기 위한 C++98의 접근방식은 그런 함수들을 private(비공개)으로 선언하고 정의는 하지 않는 것이다.

- 예시로 모든 입출력 스트림 클래스는 basic_os 클래스를 상속한다. 입력 스트림 객체나 출력 스트림 객체는 복사하지 않는 것이 좋은데, 이는 그런 객체에 대한 복사 연산이 구체적으로 어떤 일을 해야 할 것인지가 명확하지 않기 때문이다.

- 그래서 istream 객체를 복사한다면, '이후에 읽힐 값들뿐만 아니라 이미 읽은 값들도 복사해야 할까'라는 질문이 제기 될 여지를 아예 없애는 것이다. 스트림 객체의 복사를 방지하면 그런 여지가 사라진다.

 

template<class charT, class traits = char_traits<charT>>
class basic_ios : public ios_base{
public:
	...
private:
	baisc_ios(const basic_ios&); // not defined
    basic_ios& operator=(const basic_ios&); // not defined
};

- 이 함수들을 private 섹션에 선언되어 있으므로 클라이언트가 호출할 수 없다.

- 또한, 함수들을 의도적으로 정의하지 않았기 때문에(not defined), 이들에 접근할 수 있는 코드에서 호출한다고 해도 정의가 없어서 링크가 실패한다.

 

- C++11에서는 같은 목적을 달성한느 더 나은 방법이 있다. 복사 생성자와 복사 배정 연산자 선언의 끝에 "=delete"를 붙인는 것이다.

- =delete를 붙인 함수를 삭제된 함수(delete function)라고 부른다.

template <class charT, class traits = char_traits<charT>>
class basic_ios : public ios_base{
public:
	...
    basic_ios(const basic_ios&) = delete;
    basic_ios& operator=(const basic_ios&) = delete;
    ...
};

- 삭제된 함수는 어떤 방법이로든 사용할 수 없다. 따라서 멤버 함수나 friend 함수에서 basic_ios 객체를 복사하려 하면 컴파일이 실패한다.

 

- 삭제된 함수는 private이 아니라 public으로 선언하는 것이 관례이다.

- 클라이언트 코드가 멤버 함수를 사용하려 할 때, C++은 먼저 그 함수의 접근성을 점검한 후에야 삭제 여부를 점검한다.

- private 함수를 사용하는 클라이언트 코드에 대해 그 함수가 piravate라는 점을 문제 삼는 컴파일러들이 있다. 사실 함수를 사용할 수 없는 주된 이유가 함수의 접근성 때문이 아니라는 점에서, 이는 오해의 여지를 제공한다. 구식 코드에서 정의되지 않은 private 멤버 함수를 삭제된 함수로 변경할 때에는, 새 함수들을 public으로 선언하면 컴파일러가 삭제자 함수 때문이라는 것이라고 오류 메시지를 알려준다.

 

- 그 어떤 함수도 삭제할 수 있다는 것이다. 반면 private은 멤버 함수에만 적용할 수 있다.

bool isLucky(int number);

if(isLucky('a')) ... // 'a'가 행운의 번호인가?
if(isLucky(true)) ... // true는 어떤가?
if(isLucky(3.5)) ... // 먼저 소수부를 버려서 3을 만든 후 행운을 시험해야 하는가?

 

- 매개변수가 정수이어야 한다면, 위의 호출들이 아예 컴파일되지 않게 해야하는데 배제할 형식들에 대한 함수 중복적재들을 명시적으로 삭제하는 것이다.

bool isLucky(int number); // 원래의 함수
bool isLucky(char) = delete; // char를 배제
bool isLucky(bool) = delete; // bool를 배제
bool isLucky(double) = delete; // double과 float를 배제

- 이 과정은 중복적재 해소 과정에서 후보로 간주된다

 

- 삭제된 함수로 수행할 수 있는 것은 원치 않은 템플릿 인스턴스화를 방지하는 것이다.

- 예를 들어 void* 포인터(역참조나 증가, 감소가 아예 불가능하다), char* 포인터(C 스타일 문자열을 나타낸다)

template<>
void processPointer<void>(void*) = delete; // const void, const volatile void* 도 가능

template<>
void processPointer<char>(char*) = delete; // const char, const volatile char* 도 가능

 

- 클래스 안의 함수 템플릿의 일부 인스턴스화를 방지하려는 목적으로 private 선언 접근방식을 적용할 수는 없다.

- 멤버 함수 템플릿의 한 특수화의 접근 수준을 그 템플릿 자체의 것과는 다른 수준으로 지정하는 것을 불가능하기 때문이다.

class Widget{
public:
	...
	template<typename T>
	void processPointer(T* ptr)
    {...}

private:
	template<>
    void processPointer(void*) // 오류

- 이것이 불가능한 이유는, 템플릿 특수화는 반드시 클래스 범위가 아니라 이름공간 범위에서 작성해야 한다는 것이다

- 삭제된 함수에는 다른 접근 수준을 지정할 필요가 없으므로 이런 문제가 없다

- 멤버 함수를 클래스 바깥에서(따라서 이름공간 범위에서) 삭제하는 것이 가능하다

class Widget{
public:
	...
	template<typename T>
	void processPointer(T* ptr)
    {...}
};

template<>
void Widget::processPointer(void*) = delete; // 여전히 public, 그러나 삭제되었음

 

항목 12: 재정의 함수들을 override로 선언하라

파생 클래스(derived class)의 가상 함수 구현이 기반 클래스(base class)의 해당 가상 함수 구현을 재정의한다(override)

가상 함수 재정의는 파생 클래스 함수를 기반 클래스의 인터페이스를 통해서 호출할 수 있게 만드는 메커니즘이다

class Base{
public:
	virtual void doWork(); // 기반 클래스 가상 함수
    ...
};

class Derived: public Base{
public:
	virtual void doWork(); // Base::doWork를 재정의한다 (여기서 virtual를 생략할 수 있다)
    ...
};

std::unique_ptr<Base> upb = std::make_unique<Derived>(); // 파생 클래스 객체를 가리키는 기반 클래스 포인터를 생성한다. 
...
upb->doWork(); // 기반 클래스 포인터로 doWork을 호출한다; 파생 클래스의 함수를 호출된다.

 

재정의가 일어나려면 아래와 같은 필수조건을 만족해야 한다

- 기반 클래스 함수가 반드시 가상 함수이어야 한다

- 기반 함수와 파생 함수의 이름이 반드시 동일해야 한다(단, 소멸자는 예외)

- 기반 함수와 파생 함수의 매개변수 형식들이 반드시 동일해야 한다

- 기반 함수와 파생 함수의 const성이 반드시 동일해야 한다

- 기반 함수와 파생 함수의 반환 형식과 예외 명세(exception specification)가 반드시 호환되어야 한다

 

C++11에는 다음과 같은 조건 하나가 추가되어야 한다

- 멤버 함수들의 참조 한정사(reference qualifier)들이 반드시 동일해야 한다. 이 기능을 이용하면 멤버 함수를 왼값에만 또는 오른값에만 사용할 수 있게 제한할 수 있다

- 가상 함수가 아닌 멤버 함수에도 멤버 함수 참조 한정사를 적용할 수 있다

class Widget{
public:
	...
    void doWork() &; // doWork의 이 버전은 *this가 왼값일 때에만 적용할 수 있다
    void doWork() &&; // doWork의 이 버전은 &this가 오른값일 때에만 적용할 수 있다
};

Widget makeWidget(); // 팩터리 함수(오른값을 돌려줌)
Widget w; // 보통 객체(왼값을 돌려줌)

...

w.doWork(); // 왼값용 Widget::doWork(즉, Widget::doWork &)를 호출한다
makeWidget().doWork(); // 오른값용 Widget::doWork(즉, Widget::doWork &&)를 호출한다

만일 기반 클래스의 가상 함수에 참조 한정사가 있으며, 그 함수를 재정의하는 파생 클래스의 해당 함수에도 정확히 같은 참조 한정사가 있어야 한다는 점만 기억해야 한다.

 

C++11은 파생 클래스 함수가 기반 클래스의 버전을 재정의하려 한다는 의도를 명시적으로 표현하는 방법을 제공한다. 바로, 그런 파생 클래스 함수를 override로 선언하는 것이다.

- 그렇게 한다면 컴파일러는 재정의 관련 문제점들을 모두 지적해준다.

 

파생 클래스의 모든 재정의 함수를 override로 선언한다는 방침은, 재정의를 의도한 함수가 실제로는 아무것도 재정의하지 않는다는 점을 컴파일러가 지적해 주는 것 이상의 장점을 제공한다.

 

C++11은 문맥 의존 키워드(contextural keyword) override와 final을 추가했다

이 키워드들은 오직 특정한 문맥에서만 예약어로 작용한다는 특성을 가지고 있다.

override는 멤버 함수 선언의 끝에 나올 때에만 예약된 의미를 가진다.(가상 함수의 의미를 가진다)

- 따라서, override라는 이름을 사용하는 구식 코드가 남아 있다고 해도, C++11을 위해 그 이름을 변경할 필요는 없다

class Warning{ // C++98 시절에 있었을 클래스
public:
	...
    void override(); // C++98과 C++11 모두에서 유효함(의미도 같음)
    ...
};

 

왼값 인수만 받는 함수를 작성하고 싶다면, 비 const 왼값 참조 매개변수를 선언하면 된다.

void doSomething(Widget& w); // 왼값 Widget만 받는 함수

 

오른값 인수만 받는 함수를 작성하고 싶다면, 오른값 참조 매개변수를 선언하면 된다

void doSomething(Widget&& w); // 오른값 Widget만 받는 함수

 

class Widget{
public:
	using DataType = std::vector<double>;
    ...
    DataType& data() {return values;}
    ...
    
private:
	DataType values;
};

Widget w;
...
auto vals1=w.date(); // w.values를 vals1에 복사

- Widget::data의 반환 형식은 왼값 참조이고 왼값 참조는 정의상 왼값으로 취급하므로, 이 코드는 하나의 왼값으로  vals1을 초기화한다. 즉, vals1은 w.value로부터 복사 생성된다.

 

Widget makeWidget();
auto vals2=makeWidget().data(); // Widget 안에 있는 values를 vals2에 복사

- Widget::data는 왼값 참조를 돌려주며, 언제나 왼값 참조는 하나의 왼값이므로, vals2는 Widget 안의 values로부터 복사 생성된다. 그러나 Widget이 makeWidget이 돌려준 임시 객체이다. 따라서 임시 객체 안의 복사하는 것은 시간 낭비다

- data가 왼값 참조를 돌려주기 때문에 C++의 규칙들을 준수하는 컴파일러는 반드시 복사 연산을 위한 코드를 작성해야 한다.

 

오른값에 대해 호출된 경우에는 반드시 오른값을 돌려주게 하는것이다. data를 왼값 Widget과 오른값 Widget에 대해 개별적으로 오버로딩하면 그런 일이 가능하다

class Widget{
public:
	using DataType=std::vector<double>;
    ...
    DataType& data() & {return values;} // 왼값 Widget에 대해서는 왼값을 반환
    DataType&& data() && {return std::move(values);} // 오른값 Widget에 대해서는 오른값을 반환
    ...
private:
	DataType values;
};

 

왼값 참조 오버로딩은 왼값 참조를 돌려주고 오른값 참조 오버로딩은 오른값 참조를 돌려준다. 

auto vals1=w.data() // Widget::data의 왼값 오버로딩을 호출; vals1은 복사 생성됨
auto vals2=makeWidget().data(); // Widget::data의 오른값 오버로딩을 호출; vals2은 이동 생성됨

 

항목 13: iterator보다 const_iterator를 선호하라

const_iterator는 const를 가리키는 포인터의 STL버전이다.

const_iterator는 수정하면 안되는 값들을 가리킨다

반복자가 가리키는 것을 수정할 필요가 없을 때에는 항상 const_iterator를 사용하는 것이 바람직하다

std::vector<int> values;
...
std::vector<int>::iterator it = std::find(values.begin(), values.end(), 1983);
values.insert(it, 1998);

- 위는 iterator가 가리키는 것을 전혀 수정하지 않는다. 하지만 C++98에서는 const_iterator를 사용하도록 코드를 고치는 것은 간단한 일이 아니다

typedef std::vector<int>::iterator IterT;
typedef std::vector<int>::const_iterator ConstIterT;

std::vector<int> values;
...
ConstIterT ci = std::find(static_cast<ConstIterT>(values.begin()),
						  static_cast<ConstIterT>(values.end()), 1983);

values.insert(static_cast<IterT>(ci), 1998); // 컴파일이 안될 수 있음

- typedef가 꼭 필요한 것은 아니나, 이들 덕분에 코드의 캐스팅을 좀 더 쉽게 작성할 수 있다

- std::find 호출에서 정적 캐스팅들을 사용하는 것은, values가 비const 컨테이너이며 C++98에서는 비const 컨테이너로부터 const_iterator를 얻는 간단한 방법이 없었기 때문이다

- 꼭 캐스팅은 아니어도 다른 방법들이 존재하긴하지만 C++98에서는 삽입(그리고 삭제) 위치를 iterator로만 지정할 수 있었기 때문에 const_iterator는 허용되지 않았다

 - 앞의 코드에서 const_iterator를 다시 iterator로 캐스팅한 것은 바로 그 때문이다. 

- 캐스팅해도 const_iterator에서 iterator로의 이식성 있는 변환은 존재하지 않기 때문에 컴파일되지 않는다

 

C++11에서는 const_iterator를 얻기도 쉽고 사용하기도 쉽다

컨테이너 멤버 함수 cbegin과 cend는 const_iterator를 돌려준다. 심지어 비const 컨테이너도 그렇다

삽입, 삭제 위치를 지정하는 목적으로 반복자를 사용하는 STL 멤버 함수들은 실제로 const_iterator를 사용한다. 

std::vector<int> values;
...
auto it = std::find(values.cbegin(), values.end(), 1983);
values.insert(it,1983);

- const_iterator를 사용하는 실용적인 코드다

 

일반성을 극대화한 코드는 특정 멤버 함수의 존재를 요구하는 대신 그 멤버 함수에 상응하는 비멤버 함수를 사용한다

template<typename C, typename V>
void findAndInsert(C& container, const V& targetVal, const V& insertVal)
// container에서 targetVal의 첫 출현을 찾고, 그 위치에 insertVal을 삽입한다
{
	using std::cbegin;
    using std::cend;
    
    auto it = std::find(cbegin(container), cend(container), targetVal); // 비멤버 cbegin, cend
    container.insert(it, insertVal);
}

- 이 템플릿은 C++14에서는 잘 작동하지만, C++11에서는 그렇지 않다. C++11 표준화 과정에서 비멤버 함수 begin과 end는 표준에 추가했지만 cbegin과 cend, rbegin, rend, crbegin, crend는 빼먹고 추가하지 않았기 때문이다

 

C++11로 최대한 일반적인 코드를 작성하고 싶다면 아래와 같이 적어야 한다.

template<class C>
auto cbegin(const C& container)->decltype(std::begin(container))
{
	return std::begin(container);
}

- 이 cbegin 템플릿은 컨테이너 같은 자료구조를 대표하는 임의의 인수 형식 C를 받고, 해당 const 참조 매개변수 container를 통해서 그 자료구조에 접근한다

- 만일 C가 통상적인 컨테이너 형식이면 container는 그 컨테이너의 const버전에 대한 참조가 된다

- 그러한 const 컨테이너에 대해 비멤버 begin함수를 호출하면 const_iterator 형식의 반복자가 반환된다

- 이 템플릿이 돌려주는 것이 바로 그 반복자이다

- 이런 구현 방식의 장점은 begin 멤버 함수를 제공하지만 cbegin 멤버 함수는 제공하지 않는 컨테이너에 대해서도 작동한다는 것이다.

 - 즉, begin만 직접적으로 제공하는 컨테이너에 대해서도 이 멤버 cbegin을 사용할 수 있다

- 이 템플릿은 또한 C가 내장 배열 형식일 때에도 작동한다. 

- 그런 경우 container는 const 배열에 참조가 된다

- C++11은 내장 배열에 특화된 버전의 비멤버 begin을 제공한다

- 그 버전은 주어진 배열의 첫 원소를 가리키는 포인터를 돌려준다

- const_iterator는 const를 가리키는 포인터다.