z 'C++공부/Effective Modern C++' 카테고리의 글 목록 :: C++, 그래픽 기술 블로그

41. Consider pass by value for copyable parameters that are cheap to move and always copied

 효율성 좋은 코드를 만들기 위해서는 왼값 인수는 복사하되, 오른값 인수는 인동시키는 것이 바람직합니다. 이를 그냥 코드로 짜게 되면은 두개의 함수가 필요하며 이를 유지보수 및 object file 코드의 비대화도 걱정이 됩니다. 따라서 보편 참조를 활용한 템플릿을 사용하게 되면 이를 해결할 수 있습니다. 하지만  다루어야할 소스 코드는 줄어들지만, 앞선 항목들에서 본 것처럼 많은 문제들을 야기합니다.

class Widget {
public:
  void addName(std::string newName)
  { names.push_back(std::move(newName)); }
  ...
};

 위 코드는 어떠한 문제도 발생하지 않지만 newName이 복사 생성되기에 이동 생성되는 경우에 비해 성능이 떨어집니다.실제로 C++98에서는 복사만이 지원하여 비용이 컸지만 C++11 로 오면서 왼값일 때에만 복사 생성되고, 오른값일 때에는 이동 생성됩니다. 매개변수 전달 방법을 3가지로 나누어 성능을 비교해보자.

// Overloading
class Widget {
public:
  void addName(const std::string& newName)
  { names.push_back(newName); }
  
  void addName(std::string&& newName)
  { names.push_back(std::move(newName); }
  
  ...
  
  private:
    std::vector<std::string> names;
};

// 보편 참조
class Widget {
public:
  template<typename T>
  void addName(T&& newName)
  { names.push_back(std::forward<T>(newName)); }
  
  ...
};

// 값 전달
class Widget {
public:
  void addName(std::string newName)
  { names.push_back(std::move(newName)); }
  
  ...
};

[Overloading]

 왼값과 오른값 모두 참조로 전달되므로 인수로 전달되는 비용은 없습니다. 함수 내에서 왼값의 경우에는 복사 1회가 수행되고 오른값의 경우에는 이동 1회 수행됩니다.

[보편 참조]

 오버로딩과 연산 시점과 비용이 똑같습니다. 함수로 전달하는 비용이 없고 본문에서 복사와 이동이 일어납니다. 만일 std::string 이 아닌 타입의 경우에는 std::string 의 복사와 이동이 0회 이상 일어날 수 있습니다. 그러나 여기서는 std::string 의 경우에만 생각합니다.

[값 전달]

 앞서 설명한대로 왼값과 오른값으로 나누어 생각해야 합니다. 왼값의 경우에는 인수로 전달하는데 복사 1회가 일어나고, 오른값의 경우에는 이동 1회가 발생합니다. 함수 본문에서는 두 가지 경우 모두 이동 1회가 발생합니다.

 

 성능의 비교로 알아보았듯이 값 전달이 장점만 있는 것은 아닙니다. 성능 뿐만 아니라 이동 전용 형식의 경우에는 우리가 값 전달을 고려할 필요가 없습니다. 오른값으로 매개변수를 받도록 만들었다면 인수를 전달하는데 드는 비용이 없기 때문입니다. 또한 만일 addName 함수 내에 조건문에 따라 삽입을 하지 않는다면 쓸데없는 복사나 이동 연산을 하게 되는 것입니다.

 다시 "이동이 저렴하고 항상 복사되는 복사 가능 매개변수에 대해서는 값 전달을 고려하라"라는 문장을 해석해보겠습니다.

  1. 값 전달을 사용하라가 아니라 고려하라 입니다.
  2. 복사 가능 매개변수에 대해서만 값 전달을 고려해야 합니다. 이동만 가능한 경우에 값 전달을 하게 되면 이동 2회의 비용으로 보편 참조의 경우의 이동 1회보다 비용이 비싸집니다.
  3. 이동이 저렴한 경우에 대해서만 고려해야 합니다.
  4. 값 전달은 항상 복사되는 매개변수에 대해서만 고려해야 합니다.

 복사에는 생성을 위한 복사와 배정을 위한 복사가 존재합니다. '=' 연산을 사용하는 경우를 배정문이라고 배정을 통해 복사하는 함수의 경우에는 좀 더 복잡해집니다.

class Password {
public:
  explicit Password(std::string pwd)
  : text(std::move(pwd)) {}
  
  void changeTo(std::string newPwd)
  { text = std::move(newPwd): }
  
  ...
  
private:
  std::string text;
};

 위 코드는 패스워드를 담고있는 객체입니다. 생성자에서는 우리가 앞서 본 그대로입니다. 배정문에서는 인수로 전달되는 것은 왼값이나 오른값에 따라 다른 것은 분명합니다. 문제는 배정문을 수행할 때 기존에 저장되어 있던 메모리를 해제하고 새로운 객체를 이동합니다. 또한 만일 기존에 저장된 메모리가 새로 배정되는 메모리보다 크다면 굳이 메모리를 해제할 필요가 없습니다. 만약 우리가 값 전달이 아닌 중복적재 방식을 이용했다면 할당과 해제를 생략할 수 있습니다.

class Password {
public:
  ...
  
  void changeTo(const std::string& newPwd)
  {
    text = newPwd;
  }
  
  ...
private:
  std::string text;
};

 이 이외에도 값 전달은 slice off 문제가 발생할 수 있습니다. 다형성과 관련된 문제로 포인터 타입의 경우에는 이런 문제가 발생하지 않습니다. 값 전달을 사용하기에는 위험한 점이 너무나도 많기 때문에 주의해서 사용해야 합니다.

이것만은 잊지 말자!

  • 이동이 저렴하고 항상 복사되는 복사 가능 매개변수에 대해서는 값 전달이 참조 전달만큼이나 효율적이고, 구현하기가 더 쉽고, 산출되는 목적 코드의 크기도 더 작다.
  • 좌측값 인수의 경우 값 전달(즉, 복사 생성) 다음의 이동 배정은 참조 전달 다음의 복사 배정보다 훨씬 비쌀 가능성이 있다.
  • 값 전달에서는 잘림 문제가 발생할 수 있으므로, 일반적으로 기반 클래스 매개변수 형식에 대해서는 값 전달이 적합하지 않다.​

42. Consider emplacement instead of insertion

std::vector<std::string> vs;
vs.push_back("xyzzy"); // vs.push_back(std::string("xyzzy"));
vs.emplace_back("xyzzy");

 위 코드에서 함수로 전달되는 타입은 std::string 이 아닌 문자열 리터럴입니다. 이는 vector의 타입과는 맞지 않지만 std::string으로의 암시적 변환이 가능합니다. 따라서, 컴파일러는 형식 불일치를 인지하고 문자열 리터럴의 형식을 std::string으로 변환하여 push_back을 수행합니다. 이는 임시 std::string 객체가 생성하고 그 후에 벡터에 집어넣을 때 객체를 한번 더 생성하게 되며 임시 객체에 대해서는 소멸자가 호출되는데 이는 상당히 비효율적입니다.

 이에 따라 등장한 것이 emplace_back으로 삽입할 객체가 아닌 삽입할 객체의 생성자를 위한 인수를 받습니다. 임시 std::string 객체를 생성하지 않고 함수 안에 들어가서 std::string 객체를 생성 후에 집어넣게 됩니다. 심지어 문자열 리터럴이 아닌 경우에도 push_back 과 똑같은 역할을 하며, 때로는 더 효율적으로 동작하기도 합니다. 하지만 이는 항상 emplace_back이 좋다는 것을 의미하지는 않습니다. 삽입 함수가 더 빠르게 실행되는 상황도 존재합니다. 아래의 세 조건이 모두 성립한다면 거의 항상 생성 삽입의 성능이 삽입의 성능보다 좋습니다.

 

[컨테이너에 배정이 아닌 생성되는 경우]

 컨테이너에 원소를 추가할 때 어떠한 방식으로 추가하느냐의 차이이다. 객체 생성을 통해 추가할 수도 있고, 이동 배정을 통해 추가될 수 있다. 이동 배정을 위해서는 항상 원본 객체가 필요하다. 그래서 임시 객체가 생성되어 생성 삽입의 장점이 사라지는 것이다. 노드 기반 컨테이너들은 거의 항상 삽입을 통해 추가한다. 그 외에 컨테이너는 3가지 밖에 존재하지 않는다. (std::vector, std::deque, std::string) 이 컨테이너들의 emplace_back 의 경우에는 생성을 사용한다. 

[인수의 형식과 템플릿 형식이 다른 경우]

 앞서 보았던 내용이다. 만일 인수의 형식과 템플릿 형식이 같다면 임시 객체의 생성과 소멸이 일어나지 않기 때문에 생성 삽입이 빠를 이유가 없다.

[기존 값과의 중복을 허용하는 경우]

 std::map, std::set 과 같이 중복을 허용하지 않은 경우에는 적용되지 않는 것을 의미하며, 이들은 생성 삽입의 경우에 노드를 생성해서 중복을 체크합니다. 만약 중복이라면 노드를 파괴시키는데 이는 생성과 파괴 비용을 발생시킨다. 이는 삽입 함수보다 생성 삽입에서 더 자주 발생한다.

 

 이번에는 생성 삽입의 문제점을 알아보겠습니다. 커스텀 삭제자를 통해서 해제되어야 하는 객체 원소를 삽입한다고 가정한 경우make_shared 함수를 사용할 수 없기에 push_back을 이용하여 넣는 경우는 다음과 같습니다.

std::list<std::shared_ptr<widget>> ptrs;
void killWidget(Widget* pWidget);

// 첫번째 방식
ptrs.push_back(std::shared_ptr<Widget>(new Widget, killWidget));

// 두번째 방식
ptrs.push_back({ new Widget, killWidget });

 첫번째와 두번째 방식 모두 임시 객체가 생성되며, 만약 메모리 부족으로 예외가 발생한 경우에는 임시 객체가 소멸되어도 메모리 누수를 발생 시키지 않습니다. 하지만 emplace_back의 경우는 다르게 작동합니다.

ptrs.emplace_back(new Widget, killWidget);

 new Widget 은 오른값이기 때문에 생성된 포인터가 emplace_back 안으로 완벽 전달됩니다. 이제 메모리가 부족하여 예외가 발생한 경우에는 new Widget에 대한 메모리를 해제 해주어야 하는데 이에 대한 포인터가 사라지며 메모리 누구사 생깁니다. 위와 같은 문제를 해결하기 위해서는 외부에서 객체를 생성해서 함수로 전달하는 방법으로 해결할 수 있습니다.

std::shared_ptr<Widget> spw(new Widget, killWidget);

ptrs.push_back(std::move(spw));
ptrs.emplace_back(std::move(spw));

이 외의 다른 문제로는 정규표현식을 사용하는 경우가 있습니다.

std::vector<std::regex> regexes;

regexes.emplace_back(nullptr);  // 정상 동작

regexes.push_back(nullptr);     // 컴파일 에러

 정규표현식들을 저장하는 컨테이너를 생성하였는데 이는 emplace_back과 push_back은 서로 다른 양상을 보입니다. 정규표현식의 생성자를 먼저 알아보아야 합니다. 정규표현식의 생성자 중에서 const char* 를 받는 생성자가 explicit 으로 선언되어 있습니다. 따라서 다음과 같은 코드는 정상 동작합니다.

std::regex r = nullptr;        // 컴파일 에러

regexes.push_back(nullptr);    // 컴파일 에러

std::regex r(nullptr);         // 컴파일 성공

 생성 삽입의 경우에는 첫번째 코드를 수행하는 것과 마찬가지이기 때문에 컴파일 에러가 발생하는 것이고 삽입 생성의 경우에는 마지막 라인을 수행하는 것이여서 컴파일에 성공합니다. 

이것만은 잊지 말자!

  • 이론적으로, 생성 삽입 함수들은 종종 해당 삽입 버전보다 더 효율적이어야 하며, 덜 효율적인 경우는 절대로 없어야 한다.
  • 실질적으로, 만일 (1) 추가하는 값이 컨테이너로 배정되는 것이 아니라 생성되고, (2) 인수 형식(들)이 컨테이너가 담는 형식과 다르고, (3) 그 값이 중복된 값이어도 컨테이너가 거부하지 않는다면, 생성 삽입 함수가 삽입 함수보다 빠를 가능성이 아주 크다.
  • 생성 삽입 함수는 삽입 함수라면 거부당했을 형식 변환들을 수행할 수 있다.​

35. Prefer task-based programming to thread-based

doAsyncWork라는 함수를 비동기적으로 실행하는 경우 방법은 크게 두 가지로, 하나는 std::thread 객체를 생성해서 그 객체에서 doAsyncWork를 실행하는 스레드 기반 프로그래밍과 doAsyncWork를 std::async에 넘겨주는 태스크 기반 프로그래밍으로 나뉘어 집니다. 이 둘의 근본적인 차이는, 태스크 기반 접근 방식은 좀 더 높은 수준의 추상을 체현한다는 점이며, 이를 통해 세부적인 스레드 관리에서 벗어날 수 있습니다.

 스레드는 크게 3가지 의미로 쓰일 수 있습니다.

  • 하드웨어 스레드 - 실제 계산을 수행하는 스레드
  • 소프트웨어 스레드 - 운영체제가 하드웨어 스레드들에서 실행되는 모든 프로세서와 일정 관리를 하는데 사용되는 스레드
  • C++ 표준 라이브러리의 std::thread - 하나의 C++ 프로세스 안에서 바탕 소프트웨어 스레드에 대한 핸들로 작용하는 스레드 이는 어떤 소프트웨어 스레드에도 대응되지 않을 수 있습니다.

 소프트웨어 스레드는 제한된 자원이므로, 이는 실행하려는 함수가 예외를 던질 수 없는 경우에도 한계 이상의 소프트웨어 스레드를 생성하려고 하면 std::system_error 예외를 던집니다. 가용 스레드가 모자라지 않더라도 실행 준비가 된 소프트웨어 스레드가 하드웨어 스레드보다 많은 상황인 과다구독(oversubscription)이 발생하면 OS에서는 하드웨어 상의 실행 시간을 여러 조각으로 나누어서 소프트웨어 스레드에 분배하고, 소프트웨어 스레드가 시간 조각을 시작할 때 문맥 전환(context switching)이 수행됩니다. 이런 경우 CPU 캐시 히트가 잘 일어나지 않게되거나 CPU 캐시가 오염될 가능성이 높아집니다. 이를 해결하기는 어려운데, 직접 해결하기 보다는 다른 누군가에 떠넘기는 방법을 택한다면 std::async을 선택해야 합니다.

 std::async은 std::thread와는 다르게 스레드를 생성하지 않을 수 있으며, 대신 지정된 함수를 결과가 필요한 스레드에서 실행하도록 만들 수 있습니다. 이는 문제가 아예 사라지는 것은 아니지만, 컴퓨터에서 일어나는 일들의 전반적인 상을 더 잘 아는 실행 스케줄러에게 맡기게 합니다.  한가지 주의할 점은 스레드를 생성하지 않을수도 있기 때문에 반응성에 문제가 있을 수 있습니다. 이 때에는 시동 방침을 넘겨주어 반드시 스레드를 생성하도록 하는 것이 좋습니다.

 스레드 기반 프로그래밍에 비해 과제 기반 프로그래밍이 스레드들을 일일이 관리해야하는 번거러움은 없으며, 비동기적으로 실행된 함수의 결과를 자연스럽게 조회할 수 있는 수단을 제공합니다. 하지만 스레드를 직접 다루어야 하는 경우도 존재합니다.

  • 바탕 스레드(소프트웨어 스레드) 적용 라이브러리의 API에 접근해야 하는 경우
  • 응용 프로그램의 스레드 사용량을 최적화해야 하는, 그리고 할 수 있어야 하는 경우
  • C++ 동시성 API가 제공하는 것 이상의 스레드 적용 기술을 구현해야 하는 경우

이것만은 잊지 말자!

  • std::thread API에서는 비동기적으로 실행된 함수의 반환값을 직접 얻을 수 없으며, 만일 그런 함수가 예외를 던지면 프로그램이 종료된다.
  • 스레드 기반 프로그래밍에서는 스레드 고갈, 과다구독, 부하 균형화, 새 플랫폼으로의 적응을 독자가 직접 처리해야 한다.
  • std::async와 기본 시동 방침을 이용한 태스크 기반 프로그래밍은 그런 대부분의 문제를 알아서 처리해준다.

36. Specify std::launch::async if asynchronicity is essential

std::async의 호출은 비동기적으로 작업을 수행하겠다는 의도가 깔려 있지만, 그러나 std::async의 호출이 항상 그런 의미일 필요는 없습니다. std::async 호출은 함수를 어떤 시동 지침(launch policy)에 따라 실행한다는 좀 더 일반적인 의미를 가집니다. 시동 방침에는 두 가지인데, std::launch 범위의 enum에 정의된 열거자들을 이용해서 지정 가능합니다.

  • std::launch::async
    시동 방침을 지정하면 f는 반드시 비동기적으로, 다시 말해서 다른 스레드에서 실행됩니다.
  • std::launch::deferred
    시동 방침을 지정하면 f는 std::async가 돌려준 미래 객체(std::future)에 대해 get이나 wait가 호출될 때까지 지연되다가, get이나 wait가 호출되면 f는 동기적으로 실행됩니다. 즉, 호출자는 f의 실행이 종료될 때까지 차단되며, get이나 wait가 호출되지 않으면 f는 결코 실행되지 않습니다.

기본 시동 방침은 둘을 OR로 결합한 것 입니다. 다시 말하면 함수는 비동기적으로 실행될 수도 동기적으로 실행될 수도 있다. 그런데 위와 같은 특성 때문에 문제가 발생할 수 있다.

  • 함수가 지연 실행될 수도 있으므로, 비동기로 실행될지 예측이 불가능합니다.
  • 함수를 get 이나 wait 을 호출하는 스레드와 다른 스레드에서 함수가 실행될지 알 수 없습니다.
  • get이나 wait 호출이 일어난다는 보장이 없기 때문에 함수가 실행되지 않을 수 있습니다.

 이러한 유연한 스케줄링이 스레드 지역 저장소(TLS)를 읽거나 쓰는 코드가 있을때, 언제 접근하는지 예측이 불가능하기 때문에, thread_local 변수들과도 궁합이 잘 맞지 않습니다. 또한 wait 기반 루프에도 영향을 미치기도 합니다. wait_for  wait_until 을 사용하면 std::future_status::deffered 라는 값이 반환되기 때문이다. 이 때문에 무한 루프가 발생하기도 한다.

using namespace std::literals;

void f()
{
  std::this_thread::sleep_for(1s);
}

auto fut = std::async(f);

while (fut.wait_for(100ms) !=
       std::future_status::ready)
{
  ...
}

 f 가 기존 스레드와 동시에 실행되다면 문제가 발생하지 않지만 f가 지연되는 경우에는 fut.wait_for 의 결과가 항상 std::future_status::deffered를 반환하여 무한 루프가 발생하게 됩니다. 해결 방법은 std::async이 돌려준 미래 겍체를 이용해서 해당 과제가 지연되었는지 점검하고, 지연되었다면 시간 만료 기반 루프에 진입하지 않게 하는 방법입니다. 과제의 지연 여부를 미래 객체로부터 알아내는 방법은 없기에,시간 만료 기반 함수를 호출하여 확인 뒤 wait나 get을 통해 동기적으로 호출합니다. 다음과 같이 구현이 가능합니다.

auto fut = std::async(f);

if (fut.wait_for(0s) ==
    std::future_status::deffered)
{
  ...
} else {
  while (fut.wait_for(100ms) !=
         std::future_status::ready) {
    ...
  }
  
  ...
}
  

 이제 지연이 되더라도 코드는 문제 없이 동작하게 된다. 이에 따라 기본 시동 방침은 다음의 상황에서만 적합하다는 것을 알 수가 있습니다.

  • get 이나 wait 를 호출하는 스레드와 반드시 동시적으로 실행되어야 하는 것은 아닐때
  • thread_local 변수들을 읽소 쓰는지가 중요하지 않을때
  • futrure 객체에 대해 get 이나 wait 이 반드시 호출된다는 보장이 있거나 실행되지 않아도 괜찮은 경우
  • wait_for  wait_until 을 사용하는 코드에 지연 될 수 있다는 조건이 반영된 경우

 위 조건 중에서 하나라도 부합하지 않은 경우에는 반드시 비동기로 수행되도록 변경해야 하며 기본 시동방침을 std::launch::async로 사용하는 함수는 다음과 같이 작성 가능합니다.

template<typename F, typename... Ts>
inline auto reallyAsync(F&& f, Ts&&... params)
{
  return std::async(std::launch::async,
                    std::forward<F>(f),
                    std::forward<Ts>(params)...);
}
 

이것만은 잊지 말자!

