[Effective Modern C++] Chapter 3. 현대적 C++에 적응하기 [항목 7~17]

Chapter 3. 현대적 C++에 적응하기

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

내 경험과 개인적인 의견에 따르면,,, 객체 생성 시 왠만하면 괄호를 사용하고, 클래스 내에서 멤버 변수의 기본 값을 설정할 때와 std::initializer_list 를 매개변수로 받는 생성자를 호출 할 때에만 중괄호 초기치를 사용하는 것이 좋다. 이러면 별 문제가 없다.
중괄호 초기치의 장점은 아래와 같다.
1. 가장 광범위하게 적용할 수 있는 초기화 구문이다.
2. 좁히기 변환을 방지할 수 있다.
3. C++의 most vexing parse에서 부터 자유롭다. ("선언으로 해석 할 수 있는 것은 항상 선언으로 해석해야 한다.")
일단 1번 경우와 2번의 장점으로 인해 클래스 내에서 멤버 변수의 기본 값을 설정할 때에는 중괄호 초기치를 사용하는 것이 좋다.
그리고 2번 장점의 경우, 대부분 auto의 올바른 사용으로 인해 장점이 무색해진다. 어차피 auto 를 사용하면 암시적인 좁히기 변환이 불가능하기 때문이다. 오히려 auto와 중괄호 초기치의 결합은 type deduction에 있어서 혼란을 가져올 수 있기 때문에 좋지 않다. 따라서 chapter 2의 교훈에 따라 auto를 적극적으로 그리고 잘 활용한다면, 일반적으로 중괄호 초기치보다 괄호를 쓰는 것이 훨씬 좋다.
그러면 3번의 장점을 봐보자. 3번 부분은 ReturnValue obj(); 같은 구문의 경우 이 것이 함수 선언으로 해석되는 것인데, 중괄호 초기치를 사용하면 그럴 일이 없기 때문에 좋다는 것이다... 글쌔... 그냥 ReturnValue obj;와 같이 사용하는 게 낫지 않을 까 싶다.
이제 한번 템플릿 안에서 객체를 생성할 때는 괄호와 중괄호 중 어떤 걸 사용할 지 생각해보자.
내 생각에는 템플릿에서는 더더욱 괄호를 쓰는 게 옳다. 템플릿 매개변수로 어떤 타입이 올지 모르는데 만약 그 타입의 생성자 중에 매개변수로 std::initializer_list를 받는 것이 있다면, 중괄호를 사용하는 것은 끔찍한 결정이 된다. 괄호를 사용하는 게 당연하다고 생각하고, std::make_shared등도 괄호를 채택했다.
#include <iostream>
#include <string>

using namespace std;

class Example
{
public:
    Example(int a, int b) { cout << "normal" << endl; }
    Example(std::initializer_list<string> i) { cout << "initializer_list" << endl; }
};

int main()
{
    Example ex1({ 1, 2 }); // print "normal" (THERE IS NO "COMPILE ERROR")
    Example ex2{ 1, 2 };  // print "normal"
}

위는 마지막으로 착각할 수 있을 만한 부분을 보여준다. ex1 같이 괄호 안에 중괄호를 사용하면, 마치 '명시적으로' std::initializer_list를 매개변수로 받는 Example의 생성자를 호출할 것같은 "착각"을 할 수 있는데, ({})는 대체로 {}와 같다. 따라서 normal이 출력되게 한다. 착각하지 말도록 하자.

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

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

너무 기본적인 것.

[기억해 둘 사항들]
- 0과 NULL보다 nullptr을 선호하라.
- 정수 형식과 포인터 형식에 대한 중복적재를 피하라.

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

이 것도 너무 기본적인 것. 논란의 여지가 없다. (만약 SWIG 사용 시에는 interface에 typedef를 사용해야 하는 것은 유감...)

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

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

확실히 범위 있는 enum을 선호하는 게 맞긴 한데 범위 있는 enum은 underlying type으로 implicit type conversion이 안되서 좀 불편할 때가 많다. 보통 enum의 값을 배열 혹은 컨테이너의 index와 연관지을 때가 많은데, 이럴 때 일일히 static_cast를 해줘야 하는 점이 좀 불편하다. namespace와 범위 없는 enum을 조합하면, 범위는 있되, implicit type conversion은 가능한 enum을 만들 수 있다.
#include <type_traits>

