나혼자 공부장

[Modern Effective C++] Chap 3. 현대적 C++에 적응하기 본문

C++/Modern Effective C++

[Modern Effective C++] Chap 3. 현대적 C++에 적응하기

라부송 2020. 2. 24. 18:05

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

객체 생성시 괄호와 중괄호를 구분하라

 

 

중괄호 초기화는 축소 변환, C++의 가장 성가신 구문 해석 등에서 자유롭다.

int x(0);   // 소괄호로 초기화
int y = 0; // 등호로 초기화
int z{0};  // 중괄호로 초기화
int z = {0}; // 기본적인 중괄호 초기화와 같음

우리는 객체 생성, 함수 정의 등등 여러 용도로 괄호를 쓴다.

그러나, C++은 함수의 정의로 해석이 가능한 것은 모두 함수의 정의로 해석한다.

-> 괄호를 쓸 때 우리의 의도대로 동작하지 않을 수 있다.

 

uniform initialization (균일한 초기화)

쉽게 말해 문제가 되는 상황에서 괄호를 중괄호로 바꾸기만 하면 끝임.

// vector의 생성과 동시에 초기화
std::vector<int> v{1,2,3}; // 1,2,3으로 초기화 되는 vector 객체 v

// 비상수 멤버에 대한 초기화 
int x{0}; // 가능
int y = 0; // 가능
int z(0); // 불가능

// 복사 될 수 없는 객체들에 대한 초기화 (예를 들어 우리가 item 40에서 배울 std::atomics)
std::atomic<int> ai1{0}; // 가능
std::atomic<int> ai2(0); // 가능
std::atomic<int> ai3 = 0; // 불가능

// 따라서 초기화시 {}를 사용하면 고민없이 언제나 초기화 할 수 있다.

 

 

narrow conversion (암묵적 축소 변환) 을 방지함으로써 데이터 손실을 막을 수 있다.

// 암묵적 축소 변환이 일어날 수 있는 상황
double x,y,z;
...
int sum1{x+y+z}; // 컴파일 에러, x+y+z가 int의 표현 범위보다 클 수 있어서
int sum2(x+y+z); // 가능
int sum3 = x+y+z; // 가능

 

가장 성가신 구문 해석에서 자유롭다.

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

 

즉, 어떤 상황에서도 중괄호는 오해될 일이 없지만,

 

의도대로 동작하지 않는 순간이 크게 두가지가 있다.

1. auto x = {1}; 와 같이 선언할 경우는 std::intial_list로 추론되니 auto와의 조합은 잘 맞지 않을 수 있다.

2. std::intializer_list 를 파라미터로 가진 생성자가 있을 경우 그게 1순위로 매칭된다.

 

 

 

Item 8: Prefer nullptr to 0 and NULL.

0과 NULL보다 nulllptr을 선호하라

 

우리가 값이 없음을 표현할 때 사용하는 0과 NULL은 포인터가 아니고,

0은 int이며 NULL은 불확실한 세부사항이 존재할 지라도 어떤 타입을 부여할 수 있다.

약간 울며겨자먹기로, 포인터가 사용되는 곳에서 그걸 포인터로 해석할 뿐이다.

 

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

f(0); //f(void*)가 아니라 f(int)가 호출.

f(NULL); // f(int)가 호출됨.

위 코드를 보면, 사용자의 의도는 NULL을 인자로 넘겨 void* 함수로 호출되도록 하는 것이다.

그러나 컴파일러는 NULL을 0으로 받아들여 정수형을 호출하게 된다.

-> 정수 타입과 포인터를 같이 오버로딩하는걸 피하는게 좋다.

 

그러나, 애초부터 nullptr을 이용한다면 이런 문제를 해결할 수 있다.

포인터 계의 메타몽이다.

 

nullptr은 포인터다?

사실 이름만 보면 만물의 포인터 같지만(실제로 이 용도로 쓸 수 있지만) 정수도 포인터도 아니다. 

실제로는 std 안에 nullptr_t 라는독자적인 타입이 있으며, 모든 포인터 타입과 묵시적 변환이 가능한 것 뿐이다.

 

f(nullptr)	// f(void*) 호출

 

nullptr은 코드의 명확성 또한 높여준다. (0이 반환형인지, null을 말하는건지 판단이 어려울 때 등)

auto result = findRecord( /* 인수들 */ );
 
if (result == 0) {
    ...
}
 
// findRecord의 반환 타입을 모르거나
// 쉽게 파악할 수 없다면, result가
// 포인터 타입인지 아니면 정수 타입인지를
// 명확히 말할 수 없게 된다.
 
// 반면 다음 코드에는 모호성(ambiguity)이
// 없다.
 
auto result = findRecord( /* 인수들 */ );
 
if (result == nullptr) {
    ...
}
 