  • std::async의 기본 시동 방침은 과제의 비동기적 실행과 동기적 실행을 모두 허용한다.
  • 그러나 이러한 유연성 때문에 thread_local 접근의 불확실성이 발생하고, 태스크가 절대로 실행되지 않을 수도 있고, 시간 만료 기반 wait 호출에 대한 프로그램 논리에도 영향이 미친다.
  • 태스크를 반드시 비동기적으로 실행해야 한다면 std::launch::async를 지정하라.​

37. Make std::threads unjoinable on all paths

모든 std::thread 객체는 합류 가능(joinable) 상태이거나 합류 불가능(unjoinable) 상태입니다. 합류 가능 std::thread는 백그라운드 실행 스레드 중 현재 실행 중이거나 실행 중 상태로 전이할 수 있는 스레드에 대응되며, 차단된 상태이거나 실행 일정을 기다리는 중인 백그라운드 스레드에 해당하는 std::thread 그리고 실행이 완료된 백그라운드에 해당하는 std::thread 객체도 합류 가능으로 간주됩니다. 합류 불가능 std::thread는 말 그대로 합류할 수 없는 std::thread로, 그러한 객체로는 다음과 같은 것들이 있습니다.

  • 기본 생성된 std::thread - 그런 std::thread 객체에는 실행할 함수가 없으므로 백그라운드 스레드와는 대응되지 않습니다.
  • 다른 std::thread 객체로 이동된 후의 std::thread 객체 - 이동의 결과로 원본 std::thread에 대응되는 백그라운드 스레드는 대상 std::thread의 백그라운드 스레드가 됩니다.
  • join에 의해 합류된 std::thread -  join 이후의 std::thread 객체는 실행이 완료된 백그라운드 스레드에 대응되지 않습니다.
  • detach에 의해 탈착된 std::thread - detach는 std::thread 객체와 그에 대응되는 백그라운드 사이의 연결을 끊습니다.

 스레드의 합류 가능성이 중요한 이유는 만일 합류 가능한 스레드의 소멸자가 호출되면 프로그램 실행이 종료되기 때문입니다. 만약 스레드에 대해서 join 혹은 detach를 수행해주지 않고서 스레드가 소멸되면은 문제가 생기게 된다는 의미입니다. 만약 프로그램에서 암묵적으로 join 혹은 detach를 하게 해준다면 더 상황이 나빠질 수 있습니다

[암묵적 join]

스레드의 소멸자가 스레드의 완료를 기다리게 하는 것으로 추적하기 어려운 성능 이상을 만들어 낼 수 있습니다.

[암묵적 detach]

스레드의 소멸자가 객체와 바탕 실행 스레드 사이의 연결을 끊게 만드는 것입니다. 만약 바탕 실행 스레드의 지역 변수를 스레드 객체가 참조하여 함수를 실행하고 있고, 바탕 실행 스레드가 detach로 인해 기다리지 않고 종료될 때  변수가 사라지므로, 스레드 객체에서는 문제가 발생합니다. 

 결국 두가지 경우 모두 적절하지 않으므로 프로그램을 종료하는 것으로 마무리 되었고, 이를 보완하기 위하여 std::thread를 위한 RAII 클래스인 ThreadRAII 클래스를 다음과 같이 만들어 줄 수 있습니다.

class Thread RAII {
 public:
     enum class DtorAction { join, detach };


     ThreadRAII(std::thread&& t, DtorAction a) : action(a), t(std::move(t)) {}
     ~ThreadRAII()
     { 
         if (t.joinable()) {
             if (action == DtorAction.join) t.join();
             else t.detach();
         }
     }


     ThreadRAII(ThreadRAII&&) = default;
     ThreadRAII& operator=(ThreadRAII&&) = default;


     std::thread& get() { return t; }


 private:
     DtorAction action;
     std::thread t;
 };​
  • 생성자는 std::thread 오른값만을 받습니다.
  • 멤버 초기화 목록은 자료 멤버들이 선언된 순서를 따르기에, std::thread 객체를 맨 마지막에 선언하여 해당 함수를 즉시 실행하는 경우에 대한 대응을 해주었습니다.
  • std::thread 객체에 접근 가능한 get 함수를 통해 직접 사용 가능하기에 std::thread 인터페이스를 따로 구현하지 않았습니다.
  • 소멸자에서는 std::thread 객체에 대해 합류 가능인지 부터를 확인합니다.

이것만은 잊지 말자!

  • 모든 경로에서 std::thread를 합류 불가능으로 만들어라.
  • 소멸시 join 방식은 디버깅하기 어려운 성능 이상으로 이어질 수 있다.
  • 소멸시 detach 방식은 디버깅하기 어려운 미정의 행동으로 이어질 수 있다.
  • 자료 멤버 목록에서 std::thread 객체를 마지막에 선언하라.​

38. Be aware of varying thread handle destructor behavior

합류 가능 std::thread는 백그라운드 시스템의 실행 스레드에, 지연되지 않은 과제에 대한 미래 객체는 시스템 스레드에 대응됩니다. 따라서 std::thread 객체와 std::future 객체 모두 시스템 스레드에 대한 핸들이라고 할 수 있다.

 하지만 이 둘의 객체 소멸자들은 다르게 행동합니다. 합류 가능 std::thread를 파괴하면 프로그램이 종료되지만, 미래 객체의 소멸자는 어떨 때에는 마치 암묵적으로 join을 수행한 것 같은 결과를 내고 어떨 때에는 마치 암묵적으로 detach를 수행한 것 같은 결과를 내며 프로그램이 종료되지는 않습니다.

 미래 객체는 피호출자가 결과를 호출자에게 전송하는 통신 채널의 한쪽 끝에 해당합니다. 피호출자의 결과 저장 장소가 고민이 될 수 밖에 없는데, 호출자의 미래 객체에는 std::shared_future로 결과의 소유권이 넘어가거나, 복사되지 않는 형식이 존재할 수도 있는 문제로 인해 불가능하며, 피호출자에 연관된 std::promise 객체는 피호출자의 지역 범위에 있으며, 피호출자가 완료되면 파괴되므로 불가능합니다.

 호출자에 연관된 객체도, 피호출자에 연관된 객체도 피호출자의 결과를 담기에는 적합하지 않아 바깥에 있는 장소인 바로 공유된 상태(shared state), 줄여서 공유 상태에 담게 됩니다. 일반적으로 공유 상태는 힙 기반 객체로 표현되나, 그 형식과 인터페이스, 구현은 표준이 구체적으로 명시하지 않습니다. 이러한 공유 상태의 존재가 중요한 것은, 미래 객체 소멸자의 행동을 그 미래 객체와 연관된 공유 상태가 결정하기 때문입니다.

  • std::async를 통해서 시동된 비지연 태스크에 대한 공유 상태를 참조하는 마지막 미래 객체의 소멸자는 태스크가 완료될 때까지 차단됩니다. 본질적으로, 그런 미래 객체의 소멸자는 태스크가 비동기적으로 실행되고 있는 스레드에 대해 암묵적인 join을 수행합니다.
  • 다른 모든 미래 객체의 소멸자는 그냥 해당 미래 객체를 파괴합니다. 비동기적으로 실행되고 있는 태스크의 경우 이는 백그라운드 스레드에 암묵적 detach를 수행하는 것과 비슷합니다. 지연된 과제를 참조하는 마지막 미래 객체의 경우 이는 그 지연된 태스크가 절대로 실행되지 않음을 뜻합니다.

즉 소멸자는 바탕 스레드를 join도 detach도 그 무엇도 실행하지 않습니다. 그냥 미래 객체의 자료 멤버들만 파괴합니다. 이러한 행동에 대한 예외는 다음 조건을 모두 만족하는 미래 객체에 대해서만 일어납니다.

  • 미래 객체가 std::async 호출에 의해 생성된 공유 상태를 참조합니다.
  • 과제의 시동 방침이 std::launch::async입니다.
  • 미래 객체가 공유 상태를 참조하는 마지막 미래 객체입니다.

이 조건을 모두 만족하면 미래 객체의 소멸자는 비동기적으로 살행되는 과제가 완료될 때까지 소멸자의 실행을 차단시킵니다. 이는 std::async로 생성된 과제를 실행하는 스레드에 대해 암묵적 join을 호출하는 것과 동일합니다.

 미래 객체가 std::async 호출에 의해 생긴 공유 상태를 참조하는 지를 판단할 수 있는 수단이 제공되지 않으므로, 임의의 미래 객체에 대한 소멸자가 비동기적으로 실행되는 과제의 완료를 기다리느라 차단될 것인지를 알아내는 것은 불가능합니다. 물론 주어진 미래 객체가 특별한 소멸자 행동을 유발하는 조건들을 만족하지 않음을 미리 알 수 있다면 미래 객체의 소멸자가 차단되는 일이 없을 것을 확신할 수 있습니다.

{
  std::packaged_task<int()> pt (calcValue);
  
  auto fut = pt.get_future();
  std::thread t(std::move(pt));
  ...
};

여기서 주목할 코드는 ...인데 어떤 일이 일어나는 지를 크게 3가지로 나누어 생각해보겠습니다.

  • t에게 아무 일도 일어나지 않으면 t는 합류 가능한 스레드이므로 프로그램이 종료됩니다.
  • t에 대해 join을 수행하면 fut의 소멸자에서 join을 수행할 필요가 없어지므로, 차단될 이유가 없습니다.
  • t에 대해 detach를 수행하면, fut의 소멸자에서 그것을 수행할 필요가 없습니다.

따라서 std::packaged_task에 의해 만들어진 공유 상태를 참조하는 미래 객체가 존재한다면, std::thread를 조작하는 코드에서 내려지기 때문에 소멸자의 특별한 행동을 고려한 코드를 작성할 필요가 없습니다.

이것만은 잊지 말자!

  • 미래 객체의 소멸자는 그냥 미래 객체의 자료 멤버들을 파괴할 뿐이다.
  • std::async를 통해 시동된 비지연 태스크에 대한 공유 상태를 참조하는 마지막 미래 객체의 소멸자는 그 ​태스크가 완료될 때까지 차단된다.

39. Consider void futures for one-shot event communication

 스레드들 간의 이러한 스레드간 통신을 처리하는 방식 중 하나는 조건 변수(condition variable)를 사용하는 것입니다. 조건을 검출하는 태스크를 검출 태스크(detecting task)라 부르고 그 조건에 반응하는 태스크를 반응 태스크(reacting task)라고 부르기로 할 때, 조건변수를 이용한 전략은 간단합니다. 

std::condition_variable cv;
std::mutex m;

// 검출 과제
{
  ...
  cv.notify_one();
}

// 반응 과제
{
  ...
  {
    std::unique_lock<std::mutex> lk(m);
    cv.wait(lk);
    ...
  }
  ...
}

검출 태스크 쪽의 코드는 아주 간단한 구조이며, 반응 태스크의 코드는 이보다 조금 복잡한데, 이는 조건변수에 대해 wait를 호출하기 전에 먼저 std::unique_lock 객체를 통해서 mutex를 잠가야 하기 때문입니다. 이 코드가 잘 작동하긴 하지만, 검출 과제와 반응 과제에 그런 접근 제어가 필요 없는 경우에도 mutex가 사용된다는 점이 설계에 문제가 있음을 암시합니다. 더욱이 반드시 처리해야 할 문제점이 두 가지 더 존재합니다.

  • 만일 반응 태스크가 wait를 실행하기 전에 검출 태스크가 조건 변수를 통지하면 반응 태스크는 그 통지를 놓치게 되며, 그러면 영원히 통지를 기다리게 됩니다.
  • wait 호출문은 가짜 기상(spurious wakeup)을 고려하지 않는다. 가짜 기상 문제를 제대로 처리하려면, 기다리던 조건이 정말로 발생했는지 확인해야 하며, 깨어난 후 가장 먼저 하는 일이 바로 그러한 확인이어야 합니다. 과제 간 통신을 조건 변수를 이용해서 수행하는 것이 적절한 경우가 많기는 하지만 적어도 지금 예는 이 경우에 해당하지 않습니다.하지만 이는 검출 과제의 몫이며 반응 과제는 판단하지 못할 것입니다.

이를 해결하기 위해 두가지 방법을 소개 합니다.

[bool 플래그]

 일반적인 코드의 경우는 flag가 true가 될 때까지 무한 루프를 돌게 됩니다. 즉, 쓸데없는 자원 낭비를 한다는 의미이다. 이를 방지하기 위해서는 wait을 std::condition_variable과 함께 사용하면 됩니다.

std::condition_variable cv;
std::mutex m;

bool flag(false);

{
  ...
  {
    std::lock_guard<std::mutex> g(m);
    flag = true;
  }
}

{
  ...
  {
    std::unique_lock<std::mutex> lk(m);
    cv.wait(lk, [] { return flag; });
    ...
  }
  ...
}

 위와 같은 방법으로는 지속적인 폴링도 일어나지 않으며, 가짜 기상이 발생하더라도 wait 상태를 유지하여 이전의 문제는 해결하였지만 mutex 를 사용해야 한다는 점과 bool 변수까지 써야한다는 점은 무언가 깔끔하지 않습니다. 또한 반응 과제에서 wait 이 먼저 호출되어야 한다는 점은 변하지 않았습니다.

[std::promise]

 미래 객체를 통해 검출 과제와 반응 과제에 대한 통신을 하면 앞선 항목에서 살펴보았던 문제를 모두 해결할 수 있습니다. 이때 미래 객체에 대한 설정을 알릴 뿐 값을 주지는 않으므로 std::promise<void>를 사용하면 됩니다. 

std::promise<void> p;

// 검출 과제
p.set_value();

// 반응 과제
p.get_future().wait();

 주의해야할 점은 힙 메모리 관리를 해야한다는 점과 공유 상태가 존재한다는 점입니다. 다른 단점으로는 std::promise를 한 번만 설정할 수 있다는 점이 있습니다. 즉, std::promise와 미래 객체 사이의 통신 채널은 여러 번 되풀이해서 사용할 수 없는 단발성 매커니즘입니다. 이는 여러 번 통신에 사용할 수 있는 조건변수 설계나 플래스 기반 설계와의 주목할 만한 차이점입니다. 스레드를 한 번만 유보한다면, void 미래 객체를 이용하는 설계가 합리적인 선택입니다.

std::promise<void> p;

// 반응 과제
void react();

// 검출 과제
void detect()
{
  ThreadRAII tr(
    std::thread([]
                {
                  p.get_future().wait();
                  react();
                }),
    ThreadRAII::DtorAction::join
  );
  ...
  p.set_value();
  ...
}

더 나아가 반응 과제가 여러 개이어도 문제가 없습니다.

std::promise<void> p;

void detect()
{
  auto sf = p.get_furue().share();
  
  std::vector<std:;thread> vt;
  
  for (int i = 0; i < threadsToRun; ++i) {
    vt.emplace_back([sf]{ sf.wait();
                          react(); });
  }
  
  ...
  p.set_value();
  ...
  
  for (auto& t : vt) {
    t.join();
  }
}

 

이것만은 잊지 말자!

  • 간단한 이벤트 통신을 수행할 때, 조건변수 기반 설계에는 여분의 뮤텍스가 필요하고, 검출 태스크와 반응 태스크의 진행 순서에 제약이 있으며, 이벤트가 실제로 발생했는지를 반응 태스크가 다시 확인해야 한다.
  • 플래그 기반 설계를 사용하면 그런 단점들이 없지만, 대신 차단이 아니라 폴링이 일어난다는 단점이 있다.
  • 조건변수와 플래그를 조합할 수도 있으나, 그런 조합을 이용한 통신 메커니즘은 필요 이상으로 복잡하다.
  • std::promise와 미래 객체를 사용하면 이러한 문제점들을 피할 수 있지만, 그런 접근방식은 공유 상태에 힙 메모리를 사용하며, 단발성 통신만 가능하다.​

40. Use std::atomic for concurrency, volatile for special memory

 

 std::atomic 은 원자성을 보존해주기 위해서 사용되며, volatile 은 컴파일러가 최적화하며 특정 라인을 수행하지 않는 경우가 없도록 해줍니다.

 

std::atomic<int> int ai(0);
ai = 10;
++ai;
--ai;

std::cout << ai;

atomic<int>를 사용하면 문장들을 실행하는 동안 ai를 원자적으로 계산하게 됩니다. 여기서 주목할 점이 두개가 있는데, std::atomic<int>가 보장하는 것은 ai의 읽기가 원자적일 뿐 전체 문장이 원자적이지는 않다는 점입니다. 두번째로는, 증가 연산 및 감소 연산은 읽기-수정-쓰기 연산이지만 각각 원자적으로 수행해준다는 점입니다.

volatile int vi(0);
++vi;
--vi;

std::cout << vi;

 volatile을 사용하는 코드의 경우 다중 스레드 문맥에서 거의 아무것도 보장하지 않습니다. 이 코드를 실행하는 동안 vi 값은 -12, 68과 같은 다양한 값으로 관측가능합니다.위 코드의 출력 결과는 당연히 0이며 컴파일러에서 최적화를 하며 이 둘이 서로 사라질 수 있습니다. 그런데 만약 임베디드나 특정한 상황에서 저 계산식이 사라지면 안된다면, 해당 코드에 대해 컴파일러의 최적화를 막기 위하여 volatile을 사용합니다. 하지만 이는 data race 문제의 해결을 의미하지는 않습니다. 반대로 std::atomic에서는 무의미한 계산을 무시할 수 있습니다.

 이와 같이 동시성과 관련해서는 std::atomic은 성공하지만 volatile은 실패하는 상황이 존재합니다.

 또다른 차이점으로는 코드 재배치라는 컴파일러 최적화가 있습니다. 컴파일러는 코드 라인의 순서를 임의대로 바꾸어 레지스터에 적재하고 내리는데 사용하는 시간을 최적화시킵니다. 그런데 std::atomic을 사용하면 코드의 순서 재배치에 대한 제약들이 생기며, 그런 제약 중 하나는, 소스 코드에서 std::atomic 변수를 기록하는 문장 이전에 나온 그 어떤 코드도 그 문장 이후에 실행되지 않아야 한다는 것입니다. (이는 순차적 일관성(sequential consistency)를 사용하는 std::atomic에만 해당한다.)

 이와 같이 남아도는 적재와 죽은 저장을 효율적인 코드로 변모시켜 효율성을 올리는 작업을 컴파일러에서 수행을 합니다.

 volatile은 특별한 메모리를 다룰 때 사용이되며, 이러한 메모리는 최적화를 수행하지 않습니다.

 간단히 말하자면, volatile은 volatile이 적용된 변수가 사용하는 메모리가 보통의 방식으로 행동하지 않는다는 점을 컴파일러에게 알려주는 역할을 한다. 즉, 컴파일러는 이를 "이 메모리에 대한 연산들에는 그 어떤 최적화도 수행하지 말라"는 지시라고 생각한다.

 

 좀 더 std::atomic 의 특징들을 살펴보자. std::atomic 은 복사와 이동 연산을 지원하지 않는다. 그 이유는 간단하다. 원자성을 보존해야 하는게 주 목적인데 복사를 위해 읽고 기록하는 작업을 원자적으로 계산하는 하드웨어는 지원하지 않기 때문이다. 다행히 복사를 하는 방법이 가능하다.

std::atomic<int> y(x.load());
y.store(x.load());

 

이것만은 잊지 말자!

  • std::atomic은 뮤텍스 보호 없이 여러 스레드가 접근하는 자료를 위한 것으로, 동시적 소프트웨어의 작성을 위한 도구이다.
  • volatile은 읽기와 기록을 최적화로 제거하지 말아야 하는 메모리를 위한 것으로, 특별한 메모리를 다룰 때 필요한 도구이다.​

람다와 관련된 용어들 정리

  • 람다 표현식(lambda expression)은 이름 그대로 하나의 표현식으로, 소스 코드의 일부입니다.
  • 클로저(closure)는 람다에 의해 만들어진 실행시점 객체입니다. 갈무리 모드에 따라, 클로저가 갈무리된 자료의 복사본을 가질 수도 있고 그 자료에 대한 참조를 가질 수도 있습니다.
  • 클로저 클래스는 클로저를 만드는 데 쓰인 클래스를 말합니다. 각각의 람다에 대해 컴파일러는 고유한 클로저 클래스를 만들어 냅니다. 람다 안의 문장들은 해당 클로저 클래스의 멤버 함수들 안의 실행 가능한 명령들이 됩니다.

31. Avoid default capture modes

