Coding/Unreal, C++

[C++ Primer Plus] 18. Visiting with the New C++ Standard

대답해야 할 질문들

  • auto 타입의 type deduction 시점은 언제인가?
  • decltype의 사용법은 어떻게 되는가?
  • move semactics는 어떻게 사용할 수 있는가?
  • c++11의 클래스에서 자동생성되는 함수 6개는 무엇인가?
  • override와 final 키워드의 의미는 무엇인가?
  • 람다식이 함수 포인터와 펑터보다 우월한 이유는 무엇인가?
  • wrapper를 쓰는 이유는 무엇인가?
  • variadic template은 어떻게 사용할 수 있는가?

C++11 Features Revisited

New Types

  • C++11에서 64bit int 지원을 위해 long long, unsigned long long 타입을 추가됐다. 비슷한 이유로 char16_t, char32_t도 추가됐다.

Uniform Initialization

  • 중괄호 {}를 통해 여러 자료형의 초기화를 동일한 형태로 할 수 있다.
int* ar = new int[4] {2, 4, 6, 7};
Stump s2{5, 43.4};
Stump s3 = {4, 32.1};
  • initialization-list는 narrowing을 방지해주는 기능도 있다.
char c1 = 1.57e27; // double_to_char == undefined behavior(compile OK)
char c2 = 1234567890; // int_to_char == undefined behavior(compile OK)

char c1 {1.57e27}; // compile error
char c2 = {1234567890}; // compile error
  • initializer-list를 파라미터로 받으려면 std::initializer_list<double>을 쓰면 된다. 이를 이용해서 initializer_list로 클래스 오브젝트를 초기화할 때 어떻게 동작할지 설계할 수도 있다.
double sum(std::initializer_list<double> il)
{
	double tot = 0;
    for (auto p = il.begin(); p != il.end(); ++p)
    	tot += *p;
    return tot;
}

int main()
{
	sum({2.5, 4.14, 3.2});
...
}

Declarations

  • auto 타입은 complier에게 type deduction을 통해서 적절한 type으로 선정케 한다. 이를 이용해 template 선언을 짧게 가져갈 수 있다.
for (std::initializer_list<double>::iterator p = il.begin();
p != il.end(); p++)
// C++11
for (auto p=il.begin(); p!=il.end(); p++)
  • decltype을 활용해서 컴파일 시점에 타입을 추론-선언해서 사용할 수 있다.
template<typename T, typename U>
void ef(T x, U y)
{
	decltype(T*U) tu; // T와 U를 곱했을 때 타입을 컴파일 시점에 추론해서 쓸 수 있게 해준다.
    tu a;
... 
}
  • Return Type에 대한 컴파일 타임 타입 추론은 decltype으로 바로 적용하기 난해한 부분이 있다. 그 경우, auto 키워드와 연계해서 다음과 같이 작성한다.
template<class T, class U>
auto eff(T t, U u) -> decltype(T*U)
{ ... }
  • Template이 들어오면서 특정 type은 그자체로 이름이 매우 길어질 수 있다. 이 경우, alias를 지정해두면 편한데, 이를 위해 using = 구문을 사용할 수 있다.
using itType = std::vector<std::string>::iterator;

nullptr

  • C는 역사적으로 포인터 변수의 초기값을 0으로 설정해서 널포인터로 사용했다.
  • 그 결과, 포인터 변수도 0으로 초기화하고 정수형 변수도 0으로 초기화하게 되다보니 혼동이 생겼고 '포인터를 널값으로 설정한다.'라는 명확한 의미를 내가 위해서 C++11에서 nullptr이 생겼다.

Smart Pointers

  • C++98의 auto_ptr이 폐지되고, unique_ptr / shared_ptr / weak_ptr 3개의 스마트 포인터가 들어왔다. 관련 내용은 16장에서 자세히 서술했다.

Exception Specification Changes

  • C++98에서 특정 함수가 어떤 Exception을 던지는지 설명해주는 구문은 다음과 같았다.