// 이 경우에는 result가 포인터
// 타입임이 분명하다

 

Item 9: Prefer alias declarations to typedefs.

typedef보다 별칭 선언을 선호하라

 

typedef std::unique<<std::unordered_map<std::string, std::string>> UPtrMapSS;
  
using UPtrMapSS = std::unique<<std::unordered_map<std::stirng, std::string>>;

대충 요약하자면 딱 봐도 alias 선언 쪽이 보기 간결하다는 소리다.

그래도 자세히 파고들어 보겠다.

 

함수 포인터가 포함될 때 이해하기 쉽다.

  typedef void(*FP)(int, const std::string&); 
  using FP = void (*)(int, const std::string&);

 

템플릿을 사용할 경우에도 마찬가지다.

  // 템플릿을 표현 할 때 using은 typedef보다 쉽다.

  // typedef를 사용하는 경우
  template<typename T>
  struct MyAllocList{
   typedef std::list<T, MyAlloc<T>> type;
  };
  //typedef로 정의한 타입 사용시
  MyAllocList<Widget>::type lw;

  // using을 사용하는 경우
  template<typename T>
  using MyAllocList = std::list<T, MyAlloc<T>>;
  // using으로 정의한 타입 사용시
  MyAllocList<Widget> lw;

 

필자가 특히 편리하다고 느낀 부분은 typename을 사용해야 할 때다.

template <typename T>
class Widget {                                    // Widget<T>에는
private:                                        // MyAllocList<T> 타입의
    typename MyAllocList<T>::type list;            // 데이터 멤버가 있다.
    ...
};


template <typename T>
using MyAllocList = std::list<T, MyAlloc<T> >;    // 이전과 동일
 
template <typename T>
class Widget {
private:
    MyAllocList<T> list;                        // "typename" 없음
    ...                                            // "::type" 없음
};

위 코드를 보면 아래의 경우 typename을 별도로 선언하지 않고 alias 로 처리했다.

여기서 드는 의문 -> 의존 타입을 typename으로 지칭해주지 않았는데 왜 오류를 뱉지 않는가

 

우리에게는 의존 타입이지만, 컴파일러는 그렇게 생각하지 않기 때문이다.

alias declaration 자체가 타입 지정에만 쓰이기 때문에, using 구문을 쓴 이후로 의존 타입이 아닌 컴파일러가 알고 있는 명확한 타입이 된 것이다.

 

 

 

Item 10: Prefer scoped enums to unscoped enums.

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

 

unscoped enum은 범위에 제한을 두지 않기 때문에, 무분별한 영역 침범이 일어난다.

이 때 사용하는게 scoped enum이다.

 

enum class Color { BLACK, WHITE, RED};

auto WHITE = false; //가능함

Color c = WHITE; // 불가능!
Color c = Color::WHITE; // 가능. 범위를 명시해줘야함.
auto d = Color::WITHE; // 이것도 가능.

 

기존의 enum은 정수 타입과 암시적으로 변환이 되는데,

장점이라고 생각될 수 도 있지만 논리적으로 모순이 있는 구문들이 허용된다는 문제가 있다.

enum Color { BLACK, WHITE, RED };

std::vector<std::size_t>
  primeFactors(std::size_t x);

Color c = RED; 

if ( c < 14.5 ) // 색깔과 실수를 비교..?
{
    auto factors = primeFactors(c); //색깔의 prime factor를 어떻게..?
}

 

하지만 scoped enum은 타입 체킹을 엄격하게 하고 있기 때문에 그런 일이 발생하지 않는다.

정수 타입으로 변환하고 싶다면 static_cast로 강제 변환하면 된다.

enum class Color { BLACK, WHITE, RED };

Color c = Color::RED;

if ( c < 14.5 )  // error 발생!
{
    auto factors = primeFactors(c); // error 발생!
}

if( static_cast<double>(c) < 14.5 )
{
    auto factors = primeFactors(static_cast<std::size_t>(c));
}

 

전방 선언 또한 가능하다.

enum Color; //전방선언 불가!

enum class Color; // 전방선언 가능!

 

unscoped 또한 전방 선언이 가능해지긴 했으나, 다소 불편한 부분이 있다.

enum은 타입이 숨겨져 있는데, 기본적으로는 

enum Color { BLACK, WHITE, RED } ;

라고 표현되어 있을 때 1byte 면 충분하기 때문에 char 로 설정한다.

그러나 따로 더 큰 값을 할당한다면? -> 그 타입을 표현할 수 있을만큼 큰 정수형의 타입을 할당하게 된다.

 

너무 유동적이기 때문에 enum의 값이 조금만 바뀌어도 enum을 사용한 모든 파일을 다시 컴파일하게 된다.

 

그러나 scoped enum이라면?