 C++의 기본 갈무리 모드는 두 가지로, 하나는 참조에 의한 갈무리 모드이며 또 하나는 값에 의한 갈무리 모드입니다. 기본 참조 갈무리에서는 참조가 대상을 잃을 위험이 존재합니다. 기본 값 갈무리도 참조가 대상을 잃는 경우가 생기며, 자기 완결적이지 못하여 문제가 발생하는 경우가 존재합니다.

 참조 갈무리를 사용하는 클로저는 지역 변수 또는 람다가 정의된 범위에서 볼 수 있는 매개변수에 대한 참조를 가져옵니다. 만약 클로저의 수명이 그 지역 변수나 매개변수의 수명보다 오래 지속되면, 클로저 안의 참조는 대상을 잃게 됩니다.

using FilterContainer =
  std::vector<std::function<bool(int)>>;
  
FilterContainer filters;

void addDivisorFilter()
{
  auto calc1 = computeSomeValue1();
  auto calc2 = computeSomeValue2();
  
  auto divisor = computeDivisor(calc1, calc2);
  
  filters.emplace_back(
    [&](int value) { return value % divisor == 0; } // 위험
  );
}

 위 코드의 람다식은 실행시점에 divisor을 결정하기 위해 갈무리 참조를 사용하였습니다. 여기서 람다는 지역 변수 divisor를 참조하는데, 이 변수는 addDivisorFilter이 반환되면 더 이상 존재하지 않고, 람다 함수는 filters에 남아 있기 때문에, 이 필터 함수는 사실상 생성 직후 부터 미정의 행동을 유발합니다. 이는 명시적으로 지정해도 똑같은 문제가 발생합니다. 클로저를 즉시 사용한다면 문제가 발생하지 않을 것으로 기대되지만 위 코드가 안전하지만 다른 코드에서는 안전성이 깨질 수 있습니다.

 만약 앞선 코드를 참조 갈무리가 아닌 기본값 갈무리를 사용한다면 문제를 해결할 수 있습니다. 하지만 기본값으로 들고오는 것이 포인터인 경우에는 지칭하는 값이 사라지는 경우에 문제가 발생합니다. 포인터를 들고 오는 경우는 잘 드러나지 않는 경우가 존재합니다.

class Widget {
public:
  ...
  void addFilter() const;
  
private:
  int divisor;
};

void Widget::addFilter() const
{
  filters.emplace_back(
    [=](int value) { return value % divisor == 0; }
  };
}

 위 코드를 살펴봤을때는 마치 문제가 없는 것처럼 보인다. 아래의 코드를 살펴보자.

void Widget::addFilter() const
{
  filters.emplace_back(
    [](int value) { return value % divisor == 0; } // 컴파일 오류
  );
}

void Widget::addFilter() const
{
  filters.emplace_back(
    [divisor](int value)                           // 컴파일 오류
    { return value % divisor == 0; }
  );
}

 이는 갈무리에 사용할 수 있는 범위는 static이 아닌 지역 변수에만 적용되기 때문입니다. divisor는 지역 변수가 아닌 클래스의 한 자료 멤버이므로 문제가 발생하는 것입니다. 기본 갈무리 모드에서는 divisor를 들고 오는 것이 아닌 this 포인터를 복사하여 내부적으로 this->divisor로 대체하여 사용하였기에 문제가 발생하지 않았습니다. 이를 해결하기 위해서는 divisor를 지역 변수로 복사해서 사용하면 됩니다.

void Widget::addFilter() const
{
  auto divisorCopy = divisor;
  
  filters.emplace_back(
    [divisorCopy](int value)
    { return value % divisorCopy == 0; }
  );
}

// C++14
void Widget::addFilter() const
{
  filters.emplace_back(
    [divisor = divisor](int value)
    { return value % divisor == 0; }
  );
}

클로저가 자기 완결적이고 클로저 바깥에서 일어나는 자료의 변화로부터 격리되어 있다고 생각하지만, 람다는 지역 변수 및 매개변수 뿐만 아니라 static을 가진 객체에도 의존할 수 있습니다.

void addDivisorFilter()
{
  static auto calc1 = computeSomeValue1();
  static auto calc2 = computeSomeValue2();
  
  static auto divisor =
    computeDivisor(calc1, calc2);
    
  filters.emplace_back(
    [=](int value)
    { return value % divisor == 0; }
  );
  
  ++divisor;
}

 앞서 말했듯이 static 이 아닌 지역변수만 캡쳐가 가능하기 때문에 위에서 사용하는 divisor는 복사본이 아닌 static 으로 선언된 divisor에 해당합니다. 

 

이것만은 잊지 말자!

  • 기본 참조 갈무리는 참조가 대상을 잃을 위험이 있다.
  • 기본 값 갈무리는 포인터(특히 this)가 대상을 잃을 수 있으며, 람다가 자기 완결적이라는 오해를 부를 수 있다.​

32. Use init capture to move objects into closures

이동 전용 객체 및 복사는 비싸고 이동은 저렴한 객체를 클로저 안으로 들여오기 위해 이동을 바라는 경우에 C++11에서는 해결 방법이 없었지만 C++14에서 도입된 새로운 갈무리인 바로 초기화 갈무리를 통해 이를 해결할 수 있습니다. 초기화 갈무리로는 C++11의 갈무리 모드들이 할 수 있는 모든 것을 할 수 있으며, 그 외의 여러 가지 것들도 할 수 있습니다. 초기화 갈무리로는 다음과 같은 것들을 지정할 수 있습니다.

  1. 람다로부터 생성되는 클로저 클래스에 속한 자료 멤버의 이름
  2. 그 자료 멤버를 초기화하는 표현식
다음은 초기화 갈무리를 이용해서 std::unique_ptr를 클로저 안으로 이동하는 예이다.
class Widget {
 public:
     ...
     bool isValidated() const;
     bool isProcessed() const;
     bool isArchived() const;
 };


 auto pw = std::make_unique<Widget>();
 ...


 auto func = [pw = std::move(pw)] { return pw->isValidated() && pw->isArchived(); };

"="의 좌변은 클로저 클래스 안의 자료 멤버(클로저에서 사용할)의 이름이고, 우변은 그것을 초기화하는 표현식입니다. 흥미로운 점은, "="의 좌변의 범위는 해당 클로저 클래스의 범위이과 우변의 범위는 람다가 정의되는 지점의 범위로 서로 다르다는 점입니다. 뿐만 아니라 직접 초기화도 가능합니다.

auto func = [pw = std::make_unique<Widget>()] { return pw->isValidated() && pw->isArchived(); };

C++11에서 위와 같은 코드가 불가능하기에 이를 수행하려면 클래스를 만들어서 수행하도록 구성해야 합니다. 또 다른 방법으로는 갈무리할 객체를 std::bind 함수가 산출하는 함수 객체로 이동시키고 그 갈무리된 객체에 대한 참조를 람다에 넘겨주는 식의 코드도 존재합니다.

std::vector<double> data;

// C++14
auto func = [data = std::move(data)] { ... };
            
// C++11
auto func = std::bind([](const std::vector<double>& data) { ... }, std::move(data));

 std::bind 함수가 돌려주는 객체를 바인드 객체라고 부르며, 첫번째 인자에는 호출 가능한 객체를, 나머지는 그 객체에 전달할 값들을 전달 해줍니다. C++14 와의 차이점이라면 매개변수에는 오른값이 전달되었지만 람다 안에서 사용할 떄는 data 의 복사본을 사용한다는 점입니다.  이로 인해 상수성의 차이가 생기게 됩니다. 그 이유는 람다로부터 만들어진 클로저 클래스의 operator() 함수는 const 인데 반해 바인드로 이동 생성된 data 의 복사본은 const 가 아니기 때문에 지금 보는 예제와 같이 const 에 대한 참조로 선언해야 합니다.

 하지만 C++11 람다 의 한계를 우회하기 위해 std::bind를 사용하는 방법은 모순적이며 C++14 람다를 선호하는 편이 낫다.

이것만은 잊지 말자!

  • 객체를 클로저 안으로 이동할 때에는 C++14의 초기화 갈무리를 사용하라.
  • C++11에서는 직접 작성한 클래스나 std::bind로 초기화 갈무리를 흉내 낼 수 있다.​

     

33. Use decltype on auto&& parameters to std::forward them

C++14에서 가장 고무적인 기능은 즉 매개변수 명세에 auto를 사용하는 람다입니다. 구현은 간단해서, 람다의 클로저 클래스의 operator()를 템플릿 함수로 만들면 된다. 이 람다가 산출하는 클로저 클래스의 함수 호출 연산자는 다음과 같은 모습이 됩니다.

auto f = [](auto x){ return normalize(x); };

class 컴파일러가_만든_어떤_클래스_이름 {
public:
    template<typename T>
    auto operator()(T x) const
    { return normalize(x); }
    ...
};

 위의 예제는 단순히 normalize로 전달만 하지만, normalize가 왼값과 오른값에 대해 따로 처리하여 완벽 전달을 필요로 하는 경우에는 인자에는 auto&&를 사용하여 보편참조를 받으면서 함수로 보내는 인자는 std::forward를 통해서 처리를 해야합니다. 이때 std::forward에 들어갈 형식 매개변수 T에다가 decltype을 이용해야 합니다. decltype이 매개변수 형식을 그대로 사용하도록 하기에 decltype(x)를 사용한다면 참조 형식에 맞추어서 보내줄 수가 있으며, 가변 인수를 사용해줄 수도 있습니다.

 

auto f = [](auto&&... xs){ return normalize(std::forward<decltype(xs)>(xs)...); };

이것만은 잊지 말자!

  • std::forward를 통해서 전달할 auto&& 매개변수에는 decltype을 사용하라.

34. Prefer lambdas to std::bind

C++11에서는 람다가 거의 항상 std::bind보다 나은 선택이고, C++14에서는 람다가 거의 항상 나은 선택이 아니라 확고하게 우월한 선택입니다. C++11이 람다를 지원하기 시작하면서 std::bind는 비권장 기능이 되었으며, C++14에서는 std::bind를 사용하는 것이 합당한 경우가 전혀 없습니다. 이 이유를 살펴보도록 하겠습니다.

using Time = std::chrono::steady_clock::time_point;
enum class Sound { Beep, Siren, Whistle };
using Duration = std::chrono::steady_clock::duration;
void setAlarm(Time t, Sound s, Duration d);

auto setSoundL = [](Sound s) {
    using namesapce std::chrono;
    using namespace std::literals;
    setAlarm(steady_clock::now() + 1h, s, 30s);
  };

 한 시간 후부터 30초간 소리(setAlarm 실행)를 내는 알람 함수를 람다를 이용해서 만들면 위와 같습니다. 이를 std::bind를 이용해서 재구성하면 다음과 같이 만들어집니다.

using namespace std::chrono;
using namespace std::literals;
using namespace std::placeholders;

auto setSoundB = std::bind(setAlaram, steady_clock::now() + 1h, _1, 30s);

하지만 std::bind를 이용한 경우에는 문제가 여러가지 존재합니다.

[호출 시점의 문제]

  setAlarm이 호출되는 시점이 steady_clock::now() + 1h로 함수 호출하고 나서 1시간을 기대하고 있습니다. 람다의 경우에는 이와 똑같이 실행되지만, std::bind의 경우에는 바인드 객체가 생성되는 시점의 시간이 되어 버리기 때문에, 이를 수정하기 위해서는 std::bind를 한번더 호출하여 setAlarm 때까지 기다리도록 해야합니다.

auto setsoundB = std::bind(setAlarm, std::bind(std::plus<>(),
                    std::bind(steady_clock::now), 1h), _1, 30s);

 

[Overloading 문제]

 만약 setAlarm 함수를 overloading하게 되면 문제가 없는 람다와 달리 std::bind에서는 다음과 같이 타입을 지정해주지 않는 한 어떤 함수를 요청하는 지 모르기에 컴파일 오류가 발생합니다.

using SetAlram3ParamType = void(*)(Time t, Sound s, Duration d);

auto setSoundB =
  std::bind(static_cast<SetAlram3ParamType>(setAlarm),
            std::bind(std::plus<>(),
                      std::bind(steady_clock::now),
                      1h),
            _1,
            30s);

 이는 다른 문제로 성능을 저하시키는데, 람다의 경우에는 setAlarm 호출을 인라인화 시키는데 반해 std::bind의 경우에는 함수 포인터를 사용하기 때문에 인라인화할 가능성이 매우 낮습니다.

[구현과 유지보수의 문제]

 코드가 복잡해질수록 std::bind로의 구현이 어렵고 복잡해지기에 이를 보수하거나 구현하는 것이 더욱더 힘들어집니다.

[참조와 복사의 문제]

enum class CompLevel { Low, Normal, High };

Widget compress(const Widget& w,
                CompLevel lev);
                
Widget w;
auto compressRateB = std::bind(compress, w, _1);

 위 함수에서 바인드 객체에 전달되는 w 는 값으로 전달되며 이는 저장된 w에 변화를 줄 수 없습니다. 이는 std::bind에서는 알아보기 어렵지만 람다의 경우에는 의도가 명백하게 드러납니다.

auto compressRateL = [w](CompLevel lev) { return compress(w, lev); };

이러한 문제점들이 존재하지만 C++11에서는 std::bind가 필요한 경우가 존재합니다.

  • 이동 갈무리 - 항목 32에서 얘기를 하였습니다
  • 다형적 함수 객체 - 바인대 객체에 대한 함수 호출 연산자는 완벽 전달을 사용하기 때문에 어떤 형식의 인수도 받을 수 있습니다. 이는 C++14에서 auto가 도입되며 상관이 없지만, C++11에서는 불가능합니다.

 

이것만은 잊지 말자!

  • std::bind를 사용하는 것보다 람다가 더 읽기 쉽고 표현력이 좋다. 그리고 더 효율적일 수 있다.
  • C++14가 아닌 C++11에서는 이동 갈무리를 구현하거나 객체를 템플릿화된 함수 호출 연산자에 묶으려 할 때 std::bind가 유용할 수 있다.​

23. Understand std::move and std::forward

 C++11 에 등장한 이동 의미론(move semantic)을 수행하는 데에 있어 이를 수행하는 방법으로 크게 두가지, std::move  std::forward 함수가 존재합니다. std::move가 모든 것을 이동하지는 않고, std::forward가 모든 것을 전달하지는 않는다는 관점으로 접근하면 이해하기 쉽습니다.

[std::move]

C++14 버전으로 std::move 를 구현하면 다음과 같이 구현할 수 있다.

template<typename T>
decltype(auto) move(T&& param)
{
    using ReturnType = remove_reference_t<T>&&;
    return static_cast<ReturnType>(param);
}

 함수의 반환 형식에 있는 &&를 통해, std::move는 오른값 참조를 돌려줍니다. 만약 T가 왼값인 경우 T&&도 왼값으로 캐스팅되기에, 이를 막기 위해 형식 특질을 이용하여 고정시킵니다. 결론적으로 std::move 함수가 오른값으로의 캐스팅만을 수행하는데, std::move 함수를 사용하더라도 이동이 되지 않는 경우가 있습니다.

class Annotation {
public:
    explicit Annotation(const std::string text)
    : value(std::move(text))
    { ... }
    ...
    
private:
    std::string value;
};

 위의 코드에서 생성자를 효율적으로 구성하기 위해 매개변수 text를 value로 이동시키려고 했지만 실제로는 복사가 수행됩니다. 이는 전체의 과정에서 const가 유지되는데 const의 여부가 이동에 영향을 미치기 때문입니다. 

class string {
public:
    ...
    string(const string& rhs);
    string(string&& rhs);
    ...
};

std::move(text)의 결과는 const std::string 형식의 오른값이며, 이 값은 const 타입이기에 이동 생성자에 전달이 불가능하지만, 복사 생성자에는 전달이 가능합니다. const 에 대한 왼값 참조에다 const 오른값을 묶는 것이 허용되기 때문입니다. 따라서 복사생성자가 호출되게 됩니다. 여기서 얻게되는 교훈은 이동을 지원할 객체는 const 로 선언하면 안된다는 점과 std::move는 실제로 이동하지 않을 뿐더러 그저 오른값을 내뱉는다는 사실입니다.

[std::forward]

 std::forward와 std::move와 비슷하게 수행되는데, std::forward는 조건부 캐스팅을 한다는 점이 다릅니다. 가장 흔한 시나리오는 보편 참조 매개변수를 받아 이를 전달하는 경우입니다.

void process(const Widget& lvalArg);
void process(Widget&& rvalArg);

template<typename T>
void logAndProcess(T&& param)
{
    auto now = std::chrono::system_clock::now();
    
    makeLogEntry("Calling 'process'", now);
    process(std::forward<T>(param));
}

Widget w;

logAndProcess(w);
logAndProcess(std::move(w));

logAndProcess는 왼값과 오른값에 대해서 중복적재가 되어있고, 이를 시험해보기 위해 logAndProcess 함수에 전달하는 인자를 좌측값과 우측값으로 보내는 경우로 나누어보았습니다. 

  • w 왼값    -> param 왼값 참조     -> T 왼값 참조     -> std::forward<Widget &>   -> process(const Widget&)
  • w 오른값 -> param 오른값 참조  -> T 오른값 참조 -> std::forward<Widget &&> -> process(Widget&&)

이처럼 보편참조를 사용하는 경우에는 std::forward를 사용해야 안전하며 우리가 받은 값의 형태를 그대로 전달 해줄 수 있습니다. 인수가 왼값과 오른값 둘 중 어느 쪽으로 초기화 되었는지에 대한 정보는 템플릿 매개변수 T에 부호화되어 있습니다.

 std::forward로 모든 것을 할 수 있지만, std::move 역할을 대신하려면 상당히 피곤해집니다. 따라서 상황에 따라 둘다 맞춰쓰는 것이 좋습니다.

 결론적으로, std::move와 std::forward는 그냥 캐스팅을 수행하는 함수입니다. std::move는 주어진 인수를 무조건 우측값으로 캐스팅하고, std::forward는 특정 조건이 만족될 때에만 그런 캐스팅을 수행합니다.

이것만은 잊지 말자!

  • std::move는 오른값으로의 무조건 캐스팅을 수행한다. std::move 자체는 아무것도 이동하지 않는다.
  • std::forward는 주어진 인수가 오른값에 묶인 경우에만 그것을 오른값으로 캐스팅한다.
  • std::move와 std::forward 둘 다, 실행시점에서는 아무 일도 하지 않는다.

24. Distinguish universal references from rvalue references

 T&&에는 오른값 참조와 보편적 참조, 두 가지 의미가 존재합니다. 오른값 참조는 오른값에만 묶이며 이동의 원본이 될 수 있는 객체를 지정합니다.

 보편적 참조는 우측값 참조 또는 좌측값 참조 중 하나로, 우측값에 묶을 수도 있고 좌측값에 묶을 수도 있습니다. 더 나아가서, 이런 참조는 const 객체에 묶을 수도 있고 비const 객체에 묶을 수도 있으며, 마찬가지로 volatile 객체에 묶을 수도 있고 비volatile 객체에 묶을 수도 있습니다. 왼값과 오른값을 둘 다 적절히 전달하기 위해서 보편 참조에는 거의 항상 std::forward를 적용해야 합니다. 

 보편 참조는 두 가지 문맥에서 나타난다. 가장 흔한 것은 함수 템플릿 매개변수이고, 다른 한 문맥은 auto 선언입니다.
template<typename T>
void f(T&& param);
auto&& var2 = var1;

 위와 같이 보편 참조로서 수행되려면 반드시 형식 연역이 관여해야 하며, 딱 "T&&"의 형태이어야만 합니다. 마지막으로, 템플릿 안에서는 형식 연역이 반드시 일어나야만 합니다. 이에 해당하는 예시로 std::vector의 다음과 같은 push_back 멤버 함수를 사용하겠습니다.

template<typename T>
void f(std::vector<T>&& param);

template<typename T>
void f(const T&& param);

template<class T, class Allocator = allocator<T>>
class vector {
public:
   void push_back(T&& x);
    ...
};

 먼저 위의 경우는 T&& 형태가 아니라 std::vector<T>에 대한 오른값 참조가 됩니다. 두번째의 경우는 const 한정사를 통해서 수정불가능해지기 때문에 이동이 지원되지 않기에, 보편 참조로서 작동하지 않습니다. 마지막의 경우는 push_back의 매개변수는 보편 참조가 요구하는 형태이지만, 이 경우에는 vector 클래스가 이전에 인스턴스화되어 push_back의 형식을 결정하기 때문에 형식 연역이 전혀 일어나지 않는다. 반면, std::vector의 멤버 함수들 중 이와 비슷한 emplace_back 멤버 함수는 실제로 형식 연역을 사용합니다.

template<class T, class Allocator = allocator<T>>
class vector {
public:
    template<class... Args>
    void emplace_back(Args&&... args);
    ...
};

