Advanced C++

[Modern C++] (3-1) 현대적 C++에 적응하기

로파이 2021. 3. 25. 15:39

현대적 C++ 기능을 잘 쓸 수 있도록 관련 내용을 정리한다.

 

- 목록

1. 객체 생성시 괄호와 중괄호의 차이점을 안다.

2. 0과 NULL보다 nullptr을 선호한다.

3. typedef보다 별칭 선언을 선호한다.

4. 범위 없는 enum보다 범위 있는 enum을 선호한다.

5. 정의되지 않은 비공개 함수보다 삭제된 함수를 선호한다.

6. 재정의 함수들을 override로 선언한다.

 

1. 객체 생성시 괄호와 중괄호의 차이점을 안다.

 

- C++11에서 변수 초기화 방법

// 괄호와 중괄호를 이용한 초기화
int x(0);
int y = 0;
int z{ 0 };
int w = { 0 };

 

중괄호를 이용한 초기화의 특징: 균일 초기화 Uniform Intialization

모든 초기화에 사용가능한 단 한 종류의 초기화 구문이다.

 

- 중괄호를 이용하여 균일 초기화를 수행한다.

std::vector<int> vec = { 1,2,3,4,5 };

 

- 비정적 멤버의 초기화

C++11 멤버의 "=" 등호 초기화와 같다. 

class Widget {
private:
  int x{ 0 };
  int y = 0;
  int z(0); // 에러
};

 

- 복사할 수 없는 객체 std::atomic<T>의 초기화

std::atomic<int> ai1{ 0 };
std::atomic<int> ai2 = 0;

 

중괄호 초기화의 장단점

장점)

 

- 중괄호 초기화를 사용하면 변수 타입의 좁히기 변환 Narrowing Conversion을 방지한다.

float x, y, z;
...
int sum = {x+y+z}; // float에서 int는 narrow conversion

 

단점)

 

- std::initialzer_list<T>를 이용한 생성자 오버로딩

다양한 매개변수를 사용하는 생성자 오버로딩에서 중괄호를 이용한 초기화를 사용할 수 있다.

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

Widget w1{ 5.2, 6.4 }; // 이니셜라이저 리스트 매개변수의 생성자 오버로딩

 

- 다음 일반 괄호()와 달리 중괄호{}를 이용한 생성자 호출시 엉뚱한 변환이 일어난다.

Widget w2(10, true); // Widget(int, bool) 호출
Widget w3{10, true}; // Widget(initializer_list<double>) 호출
// 따라서 10,true가 double로 변환된다.

 

- 만약 Widget의 double() 형변환이 정의되어 있다면, 복사 및 이동 생성에도 중괄호 초기화시 형변환이 일어난다.

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

Widget w1{ 5.2, 6.4 };
Widget w2{w1}; // double(w1) 형 변환후 이니셜라이저 리스트 생성자를 호출
Widget w3{std::move(w1)}; // 이동 생성도 마찬가지

 

- 형변환이 좁히기 변환이라면 다음과 같이 금지될 수 있으며,

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

Widget w1{ 5.2, 6.4 }; // double에서 bool로 좁히기 변환 : 컴파일 오류

 

- std::initialzer_list<T>로 형변환이 불가능하다면 나머지 생성자를 후보로 삼는다.

class Widget
{
public:
	Widget(int i, bool b);
	Widget(std::initializer_list<string> dl);
    operator double() const;
};
Widget w1{ 5.2, 6.4 }; // Widget(int, bool)를 호출 (string 변환이 없음)

 

std::initializer_list<T>타입의 생성자가 있다면, 중괄호 초기화시 이 생성자가 가장 우선시 되므로 주의해야한다.

클래스 사용자는 괄호 혹은 중괄호 초기화를 세심하게 선택해야한다.

 

- std::vector<T> 컨테이너의 예시 (괄호 초기화와 중괄호 초기화의 의미가 다르게 설계되었다.)

std::vector<bool> bbb(10, false); // 10개의 원소가 false로 초기화된 벡터
std::vector<bool> ccc = {true, false}; // 2개 원소를 가진 벡터 

 

2. 0과 NULL보다 nullptr을 선호한다.

 

 

0은 int 형 변수이지 포인터가 아니다. NULL도 마찬가지로 0으로 define되어 있기 때문에 포인터가 아니다.

따라서 다음 f 함수 중 void f (void*); 가 오버로딩 되는 일은 없을 것이다.

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

f(0);
f(NULL);

 

nullptr

nullptr은 정수 형식이 아니다, 그렇다고 포인터 형식도 아닌데 실제로는 std::nullptr_t라는 형식을 정의하며 이는 nullptr형식으로 정의되어 있다.

 

std::nullptr_t는 임의의 raw 포인터로 암묵적 변환이 가능하며 포인터 처럼 행동할 수 있다.

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

 

nullptr은 다음과 같이 코드의 중의성을 없앨 수 있다.