기본적으로 int 형으로 고정적으로 다뤄지고 있고, 원하는 대로 타입캐스팅을 할 수도 있다.

enum class Status : std::uint32_t; // underlying type은 std::uint32_t가 됨

 

그러나 index 처럼 사용하려 할 때는 짜증을 유발할 수 있다.

using UserInfo = std::tuple<std::string, std::string, std::size_t>;

enum class UserInfoField 
{ uiName, 
  uiEmail, 
  uiReputation 
}; //enum class가 좋다고 해서 한번 써보았습니다.
UserInfo uInfo;
...
auto val = std::get<uiEmail>(uInfo); //에러
auto val = std::get<static_cast<std::size_t>
                    (UserInfoField::uiEmail)>(uInfo); //명시적 타입캐스팅 해야함...끔찍

 

이럴 때 쓰라고 템플릿이 있다.

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

auto val = std::get<toUType(UserInfoField::uiEmail)> (uInfo); //적어도 이전버젼보다는 훌륭하다.

 

Item 11: Prefer deleted functions to private undefined ones.

정의되지 않은 비공개 함수보다 삭제된 함수를 선호해라

 

복사생성자, 대입연산자 등등 선언하지 않으면 알아서 만들어주는 것들이 있다.

이런 함수의 호출을 금지하고 싶을 경우, 저자의 이전 책에서는 다음과 같이 해결책을 제시했다.

class Widget
{
...
private:
   Widget(const Widget& rhs);            //복사 생성자 봉인
   Widget& operator=(const Widget& rhs); //복사 대입연산자 봉인
}

 

물론 효과적이다. private로 선언한 데다가 정의하지도 않았기 때문에 링크 과정에서 에러를 낸다.

그러나, C++11은 좀 더 깔끔한 솔루션을 제공한다.

 

class Widget
{
...
public:
   Widget(const Widget& rhs) = delete;            //복사 생성자 봉인
   Widget& operator=(const Widget& rhs) = delete; //복사 대입연산자 봉인
};

 

단순히 보기가 끌끔한 것 뿐만 아니라, 컴파일러에서 금지된 함수라고 알아채는 시점이 다르다.

기존에는 링크시점까지 되어야만 알아챌 수 있었으나, delete 를 이용하면 컴파일러가 미리 알아챌 수 있다.

굳이 public으로 선언한 이유는, 접근권한 에러가 아닌 삭제된 함수를 참조하려 한다는 근본적인 오류를 이끌어 낼 수 있기 때문이다.

 

그렇다면 delete는 멤버함수에만 사용할 수 있는가?

bool isLucky(char) = delete;
bool isLucky(bool) = delete;
bool isLucky(double) = delete;

위와 같이 특정 타입의 매개변수를 받는 걸 제한하고 싶다면 delete를 이용해 오버로딩하여 아름답게 할 수 있다.

 

만약 클래스 템플릿 멤버함수의 해당 내용을 적용하고 싶다면? 

예를 들어 void 타입만 막고싶다고 해보자.

 

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

 

여기서 문제는, 템플릿 특수화 버전은 기존 멤버함수와 다른 액세스 레벨에 속할 수 없는 것도 문제고,

클래스 영역에서 특수화를 시키면 안 된다. 

 

여기서도 delete가 활약한다.

class Widget
{
public:
   template<typename T>
   void processPointer(T* ptr)
   {...}
}
template<>
void Widget::processPointer<void>(void*) = delete; //문제없이 봉인

 

 

Item 12: Declare overriding functions override

오버라이딩 함수에 override 를 선언하자.

 

오버라이딩에는 수많은 조건들이 요구된다.

이걸 다 기억하면서 충족시키는건 어려운 일이다. 컴파일러 또한 이런 문제를 제대로 체크하지 못하는 경우가 있다.

-> 그렇다면 이런 실수를 막을 수 있는 방법이 없는걸까?

 

그래서 override 선언이 있다.

class Derived:public Base{
   virtual void mf1() override;                  //컴파일 error
   virtual void mf2(unsigned int x) override;    //컴파일 error
   virtual void mf3() & override;                //오버라이딩 성공 코드
   void mf4() const override;                    //override선언하면 virtual도 겸한다. 성공 코드
};

이렇게 선언한다면, 오버라이딩한 부모의 함수가 변경된 경우 오류를 출력한다,

부모의 변경이 얼마나 타격을 줄지 보여줌으로써 현재 코딩의 어떤 문제가 있는지 파악할 수 있게 한다.

 

아래는 reference qualified function 을 이용한 예이다.

lvalue, rvalue 를 구분해서 서로 다른 생성자를 요청하도록 할 수 있다.