 이 경우에는 형식 매개변수 T와 인자 Args가 독립적이고, Args가 보편 참조의 형태를 띄고 있으며, T에서 vector 클래스가 인스턴스화 되지만, emplace_back은 Args에서 형식 연역이되고 있기에 Args는 보편 참조입니다.

 앞서 말했듯 auto 변수 역시 보편 참조가 될 수 있습니다. 특히, C++14에서 람다 표현식에 auto&&를 사용가능해지면서 범위가 더 넓어졌습니다.

auto timeFuncInvocation =
	[](auto&& func, auto&&... params)
    {
      std::foward<decltype(func)>(func)(
        std::forward<decltype(params)>(params)...
      );
    };

이것만은 잊지 말자!

  • 함수 템플릿 매개변수의 형식이 T&& 형태이고 T가 연역된다면​, 또는 객체를 auto&&로 선언한다면, 그 매개변수나 객체는 보편 참조이다.
  • 형식 선언의 헝태가 정확히 형식&&가 아니면, 또는 형식 연역이 일어나지 않으면, 형식&&은 오른값 참조를 뜻한다.
  • 오른값으로 초기화되는 보편 참조는 오른값 참조에 해당한다. 왼값으로 초기화되는 보편 참조는 왼값 참조에 해당한다.

25. Use std::move on rvalue references, std::forward on universal references

 

 오른값 참조는 이동할 수 있는 객체에만 묶이기에 어떤 매개변수가 오른값 참조라면, 객체를 이동할 수 있다는 의미입니다. 객체를 이동하기 위해 오른값 참조로 캐스팅 해주는 함수가 std::move 함수입니다. 반면에 보편 참조는 오른값과 왼값에 모두 대응해야하기에, 오른값일때만 오른값으로 캐스팅해주어야하는데 이 역할을 하는 함수가 std::forward입니다.

 이 둘을 서로 바꿔서 사용하는 것은 상당히 좋지 않습니다. 먼저 보편 참조에 std::move를 사용하는 경우를 보겠습니다.

class Widget {
public:
    template<typename T>
    void setName(T&& newName)          // 보편 참조
    { name = std::move(newName); }     // 보편 참조에 move 함수를 사용
    ...
    
private:
    std::string name;
    std::shared_ptr<SomeDataStructure> p;
};

std::string getWidgetName();  // 팩터리 함수

Widget w;

auto n = getWidgetName();

w.setName(n);

...

 이 코드는 지역변수 n을 setName 함수에 전달해주었다. 작성자는 n 을 읽기 전용으로 취급하여, std::move를 통해 이동을 수행하였습니다. 하지만 setName 함수가 끝난 이후에는 n 은 미지정 값이 되기에 이후에 n이 더 사용된다면 문제가 발생할 수 있습니다.

 이때 setName의 매개변수를 보편 참조로 선언한 것이 문제이고 이를 아래와 같이 overloadding을 한다면 문제가 사라질 것이라고 믿을 수도 있습니다.

class Widget {
public:
    void setName(const std::string& newName)
    { name = snewName; }
    
    void setName(std::string&& newNmae)
    { name = std::move(newName); }
    
    ...
};

 위 코드가 위의 문제는 사라지지만 3가지 단점이 발생합니다. 첫째는 유지보수해야 할 소스 코드의 양이 늘어나며, 둘째는 효율성이 떨어질 수 있습니다. 만약 문자열 리터럴을 파라미터로 전달하는 경우에는 setName에 전달하기 위하여 임시 std::string 객체를 생성해야 하고, 이는 또 std::move를 통해 이동을 하면서 비용이 비싸집니다. 결국 불필요한 객체 생성과 소멸이 일어납니다. 만약 보편 참조를 사용했더라면 문자열 리터럴이 name 에 대한 할당 연산자의 인수로 쓰였을 것입니다. 

 마지막으로 세번째 문제는 코드양이 기하급수적으로 증가한다는 것이다. 왼값과 오른값를 구별해야하며, 여러 개를 받는 경우의 수도 상정해야하기 때문에 전부 만들어내는 것은 힘듭니다. 따라서, 이런 함수들은 내부적으로 std::move가 아닌 std::forward 를 사용해야만 합니다. 

 보편 참조이든 오른값 참조이든 함수 내부적으로 여러번 사용하는 경우가 있을텐데, 이러한 경우에는 반드시 마지막에만 std::move  std::forward 사용해야만 합니다.

template<typename T>
void setSignText(T&& text)
{
    sign.setText(text);
    
    auto now =
        std::chrono::system_clock::now();
        
    signHistory.add(now,
                    std::forward<T>(text));
}

 만약 함수가 결과를 값으로 돌려주고 그 값이 오른값 참조이거나 보편 참조에 묶인 객체라면 반환문에 std::move std::forward를 사용하는 것이 바람직할 것입니다. 만약 그냥 결과값을 반환한다면 값을 복사해서 반환하지만, std::move를 이용한다면 이동을 시켜 좀 더 효율적으로 만들 수 있을 것 입니다. 이 같은 접근방식을 모든 곳에서 사용할 수 있을 것이라 생각하지만 이는 틀렸습니다.

Widget makeWidget()
{
    Widget w;
    ...
    return std::move(w);
}

 언뜻 보기에는 복사를 이동으로 바꾸었으니 성능이 향상될 것이라고 기대하지만, 위 코드와 같이 지역 변수를 반환하는 함수를 위하여 RVO(return value optimization)를 통해 반환 타입과 같은 같은 타입을 가지는 지역변수를 사용하는 경우, 반환값을 위해 마련한 메모리 안에 객체를 생성하여 w의 복사를 제거할 수 있도록 컴파일러가 최적화시켜줍니다.

 그런데 만약 std::move 를 사용하게 되면 반환하는 객체의 타입이 Widget 이 아닌 std::move(w)가 되어버리기에, 복사 제거의 조건을 만족하지 않게 되어 최적화가 일어나지 않아 성능 하락으로 이어집니다. 만약 복사 제거가 일어나지 않아도 이는 좋지 않은 방법인데, 반환되는 객체는 반드시 오른값으로 취급되어야만 합니다. 이는 컴파일러가 암묵적으로 오른값으로 취급하기 때문에, std::move를 적용한다고 해서 컴파일러에게 도움이 되지는 않습니다. 

이것만은 잊지 말자!

  • 우측값 참조나 보편 참조가 마지막으로 쓰이는 지점에서, 우측값 참조에는 std::move를, 보편 참조에는 std::forward를 적용하라.
  • 결과를 값 전달 방식으로 돌려주는 함수가 우측값 참조나 보편 참조를 돌려줄 때에도 각각 std::move나 std::forward를 적용하라.
  • 반환값 최적화의 대상이 될 수 있는 지역 객체에는 절대로 std::move나 std::forward를 적용하지 말아야 한다.​

26. Avoid overloading on universal references

template<typename T>
void logAndAdd(T&& name)
{
    auto now = std::chrono::system_clock::now();
    log(now, "logAndAdd");
    names.emplace(std::forward<T>(name));
}

std::string petName("Darla");
logAndAdd(petName);
logAndAdd(std::string("Persephone"));
logAndAdd("Patty Dog");

 보편 참조를 받게 하고, 이를 std::forward를 통해 emplace에 전달하면, 효율적으로 코드가 돌아가도록 할 수 있습니다. 만약 이 상태에서 logAndAdd를 다음과 같이 overloadding한다고 생각해봅시다.

void logAndAdd(int idx)
{
    auto now = std::chrono::system_clock::now();
    log(now, "logAndAdd");
    names.emplace(nameFromIdx(idx));
}

이 함수는 통상적인 문제에 대해서는 적절하게 동작하지만 int 가 아닌 short 를 전달하는 경우 문제가 발생합니다. 우리가 기대한 것은 short가 int로 암시적 변환이 일어난 후에 logAndAdd(int idx)가 수행되기를 바라지만 실제로는 short에 대한 템플릿이 객체화되면서 short을 이용하여 string 객체를 생성하는데에 실패하면서 에러를 발생시킵니다. 위와 같은 문제를 피하는 방법 중 하나는 완벽 전달 생성자를 작성하는 것입니다.

class Person {
public:
    template<typename T>
    explicit Person(T&& n)
    : name(std::forward<T>(n)) {}
    
    explicit Person(int idx)
    : name(nameFromIdx(idx)) {]
    
private:
    std::string name;
};

 위와 같은 상황에서도 int 이외의 정수 형식을 넘겨주면 보편 참조를 받는 생성자가 호출되고 컴파일에 실패하는데 이전보다 문제가 심각해졌습니다. 그 이유는 자동으로 복사 생성자와 이동 생성자가 생성되기 때문입니다.

class Person {
public:
    template<typename T>
    explicit Person(T&& n)
    : name(std::forward<T>(n)) {}
    explicit Person(int idx);
    
    Person(const Person& rhs);
    
    Person(Person&& rhs);
    ...
};

Person p("Nancy");
auto cloneOfP(P);  // 컴파일 에러

 위의 코드에서는 복사 생성자가 호출되는 것이 아닌 Person을 매개변수로 받는 완벽 전달 생성자를 호출하여 ㅐPerson로부터 string 객체를 생성하는 방법 없어 에러가 발생시킵니다. 이는 컴파일러의 추론 따라가보면 알 수 있는데, cloneofP는 const 가 아닌 왼값으로 초기화됩니다. 따라서 const를 받는 복사 생성자보다 비const Person 형식으로 템플릿화된 생성자로 추론하게 됩니다. 이를 해결 하기 위해서는 p 객체를 const 객체로 만들어 주면 복사 생성자를 호출하게 될 것 입니다.

 하지만 이는 모든 문제를 해결하기에는 부족합니다. 템플릿화된 생성자 역시 복사 생성자와 동일한 서명으로 객체화될 수 있기 때문입니다. 심지어 상속이 관여한다면 이는 더 복잡해집니다. 

class SpecialPerson: public person {
public:
    SpecialPerson(const SpecialPerson& rhs)
    : Person(rhs)
    { ... }
    
    SpecialPeron(SpecialPerson&& rhs)
    : Person(std::move(rhs))
    { ... ]
};

 복사생성자와 이동생성자 모두 person 클래스의 완벽 전달 생성자를 호출하는데, 이는 SpecialPerson 형식의 객체가 전달되기 때문에 완벽 전달 생성자가 정확히 부합하게 되기 때문입니다. 이에 대한 해결법은 다음 항목에서 소개하도록 하겠습니다.

이것만은 잊지 말자!

  • 보편 참조에 대한 중복적재는 거의 항상 보편 참조 중복적재 버전이 예상보다 자주 호출되는 상황으로 이어진다.
  • 완벽 전달 생성자들은 특히나 문제가 많다. 그런 생성자는 대체로 비const 좌측값에 대한 복사 생성자보다 더 나은 부합이며, 기반 클래스 복사 및 이동 생성자들에 대한 파생 클래스의 호출들을 가로챌 수 있기 때문이다.

27. Familiarize yourself with alternatives to overloading on universal references

 

이번 항목에서는 보편 참조에 대한 overloading이 아닌 기법 또는 보편 참조에 대한 중복적재가 부합할 수 있는 인수들의 형식을 제한함으로써 보편 참조가 지닌 문제점을 해결하고자 합니다.

​[중복적재를 포기] 

각 중복적재 버전들에 각자 다른 이름을 붙이면 보편 참조에 대한 중복적재의 단점을 피할 수 있지만, 통하지 않는 경우도 존재하고, 오히려 단점만 더 부각됩니다.

[const T& 매개변수 사용]

C++98로 돌아가서 보편 참조 매개변수 대신 const에 대한 좌측값 참조 매개변수를 사용하는 방법인데 이는 딱히 효율적이지 않다.

​[​값 전달 방식의 매개변수 사용] 

​종종 복잡도를 높이지 않고 성능을 높일 수 있는 한 가지 접근 방식은, 참조 전달 매개변수 대신 값 전달 매개변수를 사용하는 방법입니다. 이에 대한 자세한 논의는 항목 41에서 논합니다.

[꼬리표 배분(tag dispatch) 사용]

중복 적재된 함수의 호출에 대해 컴파일러는 그 호출에 쓰인 인수들과 선택 가능한 overloading 버전들의 모든 가능한 조합을 고려해서, 가장 잘 부합하는 것을 선택합니다. 이때, 일반적으로 보편 참조 매개변수는 전달된 인수에 대해 정확히 부합하지만, 매개변수 목록에 보편 참조 매개변수뿐만 아니라 보편 참조가 아닌 매개변수들도 포함되어 있으면, 보편 참조가 아닌 매개변수들에 대한 충분히 나쁜 부합이 보편 참조 매개변수가 있는 overloading을 제치고 선택될 가능성이 있습니다. 이에 착안하여 꼬리표 배분 접근 방식이 탄생하였습니다.

 template<typename T>
 void logAndAdd(T&& name)
 {
     logAndAddImpl(std::forward<T>(name), 
         std::is_integral<typename std::remove_reference<T>::type>());
 }

 template<typename T>
 void logAndAddImpl(T&& name, std::false_type)
 {
     auto now = std::chrono::system_clock::now();
     log(now, "logAndAdd");
     names.emplace(std::forward<T>(name));
 }

 void logAndAddImpl(int idx, std::true_type)
 {
     logAndAdd(nameFromIdx(idx));
 }

 위의 코드는 꼬리표 배분 접근 방식을 통해 리팩토링한 코드인데, 들어오는 매개변수에 대해 모두 형식 특질을 통해 모든 한정사를 제거 하고 원본 타입으로 만듭니다. 그 이후에, std::is_integral을 통해 정수인지 아닌지를 확인하여 std::false_type과 std::true_type으로 구별하여, 이에 따라 별도의 함수가 돌아가도록 구현을 합니다. std::is_intergral의 경우는 템플릿 특수화를 통해 만들어지며, 위와 같은 과정은 컴파일 시점의 현상인 overloading 해소 과정에서 logAndAddImpl을 적절하게 선택할 수 있도록 합니다.

[보편 참조를 받는 템플릿을 제한]

​꼬리표 배분은 완벽 전달 생성자와 복사 생성자의 문제를 해결하는데에 있어서 해결책이 되기 어렵습니다. 이는 보통 컴파일러가 작성한 함수들이 꼬리표 배분 설계를 항상 우회하지 않기 때문입니다. 이처럼 보편 참조를 받는 중복적재 버전이 독자가 원하는 것보다는 탐욕스럽지만 단일한 배분 함수로 작용할 정도로 탐욕스럽지는 않은 경우에는 꼬리표 배분 설계가 적합하지 않습니다. 그보다는, 보편 참조 매개변수를 포함하는 함수 템플릿이 중복적재 해소의 후보가 되는 조건들을 적절히 제한할 수 있는 또 다른 기법이 필요한데 바로 std::enable_if입니다.

 std::enable_if를 이용하면 컴파일러가 마치 특정 템플릿이 존재하지 않는 것처럼 행동하게 만들 수 있습니다. 그런 템플릿을 비활성화된 템플릿(disabled template)이라고 부릅니다. 기본적으로 모든 템플릿은 활성화된 상태이나, std::enable_if를 사용하는 템플릿은 오직 그 std::enable_if에 지정된 조건이 만족될 때에만 활성화 됩니다. 이는 SFINAE와도 깊은 관련이 있습니다. 그래서 Person과 관련된 객체 및 정수형 인자를 제대로 처리하기 위해, 먼저 const성, volatile성 및 참조 여부를 제거 하기 위한 std::decay와, 상속 계열 여부를 확인하는 is_base_of에 정수형인지를 파악하기 위한 is_integral을 통해서 다음과 같은 코드를 짤 수 있습니다.

class Person {
public:
  template<
    typename T, 
    typename = typename std::enable_if_T<
      !std::is_base_of<Person,std::decay_t<T>>::value
      &&
      std::is_integral<std::remove_reference_t<T>>::value
      >
    >
    explicit Person(T&& n);
    explicit Person(int idx);
    ...
};

이것만은 잊지 말자!

  • 보편 참조와 중복적재의 조합에 대한 대안으로는 구별되는 함수 이름 사용, 매개변수를 const에 대한 좌측값 참조로 전달, 매개변수를 값으로 전달, 꼬리표 배분 사용 등이 있다.
  • std::enable_if를 이용해서 템플릿의 인스턴스화를 제한함으로써 보편 참조와 중복적재를 함께 사용할 수 있다. std::enable_if는 컴파일러가 보편 참조 중복적재를 사용하는 조건을 프로그래머가 직접 제어하는 용도로 쓰인다.
  • 보편 참조 매개변수는 효율성 면에서 장점인 경우가 많지만, 대체로 사용성 면에서는 단점이 된다.​

28. Understand reference collapsing

인수가 템플릿에 전달되었을 때 템플릿 매개변수에 대해 연역된 형식에는 그 인수가 왼값인지 아니면 오른값인지에 대한 정보가 부호화되어 있습니다. 그러나 오직 인수가 보편 참조 매개변수를 초기화하는데 쓰일 때에만 그런 일이 일어납니다.

 template<typename T>
 void func(T&& param);

 

이러한 부호화의 메커니즘은 왼값 인수가 전달되면 T는 왼값 참조로 연역되고 오른값이 전달되면 T는 비참조 형식으로 연역됩니다. 이는 std::forward의 작동에 깔린 바탕 매커니즘이기도 합니다. 우리가 코드 상으로 참조에 대한 참조를 사용하게 된다면 컴파일러 에러가 뜨게 되지만, 특정 문맥에서는 컴파일러가 참조에 대한 참조를 산출하는 것이 허용되는데 템플릿 인스턴스화가 그런 문맥 중 하나입니다. 그런 경우에는 참조 축약(reference collapsing)이 적용됩니다. 참조에 대한 참조에 대한 규칙은 단 하나로 축약될 수 있습니다.

 만일 두 참조 중 하나라도 좌측값 참조이면 결과는 좌측값 참조이다. 그렇지 않으면(즉, 둘 다 우측값 참조이면) 결과는 우측값 참조이다.

 이에 대한 예시는 std::forward에서 인수가 왼값으로 들어오는 경우와, 오른값으로 들어온 경우에 대해 값에 맞게 전달하는 과정을 생각하면 정리됩니다.

template<typename T>
T&& forward(typename remove_reference<T>::type& param)
{
    return static_cast<T&&>(param);
}

즉 보편 참조라는 사실상 오른쪽 참조이지만, 참조 축약 문맥의 존재에 따라 이를 편의상 보편적 참조라고 부르고 있는 것입니다.

이와 같은 참조 축약이 일어나는 문맥은 4가지가 있는데, 템플릿 객체화, auto 형식 연역, typedef의 지정 및 사용 그리고 decltype 사용이 있습니다. 따라서 typedef의 경우 우리가 지정하고자 했던 타입과 실제 저장되는 타입이 달라질 수도 있습니다.

이것만은 잊지 말자!

  • 참조 축약은 템플릿 인스턴스화, auto 형식 연역, typedef와 별칭 선언의 지정 및 사용, decltype의 지정 및 사용이라는 4가지 문맥에서 일어난다.
  • 컴파일러가 참조 축약 문맥에서 참조에 대한 참조를 만들어 내면, 그 결과는 하나의 참조가 된다. 원래의 두 참조 중 하나라도 좌측값 참조이면 결과는 좌측값 참조이고, 그렇지 않으면 우측값 참조이다.
  • 형식 연역이 좌측값과 우측값을 구분하는 문맥과 참조 축약이 일어나는 문맥에서 보편 참조는 우측값 참조이다.​

29. Assume that move operations are not present, not cheap, and not used

 C++11에 추가된 주요 기능으로 이동 의미론이 꼽히는데, 이에 따른 성능 향상이 크게 주목 받습니다. 이동 연산이 항상 복사 연산보다 빠를 것이라고 기대하지만 사실은 그렇지 않을 수 있습니다. 먼저 명시적으로 이동 연산을 지원하지 않는다면 복사와 다를 것이 없고, 지원한다고 하더라도 성능상의 이득이 생각만큼 크지 않을 수 있습니다.

 std::array는 std::vector와 다르게 포인터가 아닌 내장 배열에 인터페이스를 씌워놓은 형태이므로,  이동을 하게 되면 내장 배열에 있는 자료들을 하나하나 이동시켜야 합니다. 그렇기 때문에 이동 또한 선형 시간이 필요하며, 수행 시간이 복사와 크게 차이가 나지 않습니다.

 이외에도 std::string의 경우에도 작은 문자열 최적화가 일어나 내장 배열로서 존재하는 경우는 std::array와 같이 복사와 이동이 선형 시간으로 필요하여 큰 차이가 나지 않습니다.

