C++공부/Effective Modern C++

Chapter 3. Moving to Modern C++

아헿헿헿 2022. 5. 26. 10:16

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를 통해 인터페이스에 대해 좀 더 명시적으로 보여줄 수 있으며, 위의 규칙을 통해 자동으로 생성하던 함수가 사라지는 등의 불상사를 막을 수 있습니다.

이것만은 잊지 말자!

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