class Widget{
public:
   using DataType = std::vector<double>;      //using을 통한 타입 별명짓기
   DataType& data() & 
   { return values; }            //*this가 lvalue인 경우는 원래랑 똑같이
   DataType data() && 
   { return std::move(values); } //rvalue라면 std::move로 rvalue 넘긴다.
private:
   DataType values;
};
...
Widget w;
auto vals1 = w.data();            //lvalue용 data()함수를 호출하여 lvalue전달
auto vals2 = makeWidget().data(); //rvalue용 data()함수를 호출하여 rvalue전달

 

Item 13: Prefer const_iterators to iterators.

iterator 보다 const_iterator 를 선호하라

 

가능한 한 const를 사용하는 것이 좋다.

그러나 iterator 에서 만큼은 불편한 점이 많았다. (비상수 컨테이너, 삽입 위치 지정 등등...)

그러나 C++ 11 에서 이러한 문제들이 개선되었다.

 

혹시 iterator 자체에 대한 의문이 든다면, 그 필요성을 잘 정리해준 블로그가 있다.

 

https://orcacode.tistory.com/entry/%EC%9D%B4%ED%84%B0%EB%A0%88%EC%9D%B4%ED%84%B0-%ED%8C%A8%ED%84%B4-Iterator-%EC%88%9C%EC%84%9C%EB%8C%80%EB%A1%9C-%EC%B2%98%EB%A6%AC%ED%95%98%EC%9E%90

 

이터레이터 패턴 [Iterator] : 순서대로 처리하자

이라는 책을 가지고 복습하는 것이기 때문에 책에 나와 있는 패턴을 순서대로 공부해보려고 한다. 좀 멀리 떨어져 있더라도 비슷한 모양이나 역할의 패턴들이 있기는 하지..

orcacode.tistory.com

std::vector<int> values;                                // 이전과 동일
 
...
 
auto it =                                                // cbegin과
    std::find(values.cbegin(), values.cend(), 1983);    // cend를 사용
 
values.insert(it, 1998);

cbegin과 cend 라는 멤버 함수가 추가되어 비상수 컨테이너에서도 const_iterator를 얻을 수 있게 되었다.

이제 번거로운 캐스팅을 하지 않아도 const를 쓸 수 있게 되었으니, const iterator를 마다할 이유가 없어진다.

 

그러나, 최대한 일반적인 코드에서는 비멤버 버전 함수를 지향하는게 좋다.

 

auto it = std::find(cbegin(container),        // 비멤버 cbegin
                        cend(container),        // 비멤버 cend
                        targetVal)
template <class C>
auto cbegin(const C& container)->decltype(std::begin(container))
{
    return std::begin(container);
}

위는 cbegin 멤버 함수를 제공하지 않는 컨테이너에 대해서도 작동시킬 수 있는 코드다.

비멤버 cbegin은 인수 타입 C 컨테이너를 통해서 그 자료구조에 접근하는데, 이 경우 cbegin은 그 컨테이너의 const 버전에 대한 참조를 한다.

begin 함수는 const_iterator 타입의 반복자를 반환하므로, 위와 같이 지정해준다면 const_iterator 타입을 명확히 반환해줄 수 있다.

 

 

Item 14: Declare functions noexcept if they won’t emit exceptions.

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

 

과거에는 예외 지정은 다소 변덕스러운 요소였다.

그러나 C++ 11 제정 과정에서, 함수가 예외를 하나라도 던질 가능성이 있는지 여부를 알 필요가 있다고 판단했다.

이를 명시할 때에는 noexcept 라는 키워드가 있다.

 

이는 인터페이스 설계상의 문제이며, 호출 코드의 예외 안정성이나 효율성에 영향을 미친다.

게다가 기존 throw() 보다 훨씬 최적화의 여지도 크다.

 

throw() : 예외 발생 시 스택이 본 함수를 호출한 지점까지 풀리며, 몇가지 동작 후 프로그램 실행 종료

noexcept : 예외 발생 시 스택 runtime 유지할 필요 x, 객체 순서 상관없이 소멸 가능

최적화 유연성 면에서 훨씬 뛰어나다.

 

또한 move 연산들과 swap, 메모리 해제 함수들, 소멸자들에 특히 유용하다.

 

한 가지 주의할 점은 나중에 noexcept 를 취소하고 싶다면, 달리 예쁘게 수습할 방법이 없다.

함수의 인터페이스의 일부기 때문에 그걸 수정하게 된다면 클라이언트 코드가 깨질 위험이 있다.

다른 함수들은 그 함수를 noexcept라 믿고 작성 되었을 것이기 때문이다.

 

그러므로 선언 시 심사숙고해야한다.

 

void setup(void);            // 다른 어딘가에 정의된 함수들
void cleanup(void);
 
void doWork(void) noexcept
{
    setup();                // 필요한 준비 작업을 수행
    
    ...                        // 실제 작업을 수행
    
    cleanup();                // 정리 작업을 수행
}

 

Comments