auto result = func();
if(result == 0) // result는 정수 형식 혹은 포인터가 될 수 도 있음.
{
}

auto result2 = func();
if(result2 == nullptr) // result2는 포인터를 의미한다.
{
}

 

템플릿과 같이 사용될 때, 어떤 임의의 인자 T가 반드시 포인터로 해석되어야한다면 0/NULL/nullptr를 구분지을 수 있다.

int f1(std::shared_ptr<Widget> spw);
float f2(std::unique_ptr<Widget> upw);
bool f3(Widget * pw);

template<typename Callback, typename Ptr>
decltype(auto) OnWidget(Callback func, Ptr ptr)
{
	return func(ptr);
}

// 0과 NULL은 int이므로 템플릿 Ptr = int로 연역됨
auto result1 = OnWidget(f1, 0); // 컴파일 에러
auto result2 = OnWidget(f2, NULL); // 컴파일 에러
auto result3 = OnWidget(f3, nullptr); // OK

각 함수 f1과 f2는 스마트 포인터를 인수로 기대하고 있지만 템플릿 인자 Ptr = int로 연역되어 해당 함수에 int를 전달하려고 하여 컴파일 에러가 발생한다.

 

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

 

- 긴 변수명에 대한 별칭 방식

typedef와 호환되는 using 키워드를 이용한 별칭 선언

typedef unordered_map<string, shared_ptr<Widget>> WidgetMap;
using WidgetMap = unordered_map<string, shared_ptr<Widget>>;

 

- 함수 포인터에 대한 별칭 선언

더 직관적인 별칭 선언을 보인다.

typedef int (*sum) (int, int); 
using sum = (int)(int,int); // 더 직관적이다.

 

typdef 보다 using을 사용하는 이유

typedef는 템플릿화 할 수 없지만 using은 별칭 템플릿을 만들 수 있다.

 

using vs typedef

 

using MyList = std::list<T>; // 템플릿화 된 별칭 선언

typedef는 위 기능을 사용할 수 없다.

 

따라서 다음과 같이 억지로 만들어줘야하는데,

template<typename T>
using MyList = std::list<T>;

MyList ulw;

template<typename T>
struct MyList {
	typedef std::list<T> type;
};

MyList<Widget>::type lw;

 

type(std::list<T>)은 템플릿 인수 T에 의존적인 타입이기 때문에 클래스 안에서 사용될 경우 템플릿에 의존한다는 것을 typename으로 명시해야한다.

template<typename T>
class Widget{
private:
	typename MyList<T>::type list;
};

 

using을 사용한 템플릿 별칭같은 경우 템플릿 인자에 의존하는 것처럼 보이지만 컴파일러가 별칭 템플릿은 비의존적 형식으로 취급한다. 따라서 다음과 같이 클래스안에서 깔끔하게 선언된다.

template<typename T>
class Widget{
private:
	MyList<T> list;
};

 

다음 <type_traits> 헤더 안의 형식 변환, const성 없애기, 참조자 없애기, 참조자화 하기 등의 변환 결과에 대한 타입은 다음과 같이 정의된다. (형식 변환을 실제로 수행하지는 않고 변환되었을 때 비슷한 동작을 하는 인터페이스들이다.)

std::remove_const<T>::type // const 성 없애기
std::remove_reference<T>::type // 참조 없애기
std::add_lvalue_reference<T>::type // 참조화 하기

위 형식은 모두 T에 의존적이기 때문에 상황에 따라 typename 키워드가 필요할 수 있다.

C++14에는 이러한 타입에 대해 별칭 템플릿 버전을 제공한다. (타입_t)

std::remove_const_t<T>;
std::remove_reference_t<T>;
std::add_lvalue_reference_t<T>;

 

4. 범위 없는 enum보다 범위 있는 enum을 선호한다.

 

기존 범위없는 enum 열거형 타입은 구성 요소에 대해 enum을 포함한 해당 헤더 파일을 포함하였다면 문자 그대로 사용가능하다. 또한 기본 enum 타입은 암묵적 정수 변환이 가능하다.

// Types.h
enum Color
{
    White,
    Black,
    Red,
    Blue,
    Green,
};
#include "Types.h"
int main()
{
	auto color = Red; // Red = 2인 정수
	int White = 2; // White가 이미 선언되어 있다.
	return 0;
};

 

범위 있는 enum

enum class로 정의된 열거형 타입은 클래스 처럼 해당 열거형 변수를 사용하기위해 스코프를 사용하여야 한다. 

범위 있는 열거형은 이름 공간을 제한하기 때문에 외부에서 변수 이름에 대한 이중 사용, 즉 이름 공간 오염이 줄어든다.

// Types.h
enum class Color
{
    White,
    Black,
    Red,
    Blue,
    Green,
};
#include "Types.h"
int main()
{
	auto color = Color::Red; // 스코프를 통해 접근
	return 0;
};

 