 따라서 다음과 같은 경우 이동 의미론이 도움이 되지 않습니다

  • 객체 자체의 이동 연산 부재한 경우
  • 이동이 복사보다 더 우월하지 않은 경우
  • 이동 연산이 예외를 방출하면 안되는 문맥에서 noexcept가 아니라 사용 불가능한 경우
  • 원본 객체가 왼값인 경우

이것만은 잊지 말자!

  • 이동 연산들이 존재하지 않고, 저렴하지 않고, 적용되지 않을 것이라고 가정하라.
  • 형식들과 이동 의미론 지원 여부를 미리 알 수 있는 경우에는 그런 가정을 둘 필요가 없다.​

30. Familiarize yourself with perfect forwarding failure cases.

완벽 전달은 단순히 객체들을 전달하는 것만이 아니라, 그 객체들의 주요 특징, 즉 그 형식, 좌측값 또는 우측값 여부, const나 volatile 여부까지도 전달하는 것을 말합니다. 이를 위해서는 앞에서 언급했듯이 참조 매개변수가 필요하며, 전달된 인수의 좌측값/우측값에 대한 정보를 부호화하는 것은 보편 참조 매개변수가 유일하기 때문에 보편 참조 매개변수가 필요합니다. f라는 함수에 인수들을 전달하는 함수를 작성하였을 때, 함수 f에 직접 전달하는 것과 전달 함수 fwd를 통해 전달하는 것과 다른 일이 일어나면 완벽 전달을 실패한 것일 것입니다.

 template<typename... Ts>
 void fwd(Ts&&... params)
 {
     f(std::forward<Ts>(params)...);
 }

 

[​중괄호 초기치]

​f의 선언이 다음과 같은 경우, 중괄호 초기치를 f에 넘겨주는 코드는 정상 작동하지만 fwd에 넘겨주는 코드는 컴파일되지 않습니다.

void f(const std::vector<int>& v);
f({1, 2, 3});        // OK: "{1, 2, 3}"은 암묵적으로 std::vector<int>로 변환된다.
fwd({1, 2, 3});      // Compile Error

f에 직접 넣어준 경우는 std::initializer_list으로부터 std::vector<int> 객체를 생성하여 에러가 발생하지 않지만 fwd를 통해 한번 전달해야하는 경우에는 컴파일 에러가 발생합니다. 이는 컴파일러는 fwd로 전달된 인수들의 형식을 연역하고, 연역된 형식들을 f의 매개변수 선언들과 비교합니다. 이 때 아래 조건이 하나라도 만족하면 컴파일 에러가 발생합니다.

  • fwd 의 매개변수들 중 하나 이상에 대해 컴파일러가 형식을 연역하지 못하는 경우
  • fwd 의 매개변수들 중 하나 이상에 대해 컴파일러가 형식을 잘못 연역하는 경우

 fwd({1, 2, 3})호출에서의 문제는 std::initializer_list가 될 수 없는 형태로 선언된 함수 템플릿 매개변수에 중괄호 초기치를 전달하여 문제가 발생하는 것입니다. 이를 '비연역 문맥(non-deduced context)' 라고 부릅니다. 이를 해결하기 위해서는 std::initializer_list 객체로 간주시키기 위하여 이를 지역 변수에 저장한 후 넘겨주면 됩니다.

auto il = {1, 2, 3}; // std::initializer_list<int>로 연역됨.
fwd(il);

[​널 포인터를 뜻하는 0또는 NULL ]

0이나 NULL을 널 포인터로서 템플릿에 넘겨주려 하면 컴파일러가 그것을 포인터 형식이 아니라 정수 형식으로 연역하기 때문에 문제가 생깁니다. 결과적으로 0과 NULL은 널 포인터로서 완벽하게 전달되지 못합니다.

[선언만 된 정수 static const 및 constexpr 자료 멤버  ]

 정수 static const 자료 멤버와 static constexpr 자료 멤버는 클래스 안에서 정의할 필요 없이 선언만 하면 됩니다. 이는 컴파일러가 'const 전파(const propagation)' 를 적용해서, 메모리를 따로 마련할 필요가 없어지기 때문입니다.

 

class Widget {
public:
  static constexpr std::size_t MinVals = 28;
  ...
};
...

std::vector<int> widgetData;
widgetData.reserve(Widget::MinVals);

 컴파일러는 MinVals의 정의가 없어도 Minvals가 언급된 곳에 28이라는 값을 배치함으로써 누락된 정의를 처리합니다. 그런데 만일 MinVals 의 포인터가 필요해지면 이 코드는 실패하게 된다. 완벽 전달 역시 문제가 발생합니다.

 fwd의 매개변수는 보편 참조이며, 컴파일러가 산출한 코드에서 참조는 포인터처럼 취급되기에 본질적으로 같은 것입니다. 따라서 static const 또는 constexpr 자료 멤버의 정의를 제공하면 이에 대한 주소값을 가지게 되어 문제가 사라집니다.

constexpr std::size_t Widget::MinVals;

[​Overloading된 함수 이름과 템플릿 이름 ]

함수를 인자로 받는 f 함수와 f에 인자를 전달하는 fwd 함수가 있을 때 overloading된 함수를 부르는 코드를 생각해보겠습니다.

 

int processVal(int value);
int processVal(int value, int priority);
void f(int pf(int));

f(processVal);

fwd(processVal); // error

 f에 전달하는 경우에는 processVal 이름을 가진 함수 중에서 첫번째 processVal와 인자 형태가 같으므로 선택됩니다. 하지만 전달자 함수를 통한 경우는 어떤 것을 선택해야 하는지에 대한 정보가 없기에 선택을 할 수가 없게 됩니다. 이는 함수 템플릿을 사용하려고 해도 문제가 동일합니다. processVal 자체에는 타입이 존재하지 않기 때문에 형식 연역도 이뤄질 수 없습니다.

template<typename T>
T workOnVal(T param)
{ ... }

fwd(workOnVal);    // 에러 발생

fwd와 같은 완벽 전달 함수가 제대로 된 함수 이름 및 템플릿 이름을 받아들이게 하기 위해서는 명시적으로 형태를 지정해주면 됩니다.

using ProcessFuncType =
  int (*)(int);
  
  ProcessFuncType processValPtr = processVal;
  
  fwd(processValPtr);
  
  fwd(static_cast<ProcessFuncType>(workOnVal));

[​비트필드 ]

 마지막으로 완벽 전달이 실패하는 경우는 비트필드를 사용할 때입니다.

struct IPv4Header {
  std::uint32_t version:4,
                IHL:4,
                DSCP:6,
                ECN:2,
                totalLength:16;
  ...
};

void f(std::size_t sz);

IPv4Header h;
...
f(h.totalLength);   // OK

fwd(h.totalLength); // error

std::size_t를 사용하는 함수 fwd에 totalLength를 전달하고 하면 에러가 발생합니다. 이는 fwd 의 매개변수는 참조인데 반해 totalLength 는 비 const 비트필드이기 때문입니다. C++ 표준에서는 "비 const 참조는 절대로 비트필드에 묶이지 않아야 한다"는 조건이 있습니다. 그 이유는 비트필드들은 워드의 일부분으로 존재할 수 있는데, 임의의 비트들을 가르키는 포인터를 생성하는 방법이 없기 때문입니다. 다행히 우회하는 방법이 존재하여, 그 방법은 복사본을 만들어 전달하는 방법입니다. 복사본에는 포인터를 생성할 수 있기 때문에 문제는 발생하지 않습니다.

auto length = static_cast<std::uint16_t>(h.totalLength);

fwd(length);

이것만은 잊지 말자!

  • 완벽 전달은 템플릿 형식 연역이 실패하거나 틀린 형식을 연역했을 때 실패한다.
  • 인수가 중괄호 초기치이거나 0또는 NULL로 표현된 널 포인터, 선언만 된 정수 static const 및 constexpr 자료 멤버, 템플릿 및 중복적재된 함수 이름, 비트필드이면 완벽 전달이 실패한다.​

'C++공부 > Effective Modern C++' 카테고리의 다른 글

Chapter 7. The Concurrency API  (0) 2022.05.28
Chapter 6. Lambda Expressions  (0) 2022.05.27
Chapter 4. Smart Pointers  (0) 2022.05.26
Chapter 3. Moving to Modern C++  (0) 2022.05.26
Chapter 2. auto  (0) 2022.05.26

18. Use std::unique_ptr for exclusive-ownership resource management

std::unique_ptr는 독점적 소유권 의미론을 체현하고 있어, 널이 아닌 std::unique_ptr는 항상 자신이 가리키는 객체를 소유합니다. 그에 따라 복사가 아닌 이동만을 지원하며, std::unique_ptr를 이동하면 소유권이 원본 포인터에서 대상 포인터로 옮겨집니다. 또한, std::unique_ptr은 생 포인터와 같은 크기라고 가정해도 되고, 생 포인터와 정확히 동일한 명령들을 실행합니다. 이는 파괴될 때 가르키는 자원 또한 함꼐 파괴되는데, 이때 수행되는 커스텀 삭제자를 사용할 수 있습니다.

 std::unique_ptr의 흔한 용도 중 하나인 팩토리 패턴의 팩토리 함수의 반환 형식으로 사용되는 경우를 예시로 들수 있습니다.

auto delInvmt = [](Investment* pInvestment)
                {
                    makeLogEntry(pInvestment);
                    delete pInvestment;
                };
                
template<typename... Ts>
std::unique_ptr<Investment, decltype(delInvmt)>
makeInvsetment(Ts&&... params)
{
    std::unique_ptr<Investment, decltype(delInvmt)>
      pInv(nullptr, delInvmt);
      
    if ( /* Stock 객체를 생성해야 하는 경우 */ )
        pInv.reset(new stock(std::forward<Ts>(params)...));
    else if ( /* Bond 객체를 생성해야 하는 경우 */ )
        pInv.reset(Bond stock(std::forward<Ts>(params)...));
    else if ( /* RealEstate 객체를 생성해야 하는 경우 */ )
        pInv.reset(new RealEstate(std::forward<Ts>(params)...));
    return pInv;
}

 

 C++14의 경우는 delInvmt가 makeInvestment 함수 안에 들어가서 좀 더 캡슐화된 방식이 구현 가능하며, 이는 함수 반환을 auto로 간결화 시킬 수 있기에 가능합니다.

 기본 삭제자를 사용할 때에는 std::unique_ptr 객체의 크기가 생 포인터의 크기와 같으리라고 가정하는 것이 합당하지만, 커스텀 삭제자를 사용하면 커스텀 삭제자의 크기만큼(함수 포인터 혹은 함수 객체) 크기가 늘어나게 됩니다. 따라서 삭제자를 보통의 함수나 갈무리 없는 람다 표현식으로 구현가능하다면 람다를 선호하는 것이 바람직합니다. 

 std::unique_ptr는 개별 객체(std::unique_ptr<T>)와 배열(std::unique_ptr<T[]>)을 위한 것으로 2개로 나뉩니다. 따라서, std::unique_ptr가 어떤 종류의 객체를 가리키는지 관련된 애매함이 절대 발생하지 않습니다. 개별 객체는 [] 연산자를, 배열 버전은 역참조 연산자를 지원하지 않습니다. 보통 내장 배열보다는 std::array나 std::vector, std::string이 거의 항상 더 나은 선택지입니다.

이것만은 잊지 말자!

  • std::unique_ptr는 독점 소유권 의미론을 가진 자원의 관리를 위한, 작고 빠른 이동 전용 스마트 포인터이다.
  • 기본적으로 자원 파괴는 delete를 통해 일어나나, 커스텀 삭제자를 지정할 수도 있다. 상태 있는 삭제자나 함수 포인터를 사용하면 std::unique_ptr 객체의 크기가 커진다.
  • std::unique_ptr를 std::shared_ptr로 손쉽게 변환할 수 있다.​

19. Use std::shared_ptr for shared-ownership resource management

 std::shared_ptr를 통해서 접근되는 객체의 수명은 그 공유 포인터가 공유된 소유권 의미론을 통해서 관리합니다. 즉, std::shared_ptr은 객체의 소멸 시점을 관리하기 위하여 특정한 하나의 객체가 데이터를 공유하는 것이 아니라 이를 참조 횟수를 통해서 객체의 상태를 파악하고 있다가 아무도 사용하지 않을 때 이를 메모리를 해제하는 방법으로 구현되어 있습니다.

 이러한 참조 횟수 관리는 다음과 같은 영향을 미칩니다.

  • std::shared_ptr의 크기는 참조 횟수를 가르키는 생 포인터도 저장하므로 생 포인터의 두 배입니다.
  • 참조 횟수를 담을 메모리는 공유되는 자원이므로 반드시 동적으로 할당해야 합니다.
  • 멀티스레드 환경을 생각하여 참조 횟수의 증가와 감소가 반드시 원자적 연산이어야 합니다.

 또한, std::unique_ptr 와 같이 std::shared_ptr 에서도 커스텀 삭제자를 구현 할 수 있습니다.

auto loggingDel = [](Widget *pw)
                  {
                    makeLogEntry(pw);
                    delete pw;
                  };
                  
// 삭제자의 형식이 포인터 형식의 일부                  
std::unique_ptr<Widget, decltype(loggingDel)> upw(new Widget, loggingDel);

// 삭제자의 형식이 포인터 형식의 일부가 아님
std::shared_ptr<Widget> spw(new Widget, loggingDel);

std::shared_ptr는 std::unique_ptr 와 다르게 커스텀 삭제자의 타입을 지정해주지 않아도 되는 유연함을 가지고 있어, 다음과 같은 작성도 가능하다.

auto customDeleter1 = [](Widget *pw) { ... };
auto customDeleter2 = [](Widget *pw) { ... };

// 서로 다른 커스텀 삭제자들을 인수로 받습니다.
std::shared_ptr<Widget> pw1(new Widget, customDeleter1);
std::shared_ptr<Widget> pw2(new Widget, customDeleter2);

std::vector<std::shared_ptr<Widget>> vpw{ pw1, pw2 };

뿐만 아니라 커스텀 삭제자가 아무리 커져도 shared_ptr 의 크기는 변하지 않는데 이는 두 개의 포인터 중 로우 포인터를 제외한 다른 하나의 포인터가 제어 블록(control block)을 가르키고 여기서 관리를 하기 때문입니다.

 제어 블록은 std::shared_ptr의 소멸에 대해 관리하는 객체로,  앞서 말한 참조 횟수와 커스텀 삭제자를 갖고 관리하는 객체가 바로 제어 블록입니다. 제어 블록 객체가 한 객체당 유일하게 존재하며, 제어 블록의 존재 여부를 알 수는 없지만 몇 가지 규칙들을 유추할 수는 있습니다.

  • std::make__shared는 새로운 std::shared_ptr 객체를 생성하기에 항상 제어 블록을 생성합니다.
  • std::unique_ptr에는 제어 블록이 없기에 std::unique_ptr로부터 std::shared_ptr 객체를 생성하면 제어 블록이 생성됩니다.
  • 마찬가지로 생 포인터로 std::shared_ptr 생성자를 호출하면 제어 블록이 생성됩니다.

이 규칙들로 비롯되는 한가지 결과는 하나의 포인터에서 여러 개의 std::shared_ptr을 생성하면 여러 개의 제어블록을 만들고, 여러 번 파괴되기 때문에 좋지 않다는 점입니다. 대신에 new를 직접 사용하거나, 복사 생성하는 것이 좋습니다.

 std::shared_ptr가 할 수 없는 일로는 배열 관리가 있습니다. 배열을 염두하고 만들어지지 않아 operator[]를 지원하지 않기에 접근하려면 포인터 산술에 기초한 어색한 표현식을 동원해야 합니다. 대신 std::vector와 같은 배열 형식을 사용하는 것이 훨씬 낫습니다.

이것만은 잊지 말자!

  • std::shared_ptr는 임의의 공유 자원의 수명을 편리하게 관리할 수 있는 수단을 제공한다.
  • 대체로 std::shared_ptr 객체는 그 크기가 std::unique_ptr 객체의 두 배이며, 제어 블록에 관련된 추가 부담을 유발하며, 원자적 참조 횟수 조작을 요구한다.
  • 자원은 기본적으로 delete를 통해 파괴되나, 커스텀 삭제자도 지원된다. 삭제자의 형식은 std::shared_ptr의 형식에 아무런 영향도 미치지 않는다.
  • 생 포인터 형식의 변수로부터 std::shared_ptr를 생성하는 일은 피해야 한다.​

20. Use std::weak_ptr for std::shared_ptr-like pointers that can dangle

 스마트 포인터가 std::shared_ptr처럼 행동하되 피지칭 자원의 소유권 공유에는 참여하지 않는 것이 편리한 상황도 있습니다. 다른 말로 하면, std::shared_ptr와 비슷하되 객체의 참조 횟수에는 영향을 미치지 않는 포인터가 바람직한 경우도 있습니다. 이러한 경우 객체의 소멸에는 관여하지는 않지만 객체가 소멸한 지에 대해서는 검출할 수 있어야 합니다. std::weak_ptr는 이러한 기능들을 수행합니다.

 대체로 std::weak_ptr는 std::shared_ptr를 이용해서 생성되며, 이때 자신을 생성하는 데 쓰인 std::shared_ptr가 가리키는 것과 동일한 객체를 가리키나, 그 객체의 참조 횟수에는 영향을 주지 않습니다. 대상을 잃은 std::weak_ptr를 가리켜 만료되었다(expired)고 말합니다.

 auto spw = std::make_shared<Widget>();
 std::weak_ptr<Widget> wpw(spw);
 swp = nullptr;         // 횟수가 0이 되고 Widget이 파괴된다.
 if (wpw.expired()) ...

 제대로 된 용법은, std::weak_ptr의 만료 여부를 점검하고 아직 만료되지 않았으면 피지칭 객체에 대한 접근을 돌려주는 연산을 하나의 원자적 연산으로 수행하는 방법입니다. std::weak_ptr로 std::shared_ptr를 생성 방식에 따라 두가지로 나뉩니다. 첫번째 방법은 std::weak_ptr::lock을 사용하는 방법입니다. 만일 std::shared_ptr가 만료되어 있었다면, 그 std::shared_ptr는 널이 됩니다. 또 다른 방법은 std::weak_ptr를 인수로 받는 std::shared_ptr 생성자를 사용하는 방법입니다. 이 경우, 만일 std::shared_ptr가 만료되었다면 예외가 발생합니다.

std::shared_ptr<Widget> spw1 = wpw.lock(); // wpw가 만료이면 spw1은 널
std::shared_ptr<Widget> spw3(wpw);         // wpw가 만료이면 std::bad_weak_ptr이 발생

 std::weak_ptr가 사용되는 상황은 여러가지가 있는데, 보통 한 대상을 참조는 해야지만 객체의 소멸에는 관여하지 않는 경우입니다. 캐시를 가져와야 하는데 이 캐시가 소멸과는 관련 없어야 하는 경우가 이에 해당합니다. 또한 옵저버 패턴에서 관찰자가 관찰 대상의 소멸 여부에는 관심이 있지만 수명에는 관여하지 않기에 std::weak_ptr이 유용합니다.

 마지막으로 순환 참조로, 객체 A, B, C가 존재하며 A와 C가 B의 소유권을 공유하며 이에 대해 std::shared_ptr을 가지고 있고 B가 A에 대한 객체를 지녀야한다고 해봅시다. 이때 세가지의 선택권이 있는데 다음과 같습니다.

  • 생 포인터 - 대상을 잃는 경우 미정의 행동 발생시킴 
  • std::shared_ptr - 서로 대상을 잃는 경우의 수가 존재하지 않게 되어 둘다 파괴되지 못함
  • std::weak_ptr - 앞서 두가지 경우의 단점을 모두 커버 가능함

 std::weak_ptr는 std::shared_ptr과 본질적으로 동일한데, std::shared_ptr은 참조횟수에 관여하지만, std::weak_ptr은 소유권 공유에 참여하지 않는 두 번째 참조 횟수가 있어, 이에 관여합니다.

 

이것만은 잊지 말자!

  • std::shared_ptr처럼 작동하되 대상을 잃을 수도 있는 포인터가 필요하면 std::weak_ptr를 사용하라.
  • std::weak_ptr의 잠재적인 ​용도로는 캐싱, 관찰자 목록, 그리고 std::shared_ptr 순환 고리 방지가 있다.

21. Prefer std::make_unique and std::make_shared to direct use of new

std::make_shared는 C++11의 일부, std::make_unique는 C++14의 일부입니다. 이 둘은 임의의 개수와 형식의 인수들을 받아 그것들을 생성자로 완벽 전달해서 객체를 동적으로 생성하고, 그 객체를 가리키는 스마트 포인터를 돌려주는 함수입니다. 이를 선호해야하는 이유에는 3가지가 있는데 알아보겠습니다.

[간결한 코드]

auto spw1(std::make_unique<Widget>());

std::unique_ptr<Widget> spw2(new Widget());

auto spw2(std::make_shared<Widget>());

std::shared_ptr<Widget> spw2(new Widget());