enum class Enum_A { A_1, A_2, A_3, };  // SCOPED, and implicit type conversion is not admitted.
namespace Enum_B { enum { B_1, B_2, B_3, }; }  // SCOPED, and implicit type conversion to underlying type.

int main()
{
    int arr[] = { 1,2,3,4 };

    // arr[Enum_A::A_1];  // COMPILER ERROR!
    arr[static_cast<std::underlying_type_t<Enum_A>>(Enum_A::A_1)];

    arr[Enum_B::B_1];
}

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

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

따로 하고 싶은 말은 삭제된 함수는 public으로 두는 것이 일반적인 관례이다. (public으로 둬야 컴파일러 메세지가 좀 더 정확하다.)

[기억해 둘 사항들]
- 정의되지 않은 비공개 함수보다 삭제된 함수를 선호하라.
- 비멤버 함수와 템플릿 인스턴스를 비롯한 그 어떤 함수도 삭제할 수 있다.

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

이 항목에서 final관련해서 하고 싶은 말이 있다.
http://blog.naver.com/likeme96/220719204817 의 항목 36에서도 말했다시피, 재정의 하기 싫은 함수들 (즉, 가상 함수가 아닌 일반 함수들)에 virtual과 final keyword를 활용해주면 훨씬 좋지 않나라는 생각이 든다.
class Base
{
    virtual void normal_function() final {};
    virtual void virtual_function() {};
};

class Derived : public Base
{
    // void normal_function() {};  // COMPILE ERROR
    virtual void virtual_function() override {};
};

즉, 위 코드와 같은 식인데, 원래 가상 함수가 아닌 일반 함수는 재정의 하지 않는 것이 관례인데, 이 것을 final을 활용하면, 컴파일단에서 제약을 가할 수 있다. 단, 꺼림칙한 점은 일반 함수인데도 불구하고, final keyword를 사용하기 위해 virtual keyword를 사용해야 하는 것이다. 물론 disassemble을 해서 확인한 결과 overhead는 없음을 확인했지만,, 표준위원회에서 final keyword를 virtual function에만 사용 가능하게 한 것에는 이유가 있지 않을 까라는 생각에 일단 실제 실무에는 사용을 자제하고 있다. 그러나 일반 함수에도 억지로 virtual keyword를 붙여서 final을 활용하는 게 더 좋은 습관이 아닌가 라는 생각이 든다. 그러나 이 부분은 stackoverflow에 물어보던지 해서 다른 고수분들의 자문을 좀 받아봐야 할 것 같다.

[기억해 둘 사항들]
- 재정의 함수는 override로 선언하라.
- 멤버 함수 참조 한정사를 이용하면 멤버 함수가 호출되는 객체(*this)의 왼값 버전과 오른값 버전을 다른 방식으로 처리할 수 있다.

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

뭐 워낙 기본적인 것이라 딱히 할 말은 없다.
다만 하고 싶은 말이 하나 있다.
begin 이나 end등을 사용할 때 비멤버 버전을 사용하는 경우, 내장 배열과 std::begin등이 존재하지 않는 외부 라이브러리 혹은 내부적으로 작성한 container 클래스들도 지원할 수 있게 된다. 만약, 비멤버 버전을 사용하지 않고 지원하려면, 해당 container 클래스를 public상속하여 begin등을 추가로 구현해줘야 한다. 그러나 이 것은 상속을 사용하게 되면서 괜히 설계가 복잡해지고 굳이 subtyping을 하게 되기 때문에 추후 어떤 다른 문제들의 근원이 될 수 도 있다. 따라서 std::begin<> 등에 대해 해당 container 클래스에 대한 완전 특수화를 작성하고, std::begin<> 같은 비멤버 버전을 사용하는 게 옳은 설계이다.
여기서 위와 같은 설계에 대한 교훈을 얻을 수 있다. C++을 이용한 소프트웨어를 설계할 때, 비슷한 클래스 군들이 같은 연산을 지원한다면 그 연산에 대한 비멤버 버전을 만드는 것을 고려해봄직하다.