- 범위가 있는 enum은 전방 선언이 가능하기 때문에 컴파일 의존관계를 줄여 해당 열거형을 참조하는 헤더 파일의 반복적 컴파일을 줄 일 수 있다.

// Widget.h
// WidgetState가 포함된 헤더를 include 하지 않는다.
enum class WidgetState;

void SetWidgetState(WidgetState e);

 

열거형 타입의 변수에 대해 컴파일 타임에 결정되려면 (char, int, unsigned int) 등등 해당 enum 타입들이 어떤 범위의 값을 가지는 지 (어떤 크기인지) 알야아한다. 이러한 enum의 바탕 형식의 기본은 int이며 다음과 같이 형식을 명시적으로 변경할 수도 있다.

enum class Status: std::uint32_t;

// 바탕 형식 명시와 동시에 서언
enum class Status: std::uint32_t
{
    good = 0,
    failed = 1,
    corrupt = 1000,
    audited = 5000,
};

 

범위 없는 enum에 대해 바탕 형식을 지정하면 전방 선언이 가능하다.

enum Color: std::uint8_t;

 

- enum class는 타입이 "열거형 클래스" 이기 때문에 암묵적 변환을 허용하지 않는다.

따라서 다음과 같이 타입 캐스팅을 해야한다.

int color = static_cast<int>(Color::Red);

 

위와 같이 타입 캐스팅을 하고 싶지 않고 소수의 클래스 혹은 프로그램 코드에서 사용되는 경우와 정수형 변수를 위해 사용자가 꼭 알아보기 쉽게 변수명이 필요한 경우, 범위없는 enum 열거형을 사용한다.

enum UserInfoFields
{
	uiName,
    uiEmail,
    uiReputation,
};

UserInfo uInfo;
auto val = std::get<uiEmail>(uInfo); // 이메일 필드 값을 얻어옴

 

5. 정의되지 않은 비공개 함수보다 삭제된 함수를 선호한다.

 

사용자가 다음 클래스의 함수를 private으로 선언하고 구현이 없는 함수로 남겨두면 함수에 대한 사용을 막을 수 있다.

class Widget
{
public:
	Widget();
	~Widget();
private:
	Widget(const Widget& w);  // 복사 생성
	Widget& operator=(const Widget&); // 복사 대입
};

위와 같이 복사 생성과 복사 대입을 막고 싶었다면 차라리 delete 키워드를 이용하여 해당 함수를 삭제하고 public으로 만들어 사용자가 해당 함수 호출시 컴파일 에러가 날 수 있도록 해준다.

class Widget
{
public:
	Widget();
	~Widget();
public:
	Widget(const Widget& w) = delete;
	Widget& operator=(const Widget&) = delete;
};

사용자는 해당 에러와 함께 자신이 삭제된 함수를 사용하고 있다는 것을 명시적으로 알 수 있다.

 

6. 재정의 함수들을 override로 선언한다.

 

가상함수에 대해 재정의를 하고 싶다면 override 키워드를 추가한다.

 

재정의를 하고 싶다면 다음 조건을 만족하여야한다.

  1. 기반 클래스 함수가 반드시 가상 함수이어야 한다.
  2. 기반 함수와 파생 함수의 매개변수 형식들이 동일해야한다.
  3. 기반 함수와 파생 함수의 const성이 동일해야한다.
  4. 기반 함수와 파생 함수의 반환 형식과 예외 명세가 반드시 호환되어야한다.

C++11에 추가된 조건

+ 멤버 함수의 참조 한정사가 동일해야한다.

참조 한정사는 함수를 호출하는 해당 인스턴스 (*this)의 타입을 제약한다.

class Widget{

public:
	void DoSomething() &; // & 형식만 해당 함수를 호출할 수 있음.
	void DoSomething() &&; // && 형식만 해당 함수를 호출할 수 있음.
};

Widget().DoSomething() // 오른 값 Widget 인스턴스가 DoSomething() &&를 호출

Widget()은 오른값의 인스턴스를 반환하고 && 한정자가 붙은 void DoSomething() &&; 를 호출한다.

 

override로 재정의 한다면, 파생 함수가 기반 함수를 바탕으로 실제로 재정의가 되었는 지 위 조건을 바탕으로 판단을 하기 때문에 그렇지 않을 경우 컴파일러가 에러로 알려준다. 만약 override로 선언되있지 않을 시, 해당 함수를 파생 함수에서 정의된 새로운 가상함수로 보기 때문에 아무런 경고를 볼 수 없다.

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

class Derived : public Base
{
public:
    // 모두 재정의 함수가 아니므로 컴파일 에러를 표시한다.
    virtual void mf1() override; // const성 불일치
    virtual void mf2(int x) override; // 매개변수 타입 다름
    virtual void mf3() & override; // 참조 한정자 불일치
    void mf4() const override; // 기반 함수에 해당 가상함수 없음
};