 형식을 여러 번 되풀이하는 것은 소프트웨어 공학적으로 별로 좋지 않습니다. 또한 중복된 코드는 일관성이 없는 코드로 진화하기 쉬우며 코드 기반의 비일관성은 버그로 이어질 수 있습니다.

[예외 안정성]

void processWidget(std::shared_ptr<Widget> spw, int priority);
int computePriority();
processWidget(std::shared_ptr<Widget>(new Widget), computePriority()); // LEAKS!
processWidget(std::make_shared<Widget>(), computePriority()); // OK!

 위 코드는 마치 문제가 없는 것처럼 보이지만 누수가 발생할 수 있습니다. 이를 설명하기 위해서는 위 함수를 호출하는 코드를 세분화하여 3단계로 나누겠습니다.

  1. new Widget
  2. std::shared_ptr<Widget>()
  3. computePriority()

 위와 같은 순서로 진행되는 경우에는 Widget을 생성하고 이를 std::shared_ptr에 넣은 뒤 computePriority를 실행합니다. 이 경우에는 3번이 도중 예외를 발생시켜도 문제가 없지만, 만약 컴파일러에 따라 2번과 3번의 순서가 바뀐 상태에서, 3번에서 예외가 발생하면 Widget 객체는 누수가 발생하게 됩니다. std::make_shared 함수를 사용했더라면 예외가 발생했더라도 Widget 객체는 std::shared_ptr 로 관리되어 누수가 발생하지 않았을 것입니다. 이는 std::unique_ptr에도 동일하게 적용됩니다.

 

[메모리 할당 효율성]

 new를 직접 사용하여 메모리를 할당해주고 std::shared_ptr을 생성하면, 이에 대한 제어 블록도 따로 생성을 해주게 된다. 하지만 make함수를 통해 만든 경우에는, 한번에 할당을 해주게 되기에 실행 코드의 속도도 빨라지고, std::make_shared를 사용하면 제어 블록에 일정 정도의 내부 관리용 정보를 포함할 필요도 없어져서 프로그램의 전체적인 메모리 사용량이 줄어들 여지도 생깁니다.

[사용 불가능한 경우]

  • 커스텀 삭제자를 사용하면 make 함수를 통해 생성 불가능합니다.
  • std:initializer_list로 객체를 만들고 싶은 경우에는 따로 초기화 리스트를 따로 선언한 후에 넣어주어야 합니다.
  • 클래스 고유의  operator new와 operator delete가 있는 경우는 전역 메모리 할당 루틴과 해제 루틴이 그 형식의 객체에 적합하지 않기 때문에 이를 사용하지 않는 것이 좋습니다.
  • make 함수는 메모리를 한번에 할당하기에 한번에 해제되어야만 합니다. 만약 할당된 객체의 크기가 크고, 제어 블록에서 weak 참조 횟수가 남아 있는 경우에는 make 함수에서는 이를 할당해제 하지 못합니다. 그러나 new로 만들어진 경우에는 이를 따로 해제할 수가 있어, 메모리가 큰 객체에 대해서는 make를 사용하지 않는 것이 메모리 관리에 유리할 수 있습니다.

[안전한 new 사용]

 make를 사용할 수 없는 경우에는 안전하게 new를 사용해야만 한다. 이를 위해서는 위의 예시 코드를 고쳐쓰면, Widget의 할당과 std::shared_ptr의 생성을 개별적인 문장으로 두고, 생성한 std::shared_ptr로 processWidget을 호출하면 됩니다.

void processWidget(std::shared_ptr<Widget> spw, int priority);
int computePriority();

std::shared_ptr<Widget> spw(new Widget);
processWidget(spw, computePriority());            // OK!
processWidget(std::move(spw), computePriority()); // 만약 오른값을 인자로 받으면 성능이 올락마

 

이것만은 잊지 말자!

  • new의 직접 사용에 비해, make 함수를 사용하면 소스 코드 중복의 여지가 없어지고, 예외 안전성이 향상되고, std::make_shared와 std::allocate_shared의 경우 더 작고 빠른 코드가 산출된다.
  • make 함수의 사용이 불가능 또는 부적합한 경우로는 커스텀 삭제자를 지정해야 하는 경우와 중괄호 초기치를전달해야 하는 경우가 있다.
  • std::shared_ptr에 대해서는 make 함수가 부적합한 경우가 더 있는데, 두 가지 예를 들자면 (1) 커스텀 메모리 관리 기능을 가진 클래스를 다루는 경우와 (2) 메모리가 넉넉하지 않은 시스템에서 큰 객체를 자주 다루어야 하고 std::weak_ptr들이 해당 std::shared_ptr들보다 더 오래 살아남는 경우이다.​

22. When using the Pimpl Idiom, define special member functions in the implementation file

 Pimpl 관용구 컴파일 시간을 줄이기 위해서 도입되었으며, "pointer to implementation" 을 의미하며 클래스의 자료 멤버들을 구현 클래스를 가리키는 포인터로 대체하고, 일차 클래스에 쓰이는 자료 멤버들을 그 구현 클래스로 옮긴 뒤, 포인터를 통해서 그 자료 멤버들에 간접적으로 접근하는 기법입니다. 만약 다음과 같은 코드가 있다고 합시다.

class Widget {
public:
    Widget();
    ...
private:
    std::string name;
    std:;vector<double> data;
    Gadget g1, g2, g3;
};

이때 std::string, std::vector, Gadget 형식이 필요하므로 이에 대한 헤더가 있어야 하며, 이들 때문에 Widget 클라이언트의 컴파일 시간이 증가합니다. 또한 헤더 내용이 바뀌거나 Widget이 바뀌는 경우에 다시 컴파일을 해야만 합니다. C++98의 경우에는 구현용 구조체와 이를 가리키는 포인터로 대체했을 것입니다.

class Widget {
public:
    Widget();
    ~Widget();
    ...
private:
    struct Impl;
    Impl *pImpl;
};

 Pimpl 적용 코드를 보면 std::string, std::vector, Gadget 을 선언하지 않고 있기 때문에 #include 할 필요가 없어져, 컴파일 속도가 빨라지게 되었습니다. 이렇게 선언만 하고 정의를 하지 않는 형식을 불완전한 형식이라고 합니다. 이에 따른 구현부는 다음과 같습니다.

struct Widget::Impl {
    std::string name;
    std:;vector<double> data;
    Gadget g1, g2, g3;
};

Widget::Widget()
: pImpl(new Impl)
{}

Widget::~Widget()
{ delete pImpl; }

 여기서 pImpl 을 살펴보면 std::unique_ptr 를 사용하기 적합한 상황임을 알 수 있지만 std::unique_ptr 를 사용하게 되면 컴파일러가 자동으로 pImpl 의 소멸자를 호출하는데 Impl가 불완전한 형식이기에 컴파일되지 않습니다. 정확히 얘기하면, 컴파일러는 소멸자 안에 Widget의 자료 멤버 pImpl의 소멸자를 호출하는 코드를 삽입하는데, 기본 삭제자를 사용하는 std::unique_ptr이며 이는 delete를 수행합니다. 이때 delete를 수행하기 전에 포인터가 불완전한 형식을 가리키지는 않는 지를 C++11의 static_assert를 통해 점검합니다. 따라서 이때 오류가 발생하게 됩니다. 이를 해결하는 방법은 간단한데, 구현부에 소멸자를 선언하면 됩니다.

struct Widget::Impl {
    std::string name;
    std:;vector<double> data;
    Gadget g1, g2, g3;
};

Widget::Widget()
: pImpl(std::make_unique<Impl>())
{}

Widget::~Widget() = default;

마찬가지로 이동 연산에 대해서도 구현부에 선언을 해주어야 컴파일 에러가 발생하지 않는다.

이것만은 잊지 말자!

  • Pimpl 관용구는 클래스 구현과 클래스 클라이언트 사이의 컴파일 의존성을 줄임으로써 빌드 시간을 감소시킨다.
  • std::unique_ptr 형식의 pImpl 포인터를 사용할 때에는 특수 멤버 함수들을 클래스 헤더에 선언하고 구현 파일에서 구현해야 한다. 컴파일러가 기본으로 작성하는 함수 구현들이 사용하기에 적합한 경우에도 그렇게 해야 한다.
  • 위의 조언은 std::unique_ptr에 적용될 뿐, std::shared_ptr에는 적용되지 않는다.​

7. Distinguish between () and {} when creating objects

C++11에서는 초기화 값을 괄호로 지정할 수도 있고, 등호로 지정할 수도 있고, 중괄호로 지정할 수도 있습니다. 또한 등호와 중괄호를 함께 사용할 수 있는 경우도 존재합니다. 이는 중괄호만 사용한 구문과 동일하게 취급됩니다. 또한 C++98과는 다르게 C++11에서는 중괄호를 감싸 구현하는 균일 초기화(이하 중괄화 초기화)를 도입하여, 초기화 구문의 모호성과 구문이 모든 초기화를 지원하지 않는 문제를 해결하였습니다. 이와 같은 중괄호 초기화를 이용하면 이전에는 표현할 수 없었던 방식의 객체 생성을 표현할 수 있습니다.

std::vector<int> v{ 1, 2, 3 }; // v의 초기 값은 1, 3, 5

std::atomic<int> ai1{ 0 }; // OK
std::atomic<int> ai1(0);   // OK
std::atomic<int> ai1 = 0;  // Error

 

또한, 비정적 자료 멤버의 기본 초기화 값 지정도 가능합니다. 복사할 수 없는 객체는 중괄호나 괄호로는 초기화할 수 있지만 "="로는 초기화할 수 없습니다. 중괄호 초기화의 기능 중 하나는 내장 기능들 사이의 암묵적 좁히기 변환(narrowing conversion)을 방지해 줍니다.

double x, y, z;
...
int sum1{ x + y + z }; // Error

 또한 C++에서 성가신 구문해석에서도 자유로워 집니다. 선언으로 해석할 수 있는 것은 항상 선언으로 해석해야 하기 때문에, 기본 생성자를 호출하는 경우 이를 객체가 아니라 함수 선언으로 받아들이게 됩니다. 따라서 이를 중괄호 초기화를 사용하면 해결 가능합니다.

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

 단점으로는 종종 예상치 못한 행동을 보인다는 점입니다. 그런 행동은 중괄호 초기화와 std::initializer_list 및 생성자 중복적재 해소 사이의 괴상하게 뒤얽힌 관계에서 비롭됩니다. 생성자 중 하나 이상이 std::initializer_list 형식의 매개변수를 선언한다면, 중괄호 초기화 구문은 std::initializer_list를 받는 중복 적재 버전을 강하게 선호합니다.

class Widget {
public:
  Widget(int i, bool b);
  Widget(std::initializer_list<long doubles> il);
  operator float() const;
};

Widget w1(10, true); // 첫 생성자 호출
Widget w2{10, true}; // std::initializer_list 생성자를 호출하며, 10과 True가 long double로 변환
Widget w3(w2);       // 복사 생성자 호출
Widget w4{w2};       // 중괄호를 사용하여, w2가 float로의 변환 이후에 다시 long double로 변환

 이는 std::initializer_list 생성자가 가능한 최선의 부합인 경우에도 그 생성자를 호출할 수 없는 기 현상이 생깁니다. 밑에 코드 같은 경우에는, 좁히기 변환이 필요하여 생성자 호출이 유효하지 않아, 코드 컴파일이 거부됩니다.

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

Widget w1{10, 5.0}; // 좁히기 변환이 필요함

 컴파일러가 보통의 중복적재 해소로 물러나는 경우에는 중괄호 초기치의 인수 형식들을 std::initializer_list 안의 형식으로 변환하는 방법이 아예 없을 때 뿐입니다.

class Widget {
public:
  Widget(int i, bool b);
  Widget(std::initializer_list<std::string> il);
  operator float() const;
};

Widget w1(10, true); // 첫 생성자 호출
Widget w2{10, true}; // std::initializer_list 생성자로 변환 불가능하여, 첫 생성자 호출

이것만은 잊지 말자!

  • 중괄호 초기화는 가장 광범위하게 적용할 수 있는 초기화 구문이며, 축소 변환을 방지하며, C++의 가장 성가신 구문 해석에서 자유롭다.
  • 생성자 중복적재 해소 과정에서 중괄호 초기화는 가능한 한 std::initializer_list 매개변수가 있는 생성자와 부합한다(심지어 겉으로 보기에 그보다 인수들에 더 잘 부합하는 생성자들이 있어도).
  • 괄호와 중괄호의 선택이 의미 있는 차이를 만드는 예는 인수 두 개로 std::vector<수치 형식>을 생성하는 것이다.
  • 템플릿 안에서 객체를 생성할 때 괄호를 사용할 것인지 중괄호를 사용할 것인지 선택하기가 여려울 수 있다.​

8. Prefer nullptr to 0 and NULL

리터럴 0은 int이지 포인터가 아니고, 포인터만 사용할 수 있는 위치에 0이 있으면, C++은 마지못해 널 포인터로 해석하지만 이는 좋다고 말하기는 힘듭니다. 이는 NULL도 마찬가지로 구체적인 형식이 아닐 뿐더러 포인터 형식이 아닙니다.

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

f(0);    // f(int) 호출
f(NULL); // 컴파일되지 않을 수도 있지만, f(int) 호출

NULL의 경우는 구체적인 형식의 구현의 재량에 맡겨져 있기에 결과가 다르게 나올 수 있습니다. 이와 같이 포인터 형식임을 자명하게 말하기 위해서는 nullptr을 사용하는 것이 좋습니다.

 nullptr의 장점은 이것이 정수 형식이 아니라 std::nullptr_t라는 형식을 지니고 있다는 점 입니다. nullptr는 모든 형식의 포인터라고 생각하면 됩니다. std::nullptr_t는 모든 생 포인터 형식으로 암묵적으로 변환되며, 이 덕분에 nullptr는 마치 모든 형식의 포인터처럼 행동합니다. 이는 많이 중의성에서 벗어날 수 있습니다.

auto result = findRecord(...);

if (result == 0) {       // result가 NULL을 의미하는 지 값이 0인지 불명확
  ...
};

if (result == nullptr) { // result가 포인터 형식임이 명백
  ...
};

위와 같이 result 값의 중의성을 해결할 수 있습니다. 또한 이는 템플릿이 관여할 때 더욱 빛납니다.

template<typename FuncType, typename MuxType, tpyename PtrType>
decltype(auto) lockAndCall(FuncType func, MuxType& mutex, PtrType ptr)
{
  using MuxGuard = std::lock_guard<MuxType>;
  
  MuxGuard g(mutex);
  return func(ptr);
}

 다음의 경우 ptr에 0과 NULL을 넣는 경우는 에러를 내뱉는데 이는 0과 NULL이 들어가면 형식 연역을 통해 ptr의 형식이 int가 되며, 이는 func이 기대하는 포인터 형식이 아니여서 매개변수와 호환되지 않아 컴파일 오류가 납니다.

 nullptr를 이용한 호출은 ptr의 형식은 std::nullptr_t로 연역이 되며 이는 암묵적으로 포인터 형석으로 변환이 가능해집니다.

이것만은 잊지 말자!

  • 0과 NULL보다 nullptr를 선호하라.
  • 정수 형식과 포인터 형식에 대한 중복적재를 피하라.​

9. Prefer alias declarations to typedefs

typedef는 C++98이며, C++11에서는 별칭 선언(alias declaration)을 제공합니다. 이 둘은 기본적으로 거의 비슷하지만, 별칭 선언이 조금 더 이해하기 쉬우며, typedef는 템플릿화할 수 없지만 별칭 선언은 템플릿화할 수 있습니다.

// typedef를 활용한 C++98 방식
template<typename T>
struct MyAllocList {
  typedef std::list<T, MyAlloc<T>> type;
};

MyAllocList<Widget>::type lw;         // 클라이언트 코드

template<typename T>
class Widget {
private:
  typename MyAllocList<T>::type list; // typename 필수
};

// using을 활용한 C++11 방식
template<typename T>
using MyAllocList = std::list<T, MyAlloc<T>>;

MyAllocList<Widget> lw;               // 클라이언트 코드

template<typename T>
class Widget {
private:
  myAllocList<T> list;                // typename 필요 X
};

위와 같이 별칭 선언을 이용했을 때는 템플릿화가 더 잘 되어 구조체로 만들어줄 필요도 없을 뿐더러 ::type 접미어가 불필요하며, 타입임이 자명하기에 typename을 통해 타입임을 명시해줄 필요도 없습니다.

 또한 형식 특질(type traits)에 대해서 C++11에서는 템플릿화된 struct안에 내포된 typdef들로 구현되어 std::변환<T>::type 형태로 불러와야 하지만, C++14에서는 별칭 템플릿 버전을 포함시켜 std::변환_t 형태를 지원합니다.

std::remove_const<T>::type
std::remove_const_t<T>

std::remove_reference<T>::type
std::remove_referenct_t<T>

std::add_lvalue_reference<T>::type
std::add_lvalue_referenct_t<T>

이것만은 잊지 말자!

  • typedef는 템플릿화를 지원하지 않지만, 별칭 선언은 지원한다.
  • 빌칭 템플릿에서는 "::type" 접미어를 붙일 필요가 없다. 템플릿 안에서 typedef를 지칭할 때에는 "typename" 접두사를 붙어야​ 하는 경우가 많다.
  • C++14는 C++11의 모든 형식 특질 변환에 대한 빌칭 템플릿들을 제공한다.

10. Prefer scoped enums to unscoped enums

 일반적인 규칙은, 한 중괄호 쌍 안에서 어떤 이름을 선언하면 그 이름의 가시성은 해당 중괄호 쌍이 정의하는 범위로 한정된다는 것입니다. 그런데 C++98 스타일의 enum으로 선언된 열거자(enumerator)들에 대해서는 그러한 일반적인 규칙이 적용되지 않습니다. 그런 열거자 이름들은 enum을 포함하는 범위에 속하며, 이런 종규의 enum을 공식적으로 범위없는 enum(unscoped enum)이라고 부릅니다. 이에 대응되는 C++11의 새로운 열거형인 범위있는 enum(scoped enum)에서는 그러한 이름 누수가 발생하지 않습니다.

enum Color { black, white, red };       // Color가 속한 범위에 속함
auto white = false;                     // Error! 범위에 이미 white 존재

enum class Color { black, white, red }; // Color 범위에 속함
auto white = false;                     // OK!
Color c = white;                        // 범위에 white가 존재하지 않음
Color c = Color::white;                 // OK!

 범위있는 enum에는 또 다른 강력한 장점이 있는데 열거자들에 형식이 훨씬 강력하게 적용된다는 점입니다. 범위있는 enum의 열거자는 암묵적으로 다른 형식으로 변환되지 않습니다.

enum class Color { black, white, red }; // 범위 한정사가 존재함  
std::vector<std::size_t> primeFactors(std::size_t x);

Color c = Color::red;

if (c < 14.5) {                         // Error! Color과 double 비교 불가능
  auto factors = primeFactors(c);       // Error! Color를 size_t에 전달 불가능
   // 만약 수행하려면 auto factors = primeFactors(static_cast<std::size_t>(c));
  ...
};

범위있는 enum의 세번째 장점은 바로 전방 선언(forward declaration)이 가능하다는 점 입니다.

enum Color;         // Error!
enum class Color;   // OK!

 enum의 경우는 주어진 열거자 값들의 범위를 표현할 수 있는 가장 작은 바탕 형식을 선택하는 경향이 존재하며, 크기 대신 속도를 위한 최적화를 적용하는 경우도 있습니다. 이렇게 하기 위하여 정의만 지원하고 선언은 허용하지 않습니다. 이는 컴파일 의존 관계가 늘어나 열거자에 새로운 값을 추가하게 된다면 시스템 전체를 다시 컴파일해야 합니다.

 이에 비해 enum Class는 전방 선언이 가능하며, 행동에 영향을 주지 않는다면 다시 컴파일할 필요도 없습니다. 이를 위해 범위 있는 enum의 바탕 형식은 기본적으로 int이며 바꾸기 위해서는 enum class Status: std::uint32_t와 같이 명시적으로 지정하면 됩니다. 이는 범위 없는 enum에서도 가능하며, 이를 통해 전방 선언이 가능해지게 됩니다.

 범위 없는 enum이 유용한 경우는 범위 있는 enum을 사용했을 때 사용법 및 코드가 장황해지는 경우가 존재하여 이를 더 편하게 작성하기 위해 사용이 가능합니다.

이것만은 잊지 말자!