[기억해 둘 사항들]
- iterator보다 const_iterator를 선호하라.
- 최대한 일반적인 코드에서는 begin, end, rbegin 등의 비멤버 버전들을 해당 멤버 함수들보다 선호하라.

항목 14. 예외를 방출하지 않을 함수는 noexcept로 선언하라.

넓은 계약 (wide constract) : 전제조건이 없는 함수들을 말한다.
좁은 계약 (narrow contract) : 전제조건이 있는 함수들을 말한다.
* 라이브러리 개발자들은 넓은 계약을 가진 함수들에 대해서만 noexcept를 사용하는 경향이 있다.
* C++11부터 모든 메모리 해제 함수와 모든 소멸자는 암묵적으로 noexcept이다.

noexcept로 인한 성능 향상
1. 호출 스택이 풀릴 수도 아닐 수도 있게 되면서 컴파일러의 코드 작성이 더 효율적이게 된다.
2. std::vector::push_back, std::swap 등에서 이동 연산들의 noexcept 여부에 따라 라이브러리 코드의 동작이 더 효율적으로 바뀔 수 있다.

책을 읽으면서 인상 깊은 구절을 하나 소개한다.
"즉, 의미 있는 것은 함수가 예외를 하나라도 던질 수 있는지 아니면 절대로 던지지 않는지라는 이분법적 정보뿐이다."

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

항목 15. 가능하면 항상 constexpr을 사용하라.

C++11부터 추가된 constexpr로 인해 이제 더 이상 compile-time에 계산을 하기 위해 template meta programming을 할 필요가 없어졌다. C++11의 constexpr는 약간 미완성이지만, C++14에서 부족한 점들이 채워졌다. 다만 아쉬운 점은 VS2015에서 아직 C++14의 constexpr 기능을 완전히 구현하지 않은 것이다. 하지만 그래도 충분히 쓸만하다.
#include <array>
#include <iostream>

class Example
{
public:
    constexpr Example(int num)
        : m_num(num)
    {}

    constexpr int Num() const noexcept { return m_num; }
    //constexpr Example& operator+=(const Example& ex) noexcept { m_num += ex.m_num; return *this; } // Not compiled in VS2015. But it is okay in C++14.
    //constexpr void SetNum(int num) noexcept { m_num = num; } // Not compiled in VS2015. But it is okay in C++14.

private:
    int m_num;
};

int main()
{
    constexpr Example a(3), b(5);
    //a += b;
    std::array<int, a.Num()> arr;
    std::cout << arr.size();
}

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

항목 16.  const 멤버 함수를 스레드에 안전하게 작성하라.

멀티쓰레드 환경이 친숙하고 빈번한 시대가 되었기 때문에 클래스를 thread-safe하게 작성하는 것은 중요하다. 결론적으로는 const 멤버 함수뿐 아니라 모든 함수를 thread-safe하게 작성해야 한다. 그러나 유독 항목에서 const 멤버 함수를 강조하는 이유는 const 멤버 함수는 '읽기 전용 함수' 이기 때문이다. 그래서 동기화에 대한 고려를 안 하기 쉬운데 mutable 멤버 변수를 접근하는 경우 혹은 전역적인 변수들에 접근하는 경우 thread unsafe할 수 있기 때문에 const 멤버 함수의 경우에도 thread-safe를 항상 고려해야 한다. 물론, mutable 멤버 변수나 전역적인 변수들에 접근하지 않더라도 동기화를 고려해야하는 경우가 많다.
#include <iostream>
#include <mutex>
#include <atomic>

class Rect_Unsafe
{
public:
    void Set(std::int32_t width, std::int32_t height)
    {
        m_width = width;
        m_height = height;
    }

    std::int32_t Area() const
    {
        return m_width * m_height;
    }

private:
    std::int32_t m_width;
    std::int32_t m_height;
};

class Rect_Safe_1
{
public:
    void Set(std::int32_t width, std::int32_t height)
    {
        std::lock_guard<decltype(m_mutex)> lk(m_mutex);
        m_width = width;
        m_height = height;
    }

    std::int32_t Area() const
    {
        std::lock_guard<decltype(m_mutex)> lk(m_mutex);
        return m_width * m_height;
    }

private:
    mutable std::mutex m_mutex;
    std::int32_t m_width;
    std::int32_t m_height;
};