void f501(int) throw(bad_dog); // f501은 bad_dog exception만 던진다.
void f733(long long) throw(); // f733은 exception을 던지지 않는다.
  • 하지만 이러한 명세가 실제로 잘 쓰이지 않았다. 하지만 throw()의 경우, 어떠한 exception도 던지지 않는다는 보장을 가지기 때문에 이에 대해서만 C++11에서 새로운 키워드 noexcept가 생겼다.
void f875(short, short) noexcept;

Scoped Enumerations

  • 전통적인 C++의 enum은 상수를 선언하는데에 사용됐다. 이에 대해 C++11의 Scoped Enum을 통해 두가지를 보완한다.
    • 내부적으로 int가 아닌 다른 자료형으로 enum을 저장할 수 있다.
    • 따로 name scope를 가지기 때문에 이름을 겹칠 수 있다.

Class Changes

  • 클래스 설계에 도움을 주는 여러 기능이 추가됐다.

explicit Conversion Operators

  • class에 대한 automatic type conversion이 기술적인 문제를 발생시키곤 한다. 이를 방지하기 위해 explicit 키워드를 사용할 수 있다.
class Plebe
{
	Plebe(int); // implicit int-to-Plebe enabled
    explicit Plebe(double); // implicit double-to-Plebe disabled
};

Plebe(5); // OK (explicit)
Plebe(5.0); // OK (explicit)
Plebe a, b;
a = 5; // OK (implicit)
b = 5.0; // ERROR (implicit)

Member In-Class Initialization

  • C++에서 멤버 변수의 초기값은 무조건 생성자에서 설정해줘야 했다. C++11에선 In-Class Initialization을 통해 기본값을 설정해줄 수 있다. 문법은 다음과 같다.
class Session
{
	int mem1 = 10; // in-class initialization
    double mem2 {1966.54}; // in-class initialization
};

Ranged-based for loop

  • 이 문법을 통해 시퀀스를 순회하는 알고리즘을 좀더 가독성이 좋게 작성할 수 있다. 이는 STL Container에서도 작동된다.
double prices[5] = {4.99, 10.99, 6.87, 7.99};
for (auto x : prices)
	std::cout << x << std::endl;
    
std::vector<int> vi(6);
for (auto& x: vi) // ref로 받아서 값을 조작할 수 있다.
	x = std::rand();

Angle Brackets

  • C++98에서는 Template에서 쓰는 <>가 연속으로 등장하면 <<나 >> 연산자와의 혼동될 수 있기 때문에 빈칸을 두도록 강제했다. C++11에서는 빈 칸이 없어도 잘 작동된다.
std::vector<std::list<int>> vl; // OK in C++11

The rvalue Reference

  • C++98에서 쓰던 레퍼런스는 모두 lvalue reference로 본다. lvalue는 프로그램에서 접근할 수 있는 주소값을 가지고 있는 데이터를 의미한다. 예를 들어, 변수나 배열 요소 등이 이에 속한다. 이에 비해 rvalue는 임시 데이터를 의미한다고 이해하면 쉽다.
int x = 10;
int& rx = x; // lvalue ref
int&& rrx = 13; // rvalue ref
  • rvalue Reference는 &&를 통해 만들어낼 수 있고, 이게 생긴 이유는 바로 다음에 설명할 Move Semantics 때문이다.

Move Semantics and the Rvalue Reference

The Need for Move Semantics

  • 다음 상황을 생각해보자.
vector<string> vstr;
// 1000글자짜리 스트링을 20000개 vstr에 저장한다.
...
vector<string> vstr_copy(vstr);
  • 위 경우에 vstr_copy는 copy constructor를 사용해서 const ref로 vstr을 받아 복사할 것이다.
  • 이 때, string vector에 각 요소마다 대문자만 가지고 있는 벡터를 만들어주는 함수를 작성하면 다음과 같을 것이다.
vector<string> allcaps(const vector<string>& vs)
{
	vector<string> temp;
    
    (vs에 담긴 대문자만 뽑아서 temp에 저장한다)....
    
    return temp;
}