  • C++98 스타일의 enum을 이제는 범위없는 enum이라고 부른다.
  • 범위있는 enum의 열거자들은 그 안에서만 보인다. 이 열거자들은 오직 캐스팅을 통해서만 다른 형식으로 변환된다.​
  • 범위있는 enum과 범위없는 enum 모두 바탕 형식 지정을 지원한다. 범위있는 enum의 기본 바탕 형식은 int이다. 범위없는 enum에는 기본 바탕 형식이 없다.
  • 범위있는 enum은 항상 전방 선언이 가능하다. 범위없는 enum은 해당 선언에 바탕 형식을 지정하는 경우에만 전방 선언이 가능하다.

11. Prefer deleted functions to private undefined ones

기본적으로 생성되는 복사 생성자 및 복사 배정 연산자의 경우, C++98에서 이들을 사용하지 않기 위해서는 private으로 넘겨 선언은 하되 정의는 하지 않는 방법을 채택했습니다.

 하지만 C++11에서는 =delete를 붙여 함수를 삭제된 함수(deleted function)로 명시하면 됩니다. 삭제된 함수는 private이 아니라 public으로 선언하는 것이 관례인데, 이를 사용할 때 C++은 먼저 함수의 접근성을 점검 후에 삭제 여부를 점검합니다. 따라서 접근성이 먼저 문제가 되는 경우가 존재해서 오해의 여지가 생깁니다. 만약 함수 overloadding에서 필요한 형식의 인자를 제외한 다른 형식을 배제하고 싶은 경우 delete를 통해 이를 수행할 수 있습니다.

bool isLucky(int number);
bool isLucky(char) = delete;   // char 배제
bool isLucky(bool) = delete;   // bool 배제
bool isLucky(double) = delete; // double과 float 배제

if (isLucky('a')) ...  // Error!
if (isLucky(true)) ... // Error!
if (isLucky(3.51)) ... // Error!
if (isLucky(3)) ...    // GOOD!

 또 다른 요령으로는 원치 않은 템플릿 인스턴스화를 방지할 수 있습니다.

template<typename T>
void processPointer(T* ptr);

template<>
void processPointer(void *) = delete;

template<>
void processPointer(char *) = delete;

 또한, 클래스 안의 함수 템플릿의 일부 객체화를 방지하기 위해 private 선언 접근방식을 사용할 수도 있는데, 이는 같은 클래스 범위가 아니라 이름 공간 범위에서 작성해야 하기 때문에, 이러한 경우는 반드시 public에서 선언해주어야 합니다.

이것만은 잊지 말자!

  • 정의되지 않은 비공개 함수보다 삭제된 함수를 선호하라.
  • 비멤버 함수와 템플릿 인스턴스를 비롯한 그 어떤 함수도 삭제할 수 있다.​

12. Declare overriding functions override

재정의가 일어나려면 다음과 같은 여러 필수조건을 만족해야 합니다.

  • 기반 클래스 함수가 반드시 가상 함수이어야 합니다.
  • 기반 함수와 파생 함수의 이름이 반드시 동일해야 합니다(단, 소멸자는 예외).
  • 기반 함수와 파생 함수의 매개변수 형식들이 반드시 동일해야 합니다.
  • 기반 함수와 파생 함수의 const성이 반드시 동일해야 합니다.
  • 기반 함수와 파생 함수의 반환 형식과 예외 명세가 반드시 호환되어야 합니다.

위는 C++98 기준이며, C++11 기준으로는 다음의 조건 하나가 더 충족되어야 합니다.

  • 멤버 함수들의 참조 한정사(reference qualifier)들이 반드시 동일해야 합니다. 멤버 함수 참조 한정사는 멤버 함수를 좌측값에만 또는 우측값에만 사용할 수 있게 제한하는 기능입니다.

 이러한 요구 조건들이 의미하는 것은 작은 실수로 인해서 overriding되지 않고 새로운 함수의 정의로 직결될 수도 있다는 점입니다. 따라서 이러한 문제를 막기 위하여, C++11은 파생 클래스 함수가 기반 클래스의 버전을 재정의하려 한다는 의도를 클래스 함수를 override로 선언하여 명시적으로 표현하는 방법을 제공한다. 이에 대한 잘못된 예시 및 올바른 예시는 다음과 같이 됩니다.

class Base {
public:
  virtual void mf1() const;
  virtual void mf2(int x);
  virtual void mf3() &;
  virtual void mf4() const;
};

class WrongDerived : public Base {
public:
  virtual void mf1();               // const성 차이
  virtual void mf2(unsigned int x); // 인자 타입 다름
  virtual void mf3() &&;            // this가 오른값으로 한정된 경우 발동되어 위와 다름
  void mf4() const;                 // 만약 Base mf4가 virtual이 없다면 overriding 불가
};

class RightDerived : public Base {
public:
  virtual void mf1() const override;
  virtual void mf2(int x) override;
  virtual void mf3() & override;
  void mf4() const override; 
};

별도로 왼쪽값 중복적재와 오른쪽값 중복적재는 좀더 효율적으로 코드를 구성할 수 있도록 만들어준다.

이것만은 잊지 말자!

  • 재정의 함수는 override로 선언하라.
  • 멤버 함수 참조 한정자를 이용하면 멤버 함수가 호출되는 객체(*this)의 좌측값 버전과 우측값 버전을 다른 방식으로 처리할 수 있다.​

13. Prefer const_iterators to iterators

C++98에서는 const_iterator을 사용하는 것이 꽤나 힘들었습니다. 비 const 컨테이너에서 const_iterator을 얻는 간단한 방법이 존재하지 않았을 뿐더러, 삽입 삭제를할 때 const_iterator이 사용 불가능하여 iterator로의 변환을 필요로 합니다. 또한 const_iterator에서 iterator로의 이식성 있는 변환이 존재하지 않기 때문입니다.

 C++11에서는 이러한 상황이 바뀌어져서, cbegin 및 cend를 통해 const_iterator을 얻기 쉽고 삽입 및 삭제에 사용하기도 쉬워졌습니다.

 C++11에서 최대한 일반적인 코드를 작성하고 싶다면, 비멤버 버전을 멤버 함수들보다 선호하는 것이 좋습니다.

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

 const_iterator을 사용하는 이유는 const를 사용하는 것이 의미가 있는 경우 const를 항상 사용하는 것이 바람직한데, 이는 C++11에 들어와서 더 행하기 쉬워졌습니다.

이것만은 잊지 말자!

  • iterator보다 const_iterator를 선호하라.
  • 최대한 일반적인 코드에서는 begin, end, rbegin등의 비멤버 버전들을 해당 메버 함수들보다 선호하라.​

14. Declare functions noexcept if they won't emit exceptions

C++98에서는 예외 명세를 위해 방출할 예외 형식들을 요약해야 했으며, 함수 구현에 따라 예외 명세가 변하면 상요자의 코드도 깨질 수가 있게 된다. 따라서 선호되지 않았습니다. 하지만 C++11 제정 과정에서, 함수의 예외 방출 행동에 관해 정말로 의미 있는 정보는 함수가 예외를 하나라도 방출하는지의 여부라는 점에 대한 공감대가 형성되어, 예외를 하나라도 던질 수 있는지 아니면 절대로 던지지 않는지라는 이분법적 정보를 제공하게 되었습니다. C++11에서 함수 선언 시 그 함수가 예외를 방출하지 않을 것임을 명시할 때에는 noexcept라는 키워드를 사용하면 됩니다.

 함수를 noexcept로의 선언 여부는 인터페이스 설계상의 문제입니다. 함수 호출자는 함수의 noexcept 여부 조회에 따라 효율성 및 예외 안정성에 영향을 미칩니다. 예외를 만들지 않는 함수에 noexcept를 적용하는 것에는 또 다른 장점은 컴파일러가 더 나은 목적 코드를 산출할 수 있다는 점입니다.

 C++98에서는 함수 f에서 예외 명세가 위반되면 호출 스택이 f를 호출한 지점에 도달할 때까지 풀리며, 몇가지 동작을 취합니다. 하지만 C++11에서 예외 명세가 위반되어 프로그램 실행이 종료되기 전에 호출 스택이 풀릴 수도 있고 풀리지 않을 수도 있는데, 이는 noexcept 함수에서 컴파일러 최적화를 통해 예외가 전파될 수 있다고 해도 스택을 풀기 가능 상태로 유지할 필요가 없습니다. 또한 벗어나도 noexcept 함수 안의 객체들을 반드시 순서대로 파괴해야하는 것도 아닙니다. 하지만 thorw()나 예외 명세가 없는 경우에는 최적화 유연성이 없기 떄문에, noexcept를 사용한다면 이들에 비해 최적화 여지가 커집니다.

 noexcept에 영향을 받는 함수도 상당히 있습니다. std::vector::push_back 및 표준 라이브러리의 여러 함수는 가능하면 이동하되 필요하면 복사하는 전략을 활용합니다. 이런 함수들은 모두, 오직 이동 연산이 예외를 방출하지 않음이 알려진 경우에만 C++98의 복사 연산을 C++11의 이동 연산으로 대체합니다. 이동 연산이 예외를 방출하지 않음을 함수가 알아내기 위해서는 주어진 연산이 noexcept로 선언되어 있는지를 점검하면 됩니다.

 또한, 표준 라이브러리에 있는 swap들의 noexcept 여부는 사용자 정의 swap들의 noexcept 여부에 어느 정도 의존합니다. 예를 들어 다음은 표준 라이브러리에 있는 배열에 대한 swap과 std::pair에 대한 swap의 선언들입니다.

template<class T, size_t N>
void swap(T (&a)[N], T (&b)[N]) noexcept(noexcept(swap(*a, *b)));

template<class T1, class T2>
struct pair {
  // ...
  void swap(pair& p) noexcept(noexcept(swap(first, p.first)) && 
                                    noexcept(swap(second, p.second)));
};

 대부분의 함수는 예외에 중립적입니다. 스스로는 예외를 던지지 않지만, 예외를 던지는 다른 함수들을 호출할 수는 있습니다. 예외를 전혀 방출하지 않는 것이 자연스러운 구현인 함수들도 있으며, noexcept로 선언하면 최적화에 큰 도움이 되는 함수들도 많습니다. 하지만 noexcept를 선언하기 위해서 함수의 구현을 작위적으로 비트는 것은 상태 부호나 특별한 반환값을 많이 반환 및 점검해야 하는데 이는 ㅎ마수의 구현이 복잡해질 뿐만 아니라 호출 지점의 코드도 복잡해질 수 있어 이가 유발하는 실행시점 비용이 noexcept를 통한 최적화 성능을 넘어갈 수도 있습니다.

noexcept로 선언하는 것이 아주 중요한 일부 함수들은 기본적으로 noexcept로 선언됩니다. 기본적으로 모든 메모리 해제 함수와 모든 소멸자는 암묵적으로 noexcept입니다. 따라서 그런 함수들은 직접 noexcept로 선언할 필요가 없습니다. 소멸자가 암묵적으로 noexcept로 선언되지 않는 유일한 경우는, 예외 방출 가능성을 명시적으로 밝힌 소멸자를 가진 형식의 자료 멤버가 클래스에 있을 때 뿐입니다.

 인터페이스 설계자들 중에는 넓은 계약들을 가진 함수와 좁은 계약을 가진 함수를 구분하는 사람이 있습니다. 넓은 계약은 전제 조건이 없는 함수이며, 호출자가 전달하는 인수들에 대해 어떤 제약을 가하지 않아 미정의 행동을 보이지 않습니다. 넓은 계약이 아닌 함수는 모두 좁은 계약 인데, 넓은 계약의 경우는 noexcept를 선언하는 것이 쉽습니다. 하지만 이를 위해서는 넓은 계약인지를 좀 더 확실하게 알고 나서 선언을 해야만 합니다.

 또한, noexcept 함수는 적법한 이유로 noexcept 보장이 없는 코드에 의존하는 경우가 존재합니다.

이것만은 잊지 말자!

  • noexcept는 함수의 인터페이스의 일부이다. 이는 호출자가 noexcept 여부에 의존할 수 있음을 뜻한다.
  • noexcept 함수는 비noexcept 함수보다 최적화의 여지가 크다.
  • noexcept는 이동 연산들과 swap, 메모리 해제 함수들, 그리고 소멸자들에 특히나 유용하다.
  • 대부분의 함수는 noexcept가 아니라 예외에 중립적이다.​

15. Use constexpr whenever possible

개념적으로, constexpr은 어떠한 값이 단지 상수일 뿐만 아니라 컴파일 시점에서 알려진다는 점을 나타냅니다. constexpr​이 객체에 적용된 경우에는 객체는 실제로 const이며, 그 값은 실제로 컴파일 시점에서 알려집니다. 간단히 말해서, 모든 constexpr 객체는 const이지만 모든 const 객체가 constexpr인 것은 아닙니다. 어떤 변수의 값을 반드시 컴파일 시점 상수를 요구하는 문맥에서 사용할 수 있어야 하는 경우에는 constexpr을 사용해야 합니다.

int sz;                                 // 비 constexpr 변수
...
constexpr auto arraySize1 = sz;         // error, sz의 값이 컴파일 도중에 알려지지 않음
std::array<int, sz> data1;              // error, 위와 동일

constexpr auto arraySize2 = 10;         // 10은 상수
std::array<int, arraySize2> data2;      // arraySize2는 constexpr 객체

 constexpr 함수는 컴파일 시점 상수를 인수로 해서 호출된 경우에는 컴파일 시점 상수를 산출합니다. 실행시점이 되어서야 알려지는 값으로 호출하면 실행시점 값을 산출합니다. 이 함수에 대한 올바른 관점은 다음과 같습니다.

  • 컴파일 시점 상수를 요구하는 문맥에 constexpr 함수를 사용할 수 있습니다. 그런 문맥에서, 만일 constexpr 함수에 넘겨주는 인수의 값이 컴파일 시점에서 알려진다면, 함수의 결과는 컴파일 도중에 계산되고, 인수의 값이 컴파일 시점에서 알려지지 않는다면, 코드의 컴파일이 거부됩니다.
  • 컴파일 시점에서 알려지지 않는 하나 이상의 값들로 constexpr 함수를 호출하면 함수는 보통의 함수처럼 작동합니다. 즉, 그 결과는 실행시점에서 계산됩니다. 이는 같은 연산을 수행하는 함수를 두 버전, 즉 컴파일 시점 상수를 위한 버전과 다른 모든 값을 위한 버전으로 나누어서 구현할 필요가 없음을 뜻합니다. 그냥 하나의 constexpr 함수를 두 가지 용도로 사용하면 된다.

 ​constexpr 함수는 컴파일 시점 값들로 호출했을 때 반드시 컴파일 시점 결과를 산출할 수 있어야 하므로, 구현에 일정한 제약들이 따릅니다. C+11에서는 실행 가능 문장이 많아야 하나이어야 합니다. 하지만 C++14에서는 constexpr 함수에 대한 제약이 상당히 느슨해져서, 다음과 같은 구현이 허용됩니다.

constexpr int pow(int base, int exp) noexcept
{
  auto result = 1;
  for (int i = 0; i < exp; ++i) result *= base;
  return result;
}

 constexpr 함수는 반드시 리터럴 형식(literal type)들을 받고 돌려주어야 한다. 리터럴 형식은 컴파일 도중에 값을 결정할 수 있는 형식이다. C++11에서 void를 제외한 모든 내장 형식이 러터럴 형식에 해당한다. 그리고 생성자와 적절한 멤버 함수들이 constexpr인 사용자 형식도 리터럴 형식이 될 수 있다.

class Point {
public:
    constexpr Point(double xVal = 0, double yVal = 0) noexcept
    : x(xVal), y(yVal)
    {}
    
    constexpr double xValue() const noexcept { return x; }
    constexpr double yValue() const noexcept { return y; }
    
    void setX(double newX) noexcept { x = newX; }
    void setY(double newY) noexcept { y = newY; }
    
private:
    double x, y;
};

 생성자를 constexpr인 이유는, 주어진 인수들이 컴파일 시점에서 알려진다면 생성된 Point 객체의 자료 멤버들의 값 역시 컴파일 시점에서 알려질 수 있기에, 객체 자체가 constexpr 객체가 될 수 있으며, 이를 조회하는 멤버함수 역시 constexpr로 선언되어 컴파일 도중에 알수 있습니다,. 이를 호출한 결과들로 또 다른 constexpr 객체를 초기화하는 constexpr 함수도 작성할 수 있습니다. 이런 식으로 컴파일 시점으로 많이 옮길 수록 컴파일 시간이 길어지지만 소프트웨어는 더 빨리 실행됩니다.

 C++ 11에서는 Point 멤버 함수를 constexpr로 선언할 수 없는데, constexpr 멤버 함수가 암묵적으로 const로 선언되기도 하며, void는 리터럴 형식이 아니라 불가능합니다. 하지만 C++14에서는 이와 같은 제한이 다 없어져서 constexpr로 선언이 가능합니다.

 이렇게constexpr 객체와 함수 모두 비constexpr 객체나 함수보다 더 넓은 문맥에서 사용할 수 있습니다. 또한 이는 인터페이스의 일부로서 작동합니다.

이것만은 잊지 말자!

  • constexpr 객체는 const이며, 컴파일 도중에 알려지는 값들로 초기화된다.
  • constexpr 함수​는 그 값이 컴파일 도중에 알려지는 인수들로 호출하는 경우에는 컴파일 시점 결과를 산출한다.
  • constexpr 객체나 함수는 비constexpr 객체나 함수보다 광범위한 문맥에서 사용할 수 있다.
  • constexpr은 객체나 함수의 인터페이스의 일부이다.

16. Make const member functions thread safe

여러 스레드가 하나의 객체 대해 어떤 const 멤버 함수를 동시에 실행하려는 경우, const 멤버 함수를 항상 스레드에 안전하도록 만들어야 합니다. 다음은 다항식의 근을 구는 함수 roots를 구현하는 상황입니다. 성능 향상을 위해 캐싱을 이용하였으며, rootsAreValid 의 값을 이용하여 캐싱 여부를 판별하는 함수입니다.

class Polynomial {
public:
    using RootsType = std::vector<double>;
    
    RootsType roots() const
    {
        if(!rootsAreValid) {
            ...
            rootsAreValid = true;
        }
    }
    
private:
    mutable bool rootsAreValid{ false };
    mutable RootsType rootVals{};
};

 

[mutex]

 여기서 다수의 스레드가 roots 함수를 호출하는 경우, roots 안에서 두 자료 멤버 모두가 rootsAreValid 와 rootVals를 수정하려드는 경쟁 상태를 유발할 수 있어, 이를 방지하기 위한 방법으로 mutex가 존재합니다.

class Polynomial {
public:
    using RootsType = std::vector<double>;
    
    RootsType roots() const
    {
        std::lock_guard<std::mutex> g(m);
        if(!rootsAreValid) {
            ...
            rootsAreValid = true;
        }
    }
    
private:
    mutable std::mutex m;
    mutable bool rootsAreValid{ false };
    mutable RootsType rootVals{};
};

 하지만 이 경우 mutex를 추가함으로서, 이제 Polynomial 객체는 복사와 이동이 불가능하여, mutex의 도입이 과해보일 수도 있습니다.

[atomic]

 mutex 를 대신하여 atomic 을 사용하는 방법도 존재하지만, 이 방법에도 문제가 발생 할 수 있습니다.

class Widget {
public:
    ...
    int magicValue() const
    {
        if (cacheValid) return cachedValue;
        else {
            auto val1 = expensiveComputation1();
            auto val2 = expensiveComputation2();
            cachedValue = val1 + val2;
            cacheValid = true;
            return cachedValue;
        }
    }
private:
    mutable std::atomic<bool> cacheValid{ false };
    mutable std::atomic<int> cachedValue;
};

 atomic 사용함으로써 이제 스레드에는 안전해졌다. 그런데 문제는 동시에 여러개의 스레드가 magicValue() 에 접근하게 되면 비싼 계산을 하는 두 개의 함수들을 모든 스레드가 사용하게 되어 성능적으로 큰 영향을 끼치게 됩니다. cachedValid를 위치를 위쪽으로 옮기는 경우에는 정의되지 않은 cachedValue를 반환할 수 있어 문제가 더욱 커집니다. 이를 막기 위해서는 결과적으로 mutex 를 사용할 수 밖에 없습니다.

 따라서 동기화가 필요한 변수 하나 또는 메모리 장소 하나에 대해서는 std::atomic을 사용하는 것이 적합하지만, 둘 이상의변수나 메모리 장소를 하나의 단위로서 조작해야 한다면 mutex를 활용하는 것이 좋습니다.

이것만은 잊지 말자!