class Rect_Safe_2
{
public:
    bool CheckLockFree()
    {
        return m_items.is_lock_free();  // return true;
    }

    void Set(std::int32_t width, std::int32_t height)
    {
        struct Items items;
        items.width = width;
        items.height = height;
        m_items = items;
    }

    std::int32_t Area() const
    {
        struct Items items = m_items;
        return items.width * items.height;
    }

private:
    #pragma pack(push, 1)
    struct Items 
    {
        std::int32_t width;
        std::int32_t height;
    };
    #pragma pack(pop)

    std::atomic<struct Items> m_items;
};

class Example
{
    void Work()
    {
        std::lock_guard<decltype(m_mutex)> lk(m_mutex);
        // Do some work...
        m_status = 1;
        // Do some work...
        m_status = 2;
        // Do some work...
        m_status = 3;
    }

    int Status() const { return m_status; }

private:
    std::mutex m_mutex;
    std::atomic<int> m_status;  // "volatile int" is "incorrect".
};

int main()
{
    Rect_Safe_2 rect_safe_2;

    std::cout << (rect_safe_2.CheckLockFree() ? "Lock Free" : "Need Lock") << std::endl;  // Lock Free
    rect_safe_2.Set(12, 11);
    rect_safe_2.Area();
}

위 코드에서 Rect_Unsafe 클래스의 Area 멤버 함수의 경우, 그 어떤 변수도 수정하지 않지만 thread-safe하지 않다. 간단하고 범용적인 해결책은 mutex를 이용해 동기화하는 것이다. 그러나 만약 이 경우 같이 두 개의 variable을 한 개의 atomic variable로 묶을 수 있다면 좀 더 효율적으로 해결이 가능하다.
그리고 위 코드의 Example 클래스를 보자. 실제로 thread-safe하게 클래스를 설계하다보면 getter와 관련해서 위 같은 상황에 맞닥뜨릴 경우가 많다. 이 경우, std::atomic을 사용하면 된다. 주의할 점은 volatile로는 해결이 안된다는 것이다. 왜냐하면 "Do some work"와 m_status = ? 는 서로 연관이 없을테므로 compile 최적화에 의해 순서가 뒤바뀔 수 도 있기 때문이다. 따라서 std::atomic을 사용해야만 이러한 순차적 일관성을 보장할 수 있다.

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

항목 17. 특수 멤버 함수들의 자동 작성 조건을 숙지하라.

Rule of 5에 따르면 소멸자, 복사 생성자, 복사 대입 연산자, 이동 생성자, 이동 대입 연산자 중 한 개라도 명시적으로 선언을 해야 한다면, 나머지도 직접 선언을 해야만 한다.
C++11 제정 시에는 기존의 rule of 3 가 충분한 공감대를 얻었기 때문에 소멸자나 복사 연산들 중에 한 개라도 명시적으로 선언이 되어 있으면 이동 연산들은 자동 작성되지 않는 것으로 표준이 제정되었다.
그러나 C++98 제정 시에는 이와 같은 것들이 충분한 공감대를 얻지 못했고, C++11에 이르어서도 하위 호환성을 유지해야 하기 때문에 여전히 복사 생성자와 복사 대입 연산자는 이동연산들과 자기자신만 선언돼있지 않으면 자동 작성된다. 그러나, 우리는 rule of 5에 따라 소멸자나 복사연산자들이나 이동연산들 중 하나라도 명시적으로 선언돼있다면 복사 연산들도 직접 명시적으로 선언을 하는 것이 좋다. ( = default; 를 사용해도 이것은 명시적 선언이라는 점에 유의하자.)
소멸자는 항상 기본적으로 작성되며 암시적으로 noexcept이다.

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



이번 chapter는 C++11/14의 쉽고 간단하지만 중요한 기능들에 대해서 살펴봤습니다...
아... 포스팅 하기 귀찮다... 하지만 시작한 이상 참고해야지... ㅠㅜ
아, 네이버의 코드 하이라이팅이 참 구려서 코드를 보기가 힘들기 때문에...
코드는 아래 링크에서 보는 걸 추천합니다..
https://github.com/taeguk/Effective-Cpp-Series (Star좀 굽신굽신...)

댓글