vector<string> vstr;
.......
vector<string> vstr_copy(vstr);
vector<string> vstr_copy2(allcaps(vstr));
  • 자 이 때, 마지막 줄에서 값 복사는 몇번이나 일어날까? 컴파일러마다 다르겠지만 최악의 경우, 2번까지 일어날 수 있다. (return될 때 한번, copy constructor에서 한번)
  • move semantics는 이러한 낭비를 없애기 위해 등장했다. 
  • 실제로 move semantics는 다음과 같은 생성자나 대입 연산자 구현에서 이득을 볼 수 있다.
Useless::Useless(Useless&& f) : n(f.n)
{
	pc = f.pc; // steal address
    f.pc = nullptr;
    f.n = 0;
    ShowObject();
}

Useless& Useless::operator=(Useless&& f)
{
	if (this == &f)
    	return *this;
    delete [] pc;
    n = f.n;
    pc = f.pc;
    f.n = 0;
    f.pc = nullptr;
    return *this;
}
  • 만약, lvalue ref를 가지고 move semantics를 강제하려면 std::move를 쓰면 된다.
Chunk one;
...
Chunk two;
two = std::move(one);

New Class Features

  • move semantics로 인해 클래스에 몇가지 추가사항이 생겼다.

Special Member Functions

  • default로 생성되는 함수로 기존 4개에 더해 2개가 추가됐다. defaulted move constructor와 defaulted move assignment가 추가 됐다.
Someclass::Someclass(Someclass &&); // defaulted move constructor
SomeClass& Someclass::operator=(Someclass &&); //defaulted move assignment

Defaulted and Deleted Methods

  • default function을 쓰거나 쓰지 않겠다고 명시적으로 선언할 수 있다.
Someclass(const Someclass &) = delete;
Someclass(Someclass &&) = default;

Delegating Constructors

  • 기존에 있던 생성자를 사용해서 생성자 구현을 단순화할 수 있다.
Notes::Notes(int kk, double xx, std::string stt)
	: k(kk),
    x(xx),
    st(stt) {/* do stuff */}
Notes::Notes() : Notes(0, 0.01, "Oh") {/* do stuff */}
Notes::Notes(int kk) : Notes(kk, 0.01, "Ah") {/* do stuff */}
Notes::Notes(int kk, double xx) : Notes(kk, xx, "Uh") {/* do stuff */}

Managing Virtual Methods: override and final

  • derived class에서 override를 명시적으로 표기하기 위해 override 키워드를 메소드 끝에 쓸 수 있다.
virtual void f(char* ch) const override { ... }
  • final 키워드는 override와는 완전히 다른 의미다. virtual 함수가 해당 클래스 이후로는 더이상 상속되지 않음을 의미한다.
virtual void f(char ch) const final {...}

Lambda Functions

  • lambda 함수는 아래처럼 생겼다. 
[&count](int x){count += (x % 13 == 0);}
  • 간단하게 생각하면 원래 함수 형태에서 리턴 타입과 함수 이름을 없애고 대괄호[]로 바꾸면 된다.
  • 리턴 타입은 타입 추론으로 결정되는데, 만약 타입을 결정하고 싶으면 아래처럼 화살표로 표기하면 된다.
[&count](int x)->int{count += (x % 13 == 0);}

The How of Function Pointers, Functors, and Lambdas

  • STL 알고리즘에는 Function Pointer, Functor, Lambda를 전부 줄 수 있다. 먼저 형태를 하나씩 살펴보자.
/* Function pointer */
bool f3(int x) {return x % 3 == 0;}
bool f13(int x) {return x % 13 == 0;}

int count3 = std::count_if(numbers.begin(), numbers.end(), f3);
int count13 = std::count_if(numbers.begin(), numbers.end(), f13);

/* Functor */
class f_mod
{
private:
	int d;
public:
	f_mod(int _d = 1) : d(_d) {}
    bool operator() (int x) {return x % d == 0;}
}

int count3 = std::count_if(numbers.begin(), numbers.end(), f_mod(3));
int count13 = std::count_if(numbers.begin(), numbers.end(), f_mod(13));