  • 동시적 문맥에서 쓰이지 않을 것이 확실한 경우가 아니라면, const 멤버함수는 스레드에 안전하게 작성하라.
  • std::atomic 변수는 뮤텍스에 비해 성능상이 이점이 있지만, 하나의 변수 또는 메모리 장소를 다룰 때에만 적합하다.

17. Understand special member function generation

 C++의 공식적인 어법에서 특수 멤버 함수(special member function)들은 C++이 스스로 기꺼이 작성하는 멤버 함수들을 가리킵니다. C++98에서는 기본 생성자, 소멸자, 복사 생성자, 복사 배정 연산자가 이에 해당하며 규칙은 밑에 쓰여져 있습니다.

 C++11은 특수 멤버 함수에 이동 생성자와 이동 배정 연산자를 추가하였습니다. 이들의 작성과 행동방식을 관장하는 규칙들은 복사 버전들의 규칙들과 비슷하며 이동 연산들은 필요할 때에만 작성되며, 작성되는 경우에는 클래스의 비정적 자료 멤버들에 대해 멤버별 이동을 수행합니다.

 이동 연산의 경우 이동이 활성화된 형식의 경우에는 std::move를 수행한 경우 복사 연산을 통해서 이동되며, 실제로 이동이 일어나지는 않습니다. 이동 연산들의 자동 생성 조건은 아래와 같습니다.

[기본 생성자]
C++98 규칙들과 같습니다. 사용자 선언 생성자가 없는 경우에만 자동으로 작성됩니다.

[소멸자]
C++98 규칙들과 같습니다. 사용자 선언 소멸자가 없는 경우에만 자동으로 작성됩니다. 차이점은 기본적으로 noexcept입니다. 또한 C++98처럼 기반 클래스 소멸자가 가상일 때에만 가상으로 선언됩니다.

[복사 생성자]
C++98 규칙들과 같습니다. 사용자 선언 복사 생성자가 없는 경우에만 자동으로 작성됩니다. 만약 이동 연산이 하나라도 선언되어 있으면 비활성화됩니다.

[복사 배정 연산자]
C++98 규칙들과 같습니다. 사용자 선언 복사 배정 연산자가 없는 경우에만 자동으로 작성됩니다. 만약 이동 연산이 하나라도 선언되어 있으면 비활성화됩니다.

[이동 생성자와 이동 배정 연산자]
클래스에 사용자 선언 복사 연산들과 이동 연산들, 소멸자가 없을 때에만 자동으로 작성됩니다. 

 

3의 법칙이라고 부르는 지침이 있어, 이는 복사 생성자와 복사 배정 연산자, 소멸자 중 하나라도 선언했다면 나머지 둘도 선언하라는 의미입니다. 이는 보통 셋 중 하나를 정의하는 경우는 자원의 관리에 관련되어 있고 이는 나머지 둘에게도 해당되기 때문에 해줄꺼면 셋다 해주어라는 뜻입니다. 이는 이동 연산에도 똑같이 추론됩니다. 만약 이들을 자동으로 생성하기 위해서는 =default를 이용하여 명시적으로 표현 가능합니다. 이와 같이 =default와 =delete를 통해 인터페이스에 대해 좀 더 명시적으로 보여줄 수 있으며, 위의 규칙을 통해 자동으로 생성하던 함수가 사라지는 등의 불상사를 막을 수 있습니다.

이것만은 잊지 말자!

  • 컴파일러가 스스로 작성할 수 있는 멤버 함​수들, 즉 기본 생성자와 소멸자, 복사 연산들, 이동 연산들을 가리켜 특수 멤버 함수라고 부른다.
  • 이동 연산들은 이동 연산들이나 복사 연산들, 소멸자가 명시적으로 선언되어 있지 않은 클래스에 대해서만 자동으로 작성된다.
  • 복사 생성자는 복사 생성자가 명시적으로 선언되어 있지 않은 클래스에 대해서만 자동으로 생성되며, 만일 이동 연산이 하나라도 선언되어 있으면 삭제된다. 복사 배정 연산자는 복사 배정 연산자가 명시적으로 선언되어 있지 않은 클래스에 대해서만 자동으로 작성되며, 만일 이동 연산이 하나라도 선언되어 있으면 삭제된다. 소멸자가 명시적으로 선언된 클래스에서 복사 연산들이 자동 작성되는 기능은 비권장이다.
  • 멤버 함수 템플릿 때문에 특수 멤버 함수의 자동 작성이 금지되는 경우는 전혀 없다.

'C++공부 > Effective Modern C++' 카테고리의 다른 글

Chapter 6. Lambda Expressions  (0) 2022.05.27
Chapter 5. Rvalue References, Move Semantics, and Perfect Forwarding  (0) 2022.05.27
Chapter 4. Smart Pointers  (0) 2022.05.26
Chapter 2. auto  (0) 2022.05.26
Chapter 1. Deducing Types  (0) 2022.05.26

5. Prefer auto to explicit type declarations

먼저 auto를 통해서 얻을 수 있는 이점은 다음과 같습니다.

[초기화]

선언만 하고 초기화를 하지 않은 경우를 방지 가능합니다

int x1;      // 문맥에 따라 위험함
auto x2;     // 오류
auto x3 = 0; // 양호함

[복잡한 이름의 간략화]

C++14의 경우는 형식 뿐만 아니라 람다 표현식의 매개변수에도 auto를 적용시켜 사용 가능하여 코드가 간략화됩니다.

auto derefLess = [](const auto& p1, const auto& p2)
                  { return *p1 < *p2; };

또한 반복적인 변수 타입을 적는 것을 방지할 수 있습니다. 특히, std::function을 통한 구현과 auto를 통한 클로저의 구현에서, auto의 경우는 클로저에 요구되는 만큼의 메모리만을 사용하지만, std::function의 경우에는 std::function 템플릿의 한 인스턴스이며 크기가 고정되어 있는데 이 크기가 부족한 경우에는 따로 힙 메모리를 할당하여 클로저를 저장합니다. 결과적으로 대체로 std::function 객체는 auto로 선언된 객체보다 메모리를 더 많이 소비하며, 인라인화 제한 및 간접 함수 호출을 산출하기 때문에 더 느립니다.

 또 다른 장점으로는 포인터나 size_type의 반환 값을 받을 때 이를 부호 없는 정수 형식으로 받게 되면, 32비트 Window에서는 돌아가는 것이 64비트 Window에서 돌아가지 않을 수 있습니다. 즉 이러한 환경 변화에도 훨씬 유연한 편입니다.

 또한 아래 코드와 같이 코드 형식의 불일치로 인해 불필요한 변환(std::pair<const std::string, int>에서 const std::pair<std::string, int>로의 변환이 필요함)을 만들어 낼수 있지만, auto를 사용하면 이러한 일이 없어집니다.

std::unordered_map<std::string, int> m;
...
for (const std::pair<std::string, int>& p : m) // std::pair<const std::string, int>&가 되어야 함
{
  ...
}

이것만은 잊지 말자!

  • auto 변수는 반드시 초기화해야 하며, 이식성 또는 효율성 문제를 유발할 수 있는 형식 불일치가 발생하는 경우가 거의 없으며, 대체로 변수의 형식을 명시적으로 지정할 때보다 타자량도 더 적다.
  • auto로 형식을 지정한 변수는 항목 2와 항목 6에서 설명한 문제점들을 겪을 수 있다.​

6. Use the explicitly typed initializer idiom when auto deduces undesired types

대체로 대리자 클래스(proxy class)의 경우는 기저에 다른 클래스가 존재하여 auto의 형식 연역이 예상과는 다르게 돌아갑니다. 그런 클래스는 해당 객체의 수명이 한 문장 이상으로 연장되지는 않는다는 가정하에서 설계되는 경우가 많으며, 따라서 그런 형식의 변수를 생성하는 것은 라이브러리 설계의 근본적인 가정들을 위반하는 경향이 있습니다. 아래의 코드에서 이를 설명합니다.

std::vector<bool> features(const Widget& w);

bool highPriority = features(w)[5];	                   // 기존 코드
auto highPriority = features(w)[5];                    // 미정의 행동
                                                       // std::vector<bool>::reference에 의존
auto highPriority = static_cast<bool>(features(w)[5]); // bool로 명시

std::vector<bool>의 경우는 bool당 1비트의 압축된 형태를 표현하도록 명시되어 있어, std::vector<bool>::reference 객체를 통해 표현하는데, auto로 값을 받아오게 되면, 이 객체를 그대로 들고 오게 되면서 다음 행동이 미정의 행동이 됩니다. 따라서 이를 해결하기 위해서는 static_cast와 같이 auto가 다른 형식을 연역하도록 강제해야 합니다. 또한, 이렇게 강제를 하면, 코드를 읽는 사람에게 확실하게 보여주기 때문에 코드의 흐름을 읽기 쉬워집니다.

 

이것만은 잊지 말자!

  • "보이지 않는" 대리자 형식 때문에 auto가 초기화 표현식의 형식을 "잘못" 연역할 수 있다.
  • 형식 명시 초기치 관용구는 auto가 원하는 형식을 연역하도록 강제한다.​

1. Understand template type deduction

 

템플릿에서 템플릿 인자 T에 대한 형식 연역은 ParamType 및 expr에 의존합니다. 이는 ParamType에 따라 크게 3가지 경우의 수로 나뉘어 질 수 있습니다.

template<typename T>
void f(ParamType param);

f(expr);

[경우 1. ParamType - T& or T*]

  1. 만일 expr가 참조 형식이면 참조 부분을 무시합니다.
  2. 그런 다음 expr의 형식을 ParamType에 대해 패턴 부합 방식으로 대응시켜서 T의 형식을 결정합니다. 

객체의 const성은 유지되며, 참조성은 무시되게 됩니다.

 

 template<typename T>
 void f(T& param);          // param은 참조 형식

 int x = 27;                // x는 int
 const int cx = x;          // cx는 const int
 const int& rx = x;         // rx는 const int인 x에 대한 참조

 f(x);                      // T는 int, param의 형식은 int&
 f(cx);                     // T는 const int, param의 형식은 const int&
 f(rx);                     // T는 const int, param의 형식은 const int&

[경우 2. ParamType - T&&]

  1. 만약 expr가 왼값이면, T와 ParamType 둘다 왼값 참조로 연역됩니다.
  2. 만일 expr가 우측값이면, '정상적인"(즉 경우 1의) 규칙들이 적용됩니다.
template<typename T>
void f(T&& param);          // param은 보편 참조

int x = 27;                 // x는 int
const int cx = x;           // cx는 const int
const int& rx = x;          // rx는 const int인 x에 대한 참조

f(x);                       // x는 좌측값. T는 int&, param의 형식은 int&
f(cx);                      // cx는 좌측값. T는 const int&, param의 형식은 const int&
f(rx);                      // rx는 좌측값. T는 const int&, param의 형식은 const int&
f(27);                      // 27은 우측값. T는 int, param의 형식은 int&&

[경우 3. ParamType - T]

  1. 이전처럼, 만일 expr의 형식이 참조라면 참조 부분은 무시됩니다.
  2. expr의 참조성, volatile성 및 const성을 무시합니다.

param은 cx나 rx의 복사본이므로, 독립적인 객체로서 const성 및 다른 특성을 물려받지 않습니다. 

 

template<typename T>
void f(T param);            // 이번엔 param이 값으로 전달된다.

int x = 27;                 // x는 int
const int cx = x;           // cx는 const int
const int& rx = x;          // rx는 const int인 x에 대한 참조

f(x);                       // T는 int, param의 형식은 int
f(cx);                      // T는 int, param의 형식은 int
f(rx);                      // T는 int, param의 형식은 int

[배열 인수]

많은 문맥에서 배열이 배열의 첫 원소를 가리키는 포인터로 붕괴합니다. 따라서 포인터 매개변수처럼 취급되므로, 템플릿 함수에 값으로 전달되는 배열의 형식은 포인터 형식으로 연역됩니다.

 template<typename T>
 void f(T param);

 const char name[] = "J. P. Briggs"; // name의 형식 : const char[13]
 f(name);                            // 배열이 포인터로 붕괴되어 T는 const char*

하지만 함수의 인수를 참조로 받도록 수정하고, 함수에 배열을 전달하면, T에 대한 연역된 형식은 배열의 실제 형식으로 변합니다.

 template<typename T>
 void f(T& param);

 const char name[] = "J. P. Briggs";
 f(name);                              // T는 const char (&)[13]

배열에 대한 참조를 선언하는 능력을 이용하면, 배열에 담긴 원소들의 개수를 연역하는 템플릿도 만들 수 있습니다.

template<typename T, std::size_t N>
constexpr std::size_t arraySize(T (&)[N]) no except
{
  return N;
}

[함수 인수]

​함수 형식도 함수 포인터로 붕괴할 수 있으며, 지금까지 배열에 대해서 논의한 것은 함수에도 적용이 됩니다.

 void someFunc(int, double);       // 형식은 void(int, double)

 template<typename T>              // 값 전달
 void f1(T param);

 template<typename T>              // 참조 전달
 void f2(T& param);

 f1(someFunc);                    // param의 형식은 void (*)(int, double)
 f2(someFunc);                    // param의 형식은 void (&)(int, double)

이것만은 잊지 말자!

  • 템플릿 형식 연역 도주에 참조 형식의 인수들은 비참조로 취급된다. 즉, 참조성이 무시된다.
  • 보편 참조 매개변수에 대한 형식 연역 과정에서 왼값 인수들은 특별하게 취급된다.
  • 값 전달 방식의 매개변수에 대한 형식 연역 과정에서 const 또는 volatile 인수는 비 const, 비 volatile 인수로 취급된다.
  • 템플릿 형식 연역 과정에서 배열이나 함수 이름에 해당하는 인수는 포인터로 붕괴한다.

2. Understand auto type deduction

auto 형식 연역은 템플릿 형식 연역에 한 가지의 예외만 빼면 서로 대응된다. auto 형식 연역도 다음과 같이 세 가지 경우로 나뉩니다.

  • 경우 1: 형식 지정자가 참조 형식이지만 보편 참조는 아닌 경우
  • 경우 2: 형식 지정자가 보편 참조인 경우
  • 경우 3: 형식 지정자가 참조가 아닌 경우

[경우 1과 경우 3]

 auto x = 27;           // 경우 3
 const auto cx = x;     // 경우 3
 const auto& rx = x;    // 경우 1

 

 

[경우 2]

 auto&& uref1 = x;       // x는 int이자 좌측값이므로 uref1의 형식은 int&
 auto&& uref2 = cx;       // cx는 const int이자 좌측값이므로 uref2의 형식은 const int&
 auto&& uref3 = 27;       // 27은 int이자 우측값이므로 uref3의 형식은 int&&

[배열과 함수]

 

 const char name[] = "R. N. Briggs"; // name의 형식은 const char[13]
 auto arr1 = name;                   // arr1의 형식은 const char*
 auto& arr2 = name;                  // arr2의 형식은 const char (&)[13]

 void someFunc(int, double);         // someFunc의 형식은 void(int, double)
 auto func1 = someFunc;              // func1의 형식은 void(*)(int, double)
 auto& func2 = someFunc;             // func2의 형식은 void(&)(int, double)

[예외 사항]

 

 auto x1 = 27;        // 형식은 int
 auto x2(27);         // 마찬가지
 auto x3 = {27};      // 형식은 std::initializer_list<int>
 auto x4{27};         // 마찬가지

중괄호를 통해서 초기화나 인수를 넘겨주는 경우에 대해서, auto가 std::initializer로 형식 연역을 하게 되어버립니다. 이런 경우, int를 받아야하는데 초기화 리스트로 받게 되어 오류가 발생할 수 있습니다.

 그리고 C++14부터는 auto를 통해 함수의 반환 형식을 지정할 수 있는데, 이 때는 auto 형식 연역이 아닌 템플릿 형식 연역의 규칙들이 적용됩니다.

auto createInitList()
{
  return { 1, 2, 3 };  // 형식 연역 오류
}

이것만은 잊지 말자!

  • auto 형식 연역은 대체로 템플릿 형식 연역과 같지만, auto 형식 연역은 중괄호 초기화자가 std::initializer_list를 나타낸다고 가정하는 반면, 템플릿 형식 연역은 그렇지 않다는 차이가 있다.
  • 함수의 반환 형식이나 람다 매개변수에 쓰인 auto에 대해서는 auto 형식 연역이 아니라 템플릿 형식 연역이 적용된다.​

3. Understand decltype

C++11에서 declytype은 함수의 반황 형식이 그 매개변수 형식들에 의존하는 함수 템플릿을 선언할 때 주로 쓰입니다.

template<typename Container, typename Index>      // C++11
auto authAndAccess(Container& c, Index i) -> decltype(c[i])
{
  authenticateUser();
  return c[i];
}
 
template<typename Container, typename Index>      // C++14
auto authAndAccess(Container& c, Index i)
{
  authenticateUser();
  return c[i];
}

 C++11 버전 함수에는 후행 반환 형식 구문이 쓰였습니다. 이는 auto 형식 추론과는 관계가 없고, 함수의 반환 형식을 이 위치가 아니라 매개변수 목록("->") 다음에 선언하는 방법입니다.

 C++14 버전에서는 후행 반환 형식을 생략하고 auto만을 남겨두면, auto 형식 연역이 일어남을 뜻하는 용도로 사용됩니다. 하지만 앞서 얘기했듯이 템플릿 형식 연역 과정에서 초기화 표현식의 참조성이 무시된다는 점이 존재합니다.

std::deque<int> d;
// ...
authAndAccess(d, 5) = 10;    // 이 코드는 컴파일되지 않는다.

 위의 경우 d[5] 값을 복제한 값인 int 형식으로 추론되어 오른값에 값을 대입하게 되므로 컴파일 되지 않습니다. 이를 위해서는 decltype 형식 연역이 적용되게 만들어야하는데 이는 C++14의 decltype(auto)를 통해서 수행될 수 있도록 만들었습니다. auto는 해당 형식이 연역되어야 함을 뜻하고, decltype은 그 연역 과정에서 decltype 형식 연역 규칙들이 적용되어야 함을 뜻합니다. 이와 같은 decltype(auto)은 함수 뿐만 아니라 변수를 선언할 때도 유용하게 사용 가능합니다. 앞선 함수를 좀 더 바꾸면, 오른값 왼값에 모두 수용가능하며, 반환값을 제대로 전달하기 위해서 forward를 사용하였습니다.

template<typename Container, typename Index>
decltype(auto) authAndAccess(Container&& c, Index i)
{
  authenticateUser();
  return std::forward<Container>(c)[i];    
}

 decltype은 아주 가끔은 예상 밖의 결과를 제공합니다. decltype은 일반적으로 이름에 해당하는 선언된 형식을 산출하지만, 이름보다 복잡한 왼값 표현식에 대해서는 왼값 참조를 돌려줍니다. 이는 아래와 같은 차이를 만들어 냅니다.

int x = 0;
decltype(x) y1;   // int
decltype((x)) y2; // int&

decltype(auto) f1()
{
  int x = 0;
  ...
  return x;  // decltype(x)는 int이므로 f1은 int를 반환
}

decltype(auto) f2()
{
  int x = 0;
  ...
  return (x);  // decltype((x))는 int&이므로 f2은 int&를 반환
}

괄호가 존재함으로 인해 복잡한 표현식이 되어, 참조 형식을 반환하게 됩니다.

이것만은 잊지 말자!

  • decltype은 항상 변수나 표현식의 형식을 아무 수정 없이 보고한다.
  • decltype은 형식이 T이고 이름이 아닌 좌측값 표현식에 대해서는 항상 T& 형식을 보고한다.
  • C++14는 decltype(auto)를 지원한다. decltype(auto)는 auto처럼 초기화자로부터 형식을 연역하지만, 그 형식 연역 과정에서 decltype 규칙들을 적용한다.​

4. Know how to view deduced types

형식 연역 결과를 직접 확인하는 수단은 소프트웨어 개발 과정에서 그러한 정보가 필요한 시점에 따라 다릅니다. 세 가지 시점에서 형식 연역 정보를 얻는 방법은 다음과 같습니다.

  • IDE 편집기
  • 컴파일러의 진단 메세지
  • 실행시점 출력

이것만은 잊지 말자!

  • 컴파일러가 연역하는 형식을 IDE 편집기나 컴파일러 오류 메시지, Boost TypeIndex 라이브러리를 이용해서 ​파악할 수 있는 경우가 많다.
  • 일부 도구의 결과는 유용하지도 않고 정확하지도 않을 수 있으므로, C++의 형식 연역 규칙들을제대로 이해하는 것은 여전히 필요한 일이다.

+ Recent posts