/* Lambda */
int count3 = std::count_if(numbers.begin(), numbers.end(),
			[](int x){return x % 3 == 0;});
int count13 = std::count_if(numbers.begin(), numbers.end(),
			[](int x){return x % 13 == 0;});

The Why of Lambdas

  • 왜 C++은 대체재가 충분히 있는데도 불구하고 Lambda를 추가했을까?
  • Lambda의 사용 이유를 근접성(proximity), 간결성(brevity), 효율성(efficiency), 확장 가능성(capability)의 측면에서 알아보자.
    • 근접성(proximity)
      • lambda로 작성하면 실제로 전달되는 알고리즘을 바로 볼 수 있다는 장점이 있다. 이와 달리 함수 포인터나 펑터 모두 따로 정의를 살펴봐야 한다.
    • 간결성(brevity)
      • functor는 lambda나 함수 포인터에 비해 코드가 길다는 단점이 있다.
      • 이 관점에선 함수 포인터가 람다에 비해 우월하다고 생각될 수 있지만 그렇지 않다. auto 타입으로 람다를 담을 수 있기 때문이다.
auto mod3 = [](int x){return x % 3 == 0;};
count1 = std::count_if(n1.begin(), n1.end(), mod3);
count2 = std::count_if(n2.begin(), n2.end(), mod3);
  • 효율성(efficienty)
    • function pointer은 따로 inline 설정을 안하면 컴파일러가 inline을 보통 설정하지 않는다.
  • 확장 가능성(capability)
    • lambda는 lambda가 사용되는 scope의 로컬 변수를 capture할 수 있어서 작성 시에 자유도가 뛰어나다.
int count13 = 0;
std::for_each(numbers.begin(), numbers.end(),
	[&count13](int x){count13 += x % 13 == 0;});
// capture에 &만 쓰면 모든 지역 변수에 접근할 수 있다.
std::for_each(numbers.begin(), numbers.end(),
	[&](int x){count13 += x % 13 == 0;});

Wrappers

answer = ef(q);
  • 여기서 문제, ef는 뭘까?
  • ef는 함수일수도, 함수 포인터일 수도, 펑터일수도, 람다 변수일 수도 있다. 이런 타입을 모두 callable types라고 부른다.
  • 이렇게 callable type이 많으면 template 함수를 사용할 때 비효율을 가져올 수 있다. 아래 예시를 보자.
double dub(double x) {return 2.0*x;}
double square(double x) {return x*x;}

use_f(y, dub);
use_f(y, square);
use_f(y, Fp(5.0)); // return과 parameter가 모두 double인 functor
use_f(y, Fp2(5.0)); // return과 parameter가 모두 double인 functor
use_f(y, [](double u) {return u*u;});
  • 위 상황에서 template은 총 몇개가 만들어질까?
  • 정답은 5개다. 그런데 정말 5개나 만들어질 필요가 있을까?
  • 함수 / 함수 포인터 / 펑터 / 람다식 모두 리턴 타입이 double, 매개변수도 double로 동일하다. 이를 call signature가 같다고 말한다. 이런 상황을 타개하기 위해 wrapper를 사용할 수 있다.
double dub(double x) {return 2.0*x;}
double square(double x) {return x*x;}

function<double(double)> ef1 = dub;
function<double(double)> ef2 = square;
function<double(double)> ef3 = Fp(10.0);
function<double(double)> ef4 = Fp2(10.0;
function<double(double)> ef5 = [](double u){return u*u;);

use_f(y, ef1);
use_f(y, ef2);
use_f(y, ef3);
use_f(y, ef4);
use_f(y, ef5);

Variadic Templates

  • Template을 사용해서 매개변수에 상관없는 함수를 작성할 수 있다. 형태는 다음과 같다.
template<typename T, typename... Args>
void show_list3(T value, Args... args)
{
	std::cout << value << ", ";
    show_list(args); // unpacking using recursion
}
  • 위 예시처럼 재귀함수를 사용해서 unpacking을 할 수 있다.

References

  • C++ Primer Plus 6th, Chapter 18