Tag Archives: 프로그래밍

전문가를 위한 C++/ C++ 멀티스레드 프로그래밍

(전체가 아니라 C#과 차이가 있는 부분을 중심으로 요약 정리)

멀티스레드 프로그래밍(multithreaded programming)은 프로세서 유닛이 여러 개 장착된 컴퓨터 시스템에서 중요한 기법이다. 멀티스레드 프로그래밍을 이용하면 시스템에 있는 여러 프로세서 유닛을 병렬로 사용하는 프로그램을 작성할 수 있다.

독립적인 CPU를 담은 프로세서 칩이 여러 개 달려 있을 수 있고, 한 프로세스 칩 안에 코어라 부르는 독립적인 CPU가 여러 개 있을 수 있고, 또 어떤 시스템은 두 가지 방식을 혼합하기도 한다. 이렇게 프로세스 유닛이 여러 개 달린 프로세서를 흔히 멀티코어 프로세서라 부른다.

요즘 판매되는 CPU는 모두 멀티코어 프로세서다. 멀티코어 프로세서가 보편화 됐기 때문에 멀티스레드 애플리케이션을 작성할 줄 아는 것은 중요하다. 전문 C++ 프로그래머라면 프로세서의 기능을 최대한 활용할 수 있도록 멀티스레드 코드를 정확하게 작성할 줄 알아야 한다.

멀티스레드 애플리케이션은 플랫폼이나 OS에서 제공하는 API에 상당히 의존한다. 그래서 멀티스레드 코드를 플랫폼 독립적으로 작성하기는 힘들다. C++ 11부터 제공되는 표준 스레딩 라이브러리를 활용하면 이 문제를 어느 정도 해결할 수 있다.

멀티스레드 프로그래밍 개념

멀티스레드 프로그래밍을 사용하면 여러 연산을 병렬로 처리할 수 있다. 그래서 현재는 거의 모든 시스템에 장착된 멀티 프로세서를 최대한 활용할 수 있다. 20년 전만 해도 프로세서 제조사는 속도 경쟁에 열을 올렸지만 2005년 즈음 전력 소모량과 발열 문제가 발생하면서 속도 경쟁의 한계에 부딪혔다. 그래서 듀얼 코어, 쿼드 코어 프로세스가 보편화됐고, 12, 16, 18 코어나 심지어 그보다 더 많은 코어를 장착한 프로세서가 등장하게 됐다.

CPU 뿐만 아니라 GPU라 부르는 그래픽 카드용 프로세서도 자세히 들여다보면 상당히 병렬화돼 있다. 요즘 나오는 고성능 그래픽 카드는 코어를 무려 4,000개 이상 장착하고 있으며 그 수는 계속 증가하고 있다. 이렇게 제작된 그래픽 카드는 단순히 게임용으로만 사용하지 않고, 수학 연산의 비중이 높은 작업을 처리하는데도 활용된다. 예컨대 이미지나 비디오 처리, 단백질 분석, 외계지적생명체 탐사 프로젝트에서 신호를 처리하는 작업 등에 활용된다.

C++ 98/03 버전은 멀티스레드 프로그래밍을 지원하지 않아서 서드파티 라이브러리나 타깃 시스템의 OS에서 제공하는 멀티스레드 API를 활용하는 수밖에 없었다. C++ 11부터 표준 멀티스레드 라이브러리가 추가되면서 크로스 플랫폼 멀티스레드 프로그램을 작성하기 한결 쉬워졌다. 현재 C++ 표준은 GPU를 제외한 CPU만을 대상으로 API를 정의하고 있지만, 향후 GPU도 지원하도록 개선될 가능성이 있다.

멀티스레드 프로그래밍이 필요한 이유는 크게 두 가지다. 첫쨰, 주어진 연산 작업을 작은 문제들로 나눠서 각각을 멀티프로세서 시스템에서 병렬로 실행하면 전반적인 성능을 크게 높일 수 있다.

둘째, 연산을 다른 관점에서 모듈화 할 수 있다. 예컨대 연산을 UI 스레드에 종속적이지 않은 독립 스레드로 분리해서 구현하면 처리 시간이 긴 연산을 백그라운드로 실행시키는 방식으로 UI의 응답 속도를 높일 수 있다.

아래 그림은 병렬 처리가 절대적으로 유리한 상황을 보여준다. 예컨대 이미지의 픽셀을 처리할 때 주변 픽셀 정보를 참조하지 않는 방식으로 구현한다고 하자. 그러면 이미지를 크게 네 부분으로 나눠서 처리하도록 알고리즘을 구성할 수 있다. 이러면 성능이 코어 수에 정비례 하게 된다.

항상 독립 작업으로 나눠서 병렬화 할 수 있는 것은 아니지만 최소한 일부분만이라도 병렬화 할 수 있다면 조금이라도 성능을 높일 수 있다. 멀티스레드 프로그래밍을 하는데 어려운 부분은 병렬 알고리즘을 고안하는 것이다. 처리할 작업의 성격에 따라 구현 방식이 크게 달라지기 때문이다.

또한 경쟁 상태, 교착 상태(데드락), 테어링(tearing), 잘못된 공유(false-sharing) 등과 같은 문제가 발생하지 않게 만드는 것도 쉽지 않다. 이런 문제는 주로 아토믹과 명싲거인 동기화 메커니즘으로 해결하며 구체적인 방법은 뒤에 소개하겠다.

Note) 멀티스레드 관련 문제를 방지하려면 여러 스레드가 공유 메모리를 동시에 읽거나 쓰지 않도록 디자인해야 한다. 아니면 동기화 기법이나 아토믹 연산을 적용한다.

경쟁 상태

여러 스레드가 공유 리소스를 동시에 접근할 때 경쟁 상태가 발생할 수 있다. 그중에서도 공유 메모리에 대한 경쟁 상태를 흔히 데이터 경쟁이라 부른다. 데이터 경쟁은 여러 스레드가 공유 메모리에 동시에 접근할 수 있는 상태에서 최소 하나의 스레드가 그 메모리에 데이터를 쓸 때 발생한다.

예컨대 공유 변수가 하나 있는데 어떤 스레드는 이 값을 증가시키고, 또 어떤 스레드는 이 값을 감소시키는 경우를 생각해보자. 값을 증가하거나 감소하려면 현재 값을 메모리에서 읽어서 증가나 감소 연산을 수행해야 한다.

PDP-11이나 VAX와 같은 예전 아키텍쳐는 아토믹하게 실행되는(주어진 시점에 혼자만 실행되는) INC와 같은 인스트럭션을 제공했다. 하지만 최신 x86 프로세서에서 제공하는 INC는 더는 아토믹하지 않다. 다시 말해 INC를 처리하는 도중에 다른 인스트럭션이 실행될 수 있기 때문에 결과가 얼마든지 달라질 수 있다.

다음 표는 초깃값이 1일 때 감소 연산이 실행되기 전에 증가 연산을 마치는 경우를 보여준다.

스레드 1(증가 연산) 스레드 2(감소 연산)
값을 불러온다 (값 = 1)  
값을 하나 증가시킨다 (값 = 2)  
값을 저장한다 (값 = 2)  
  값을 불러온다 (값 = 2)
  값을 하나 감소시킨다 (값 = 1)
  값을 저장한다 (값 = 1)

메모리에 기록되는 최종 결과는 1이다. 이와 반대로 다음 표와 같이 증가 연산을 수행하는 스레드가 시작하기 전에 감소 연산을 수행하는 스레드가 작업을 모두 마쳐도 최종 결과는 1이 된다.

스레드 1(증가 연산) 스레드 2(감소 연산)
  값을 불러온다 (값 = 1)
  값을 하나 감소시킨다 (값 = 0)
  값을 저장한다 (값 = 0)
값을 불러온다 (값 = 0)  
값을 하나 증가시킨다 (값 = 1)  
값을 저장한다 (값 = 1)  

그런데 두 작업이 다음 표와 같이 서로 엇갈리면 결과가 달라진다.

스레드 1(증가 연산) 스레드 2(감소 연산)
값을 불러온다 (값 = 1)  
값을 하나 증가시킨다 (값 = 2)  
  값을 불러온다 (값 = 1)
  값을 하나 감소시킨다 (값 = 0)
값을 저장한다 (값 = 2)  
  값을 저장한다 (값 = 0)

이렇게 하면 최종 결과는 0이 된다. 다시 말해 증가 연산의 효과가 사라진다. 이를 데이터 경쟁 상태라 부른다.

테어링

테어링(tearing)이란 데이터 경쟁의 특수한 경우로서 크게 읽기 테어링(torn read), 쓰기 테어링(torn write)의 두 가지가 있다. 어떤 스레드가 메모리에 데이터의 일부만 쓰고 나머지 부분을 미처 쓰지 못한 상태에서 다른 스레드가 이 데이터를 읽으면 두 스레드가 보는 값이 달라진다. 이를 읽기 테어링리라 부른다.

또한 두 스레드가 이 데이터를 동시에 쓸 때 한 스레드는 그 데이터의 한쪽 부분을 쓰고, 다른 스레드는 그 데이터의 다른 부분을 썼다면 각자 수행한 결과가 달라진다. 이를 쓰기 테어링이라 부른다.

데드락

경쟁 상태를 막기 위해 상호 배제와 같은 동기화 기법을 적요하다 보면 멀티스레드 프로그래밍에서 흔히 발생하는 또 다른 문제인 데드락(교착 상태)에 부딪히기 쉽다. 데드락이란 여러 스레드가 서로 상대방 작업이 끝날 때까지 동시에 기다리는 상태를 말한다.

예컨대 두 스레드가 공유 리소스를 서로 접근하려면 그 리소스에 대한 접근 권한 요청부터 해야 한다. 현재 둘 중 한 스레드가 그 리소스에 대한 접근 권한을 확보한 상태로 계속 머물러 있으면 그 리소스에 대한 접근 권한을 요청하는 다른 스레드도 무한히 기다려야 한다. 이때 공유 리소스에 대한 접근 권한을 얻기 위한 방법 중에는 23.4절 ‘상호 배제’에서 설명할 상호 배제(뮤텍스)라는 것이 있다.

예컨대 스레드가 두 개 있고 리소스도 두 개 있을 때 이를 A와 B라는 뮤텍스 객체로 보호하고 있다고 하자. 이때 두 스레드가 각 리소스에 대한 접근 권한을 얻을 수 있지만 그 순서는 다음 표와 같이 서로 다른 경우를 생각해 보자.

스레드 1 스레드 2
A 확보 B 확보
B 확보 A 확보
// 작업을 수행한다 // 작업을 수행한다
B 해제 A 해제
A 해제 B 해제

이 스레드가 실행되면 다음과 같이 진행될 수 있다.

  • 스레드 1: A 확보
  • 스레드 2: B 확보
  • 스레드 1: B 확보 (스레드 2가 B를 확보하고 있기 때문에 기다린다)
  • 스레드 2: A 확보 (스레드 1이 A를 확보하고 있기 때문에 기다린다)

그러면 두 스레드 모두 상대방을 무한정 기다린느 데드락이 발생한다. 이러한 데드락 상황을 그림으로 표현하면 다음과 같다. 스레드 1은 A 리소스에 대한 접근 권한을 확보한 상태에서 B 리소스의 접근 권한을 얻을 때까지 기다린다. 스레드 2는 B 리소스의 접근 권한을 확보한 상태에서 A 리소스의 접근 권한을 얻을 때까지 기다린다. 그림을 보면 데드락 상황이 순환 관계를 이루고 있다. 결국 두 스레드는 서로 상대방을 무한정 기다리게 된다.

이러한 데드락이 발생하지 않게 하려면 모든 스레드가 일정한 순서로 리소스를 획득해야 한다. 또한 데드락이 발생해도 빠져나올 수 있는 메커니즘도 함께 구현하면 좋다.

한 가지 방법은 리소스 접근 권한을 요청하는 작업에 시간 제한을 걸어두는 것이다. 그래서 주어진 시간 안에 리소스를 확보할 수 없으면 더는 기다리지 않고 현재 확보한 권한을 해제한다. 그러고 나서 일정 시간 동안 기다렸다가 리소스를 확보하는 작업을 다시 시도한다. 이렇게 하면 다른 스레드가 리소스에 접근할 기회를 줄 수 있다. 물론 이 기법만으로 문제를 해결할 수 있는지는 주어진 데드락 상황에 따라 다르다.

방금 소개한 우회 기법보다는 데드락 상황 자체가 아예 발생하지 않게 만드는 것이 바람직하다. 여러 뮤텍스 객체로 보호받고 있는 리소스 집합에 대해 접근 권한을 얻을 떄는 리소스마다 접근 권한을 개별적으로 요청하지 않고 std::lock()이나 std::try_lock() 같은 함수를 활용하는 것이 좋다. 이 함수는 여러 리소스에 대한 권한을 한 번에 확보하거나 요청한다.

잘못된 공유

캐시(cache)는 캐시 라인(cache line) 단위로 처리된다. 최신 CPU는 흔히 64바이트 캐시 라인으로 구성된다. 캐시 라인에 데이터를 쓰려면 반드시 그 라인 전체에 락을 걸어야 한다. 멀티스레드 코드를 실행할 때 데이터 구조를 잘 만들지 않으면 캐시 라인에 락을 거는 과정에서 성능이 크게 떨어질 수 있다.

예컨대 두 스레드가 두 가지 데이터 영역을 사용하는데, 데이터가 같은 캐시 라인에 걸쳐 있는 경우를 생각해 보자. 이때 한 스레드가 데이터를 업데이트하면 캐시 라인 전체에 락을 걸어버리기 때문에 다른 스레드는 기다려야 한다. 캐시 라인에 걸쳐 있지 않도록 데이터 구조가 저장될 메모리 영역을 명시적으로 정렬하면 여러 스레드가 접근할 때 대기하지 않게 만들 수 있다.

이러한 코드를 이식하기 좋게 작성할 수 있도록 C++ 17부터 <new> 헤더 파일에 hardware_destructive_interference_size란 상수가 추가됐다. 이 상수는 동시에 접근하는 두 객체가 캐시 라인을 고융하지 않도록 최소한의 오프셋을 제시해준다. 이 값과 alignas 키워드를 데이터를 적절히 정렬하는데 활용하면 된다.

스레드

<thread> 헤더 팡리에 정의된 C++ 스레드 라이브러리를 사용하면 스레드를 매우 간편하게 생성할 수 있다. 이때 새로 만든 스레드가 할 일을 지정하는 방식은 다양하다. 전역 함수로 표현하거나 함수 객체의 operator()로 표현하거나 람다 표현식으로 지정하거나 특정 클래스의 인스턴스에 있는 멤버 함수로 지정할 수 있다.

함수 포인터로 스레드 만들기

윈도우 시스템의 CreateThread(), _beginthread()와 같은 함수나 pthreads 라이브러리의 pthread_create()와 같은 스레드 함수는 매개변수를 하나만 받는다. 반면 C++ 표준에서 제공하는 std::thread 클래스에서 사용하는 함수는 매개변수를 원하는 만큼 받을 수 있다.

예컨대 다음과 같이 정수 두 개를 인수로 받는 counter() 함수를 살펴보자. 첫 번째 인수는 ID를 표현하고 두 번째 인수는 이 함수가 루프를 도는 횟수를 표현한다. 이 함수는 인수로 지정한 횟수만큼 표준 출력에 메시지를 보내는 문장을 반복한다.

void counter(int id, int numIterations)
{
for (int i = 0; i < numIteratrions; ++i)
{
cout << "Counter " << id << " has value " << i << endl;
}
}

std::thread를 이용하면 이 함수를 여러 스레드로 실행하게 만들 수 있다. 예컨대 인수로 1과 6을 지정해서 counter()를 수행하는 스레드인 t1을 다음과 같이 생성할 수 있다.

thread t1(counter, 1, 6);

thread 클래스 생성자는 가변 인수 템플릿이기 때문에 인수 개수를 원하는 만큼 지정할 수 있다. 첫 번째 인수는 새로 만들 스레드가 실행할 함수의 이름이다. 그 뒤에 나오는 인수는 스레드가 구동되면서 실행할 함수에 전달할 인수 개수다.

현재 시스템에서 thread 객체가 실행 가능한 상태에 있을 때 조인 가능(joinable) 하다고 표현한다. 이런 스레드는 실행을 마치고 나서도 조인 가능한 상태를 유지한다. 디폴트로 생성된 thread 객체는 조인 불가능(unjoinable)하다. 조인 가능한 thread 객체를 제거하려면 먼저 그 객체의 join()이나 detach()부터 호출해야 한다.

join()을 호출하면 그 스레드는 블록된다. 다시 말해 그 스레드가 작업을 끝낼 때까지 기다린다. detach()를 호출하면 thread 객체를 OS 내부의 스레드와 분리한다. 그래서 OS 스레드는 독립적으로 실행된다. 두 메서드 모두 스레드를 조인 불가능한 상태로 전환시킨다. 조인 가능 상태의 thread 객체를 제거하면 그 객체의 소멸자는 std::terminate()를 호출해서 모든 스레드뿐만 아니라 애플리케이션마저 종료시킨다.

다음 코드는 counter() 함수를 실행하는 스레드를 두 개 생성한다. main()에서 스레드를 생성하고 나서 곧바로 두 스레드에 대해 join()을 호출한다.

thread t1(counter, 1, 6);
thread t2(counter, 2, 4);

t1.join();
t2.join();

코드를 실행하는 시스템마다 결과가 달라질 수 있고, 같은 시스템에서도 실행할 때마다 결과가 달라질 수 있다. 두 스레드가 counter() 함수를 동시에 실행하므로 시스템에 장착된 코어 수와 OS의 스레드 스케쥴링 방식에 따라 출력 형태가 달라진다.

기본적으로 cout에 접근하는 작업은 스레드에 안전해서 여러 스레드 사이에서 데이터 경쟁이 발생하지 않는다. (출력이나 입력 연산 직전에 cout.sync_with_stdio(false)를 호출하지 않았을 경우) 그런데 데이터 경쟁이 발생하지 않더라도 스레드마다 출력한 결과는 여전히 겹쳐질 수 있다. 동기화 기법을 적용하면 뒤섞이지 않게 만들 수 있는데, 이에 대해서는 뒤에 소개하겠다.

함수 객체로 스레드 만들기

이번에는 함수 객체로 스레드를 실행시키는 방법을 알아보자. 앞서 소개한대로 함수 포인터로 스레드를 만들면 함수에 인수를 전달하는 방식으로만 스레드에 정보를 전달할 수 있다. 반면 함수 객체로 만들면 그 함수 객체의 클래스에 멤버 변수를 추가해서 원하는 방식으로 초기화해서 사용할 수 있다.

다음 예제는 먼저 Counter란 클래스를 정의한다. 이 클래스는 ID와 반복 횟수를 표현하는 멤버 변수를 가지고 있다. 두 변수 모두 생성자로 초기화한다. Counter 클래스를 함수 객체로 만들려면 18장에서 설명한대로 operator()를 구현해야 한다. operator()를 구현하는 코드는 앞에서 본 counter()와 같다.

class Counter
{
public:
Counter(int id, int numIterations) : mId(id), mNumIterations(numIterations)
{
}

void operator()() const
{
for (int i = 0; i < mNumIterations; ++i)
{
cout << "Counter " << mId << " has value " << i << endl;
}
}

private:
int mId;
int mNumIterations;
};

다음 코드는 함수 객체로 만든 스레드를 초기화하는 세 가지 방법을 보여준다. 첫 번째 방법은 유니폼 초기화로 처리한다. Counter 생성자에 인수를 지정해서 인스턴스를 생성하면 그 값이 중괄호로 묶인 thread 생성자 인수로 전달된다.

두 번째 방법은 Counter 인스턴스를 일반 변수처럼 네임드 인스턴스로 정의하고, 이를 thread 클래스의 생성자로 전달한다.

세 번째 방법은 Counter 인스턴스를 생성해서 이를 thread 클래스 생성자로 전달하는 점에서 첫 번째와 비슷하지만, 중괄호가 아닌 소괄호로 묶는 점이 다르다.

// 유니폼 초기화를 사용하는 방법
thread t1{ Counter{ 1, 20 }};

// 일반 변수처럼 네임드 인스턴스로 초기화하는 방법
Counter c(2, 12);
thread t2(c);

// 임시 객체를 사용하는 방법
thread t3(Counter(3, 10));

// 세 스레드가 모두 마칠때까지 기다린다.
t1.join();
t2.join();
t3.join();

t1과 t3의 생성 과정을 비교하면 전자는 중괄호를 사용하고, 후자는 소괄호를 사용한다는 점이 달라보인다. 하지만 함수 객체 생성자가 매개변수를 받지 않을 때 후자와 같이 코드를 작성하면 에러가 발생한다. 예컨대 다음과 같다.

class Counter
{
public:
Counter() {}
void operator()() const { /* 코드 생략 */ }
};

int main()
{
thread t1(Counter()); // error
t1.join();
}

이렇게 작성하면 컴파일 에러가 발생한다. C++ 인터프리터는 main()의 첫 줄을 t1 함수의 선언문으로 해석하기 때문이다. 즉 매개변수 없이 Counter 객체를 리턴하는 Counter 함수에 대한 포인터를 인수로 받아서 thread 객체를 리턶나느 t1 함수로 처리한다. 이때는 다음과 같이 유니폼 초기화를 사용하는 것이 좋다.

thread t1{ Counter{} };  // 정상 처리 된다.

Note) 함수 객체는 항상 스레드의 내부 저장소에 복제된다. 함수 객체의 인스턴스를 복제하지 않고 그 인스턴스에 대해 operator()를 호출하려면 <functional> 헤더에 정의된 std::ref()나 cref()를 사용해서 인스턴스를 레퍼런스로 전달해야 한다. 예컨대 다음과 같다.

Counter c(2, 12);
thread t2(ref(c));

람다 표현식으로 스레드 만들기

람다 표현식은 표준 C++ 스레드 라이브러리와 궁합이 잘 맞는다. 예컨대 람다 표현식으로 정의한 작업을 실행하는 스레드를 생성하는 코드를 다음과 같이 작성할 수 있다.

int main()
{
int id = 1;
int numIterations = 5;

thread t1([id, numIterations] {
for (int i = 0; i < numIterations; ++i)
{
cout << "Counter " << id << " has value " << i << endl;
}
});

t1.join();
}

멤버 함수로 스레드 만들기

스레드에서 실행할 내용을 클래스의 멤버 함수로 지정할 수도 있다. 다음 코드는 기본 Request 클래스에 process() 메서드를 정의하고 있다. 그러고 나서 main() 함수에서 Request 클래스의 인스턴스를 생성하고, Request 인스턴스인 req의 process() 메서드를 실행하는 스레드를 생성한다.

class Request
{
public:
Request(int id) : mId(id) { }

void process()
{
cout << "Processing request " << mId << endl;
}

private:
int mId;
};

int main()
{
Request req(100);
thread t{ &Request::process, &req };
t.join();
}

이렇게 하면 특정한 객체에 있는 메서드를 스레드로 분리해서 실행할 수 있다. 똑같은 객체를 여러 스레드가 접근할 때 데이터 경쟁이 발생하지 않도록 스레드에 안전하게 작성해야 한다. 스레드에 안전하게 구현하는 방법 중 하나는 뒤에서 설명할 상호 배제(뮤텍스)라는 동기화 기법을 활용하는 것이다.

스레드 로컬 저장소

C++ 표준은 스레드 로컬 저장소(thread local storage)란 개념을 제공한다. 원하는 변수에 thread_local이란 키워드를 지정해서 스레드 로컬 저장소로 지원하면 각 스레드마다 이 변수를 복제해서 스레드가 없어질 때까지 유지한다. 이 변수는 각 스레드에서 한 번만 초기화된다.

예컨대 다음 코드에는 두 개의 전역 변수가 정의돼 있는데, 모든 스레드가 k의 복제본 하나를 공유하며, 각 스레드는 자신의 고유한 n의 복제본을 가진다.

int k;
thread_local int n;

만일 thread_local 변수를 함수 스코프 안에서 선언하면 모든 스레드가 복제본을 따로 갖고 있고, 함수를 아무리 많이 호출하더라도 스레드마다 단 한번만 초기화된다는 점을 제외하면 static으로 선언할 때와 똑같이 작동한다.

스레드 취소하기

C++ 표준은 현재 실행 중인 스레드를 다른 스레드에서 중단시키는 메커니즘을 제공하지 않는다. 이렇게 다른 스레드를 종료시키기 위한 가장 좋은 방법은 여러 스레드가 공통으로 따르는 통신 메커니즘을 제공하는 것이다. 가장 간단한 방법은 공유 변수를 활용하는 것이다.

값을 전달 받은 스레드는 이 값을 주기적으로 확인하면서 중단 여부를 결정한다. 나머지 스레드는 이러한 공유 변수를 이용해 이 스레드를 간접적으로 종료시킬 수 있다.

하지만 이때 조심해야 할 점이 있다. 여러 스레드가 공유 변수에 접근하기 때문에 최소한 한 스레드는 그 변수에 값을 쓸 수 있다. 따라서 이 변수를 아토믹이나 조건 변수로 만드는 것이 좋다.

스레드로 실행한 결과 얻기

앞서 나온 예제에서 볼 수 있듯이 스레드를 새로 만들기는 쉽다. 하지만 정작 중요한 부분은 스레드로 처리한 결과다. 예컨대 스레드로 수학 연산을 수행하면 모든 스레드가 종료한 뒤에 나오는 최종 결과를 구해야 한다. 

한 가지 방법은 결과를 담은 변수에 대한 포인터나 레퍼런스를 스레드로 전달해서 스레드마다 결과를 저장하게 만드는 것이다. 또 다른 방법은 함수 객체의 클래스 멤버 변수에 처리 결과를 저장했다가 나중에 스레드가 종료할 때 그 값을 가져오는 것이다. 이렇게 하려면 반드시 std::ref()를 이용해서 함수 객체의 레퍼런스를 thread 생성자에게 전달해야 한다.

그런데 이보다 더 쉬운 방법이 있다. 바로 future를 활용하는 것이다. 그러면 스레드 안에서 발생한 에러를 처리하기도 쉽다. 이에 대해서는 이후에 자세히 소개한다.

익셉션 복제와 다시 던지기

스레드가 하나만 있을 때는 C++의 익셉션 메커니즘 관련 문제가 발생할 일이 없다. 그런데 스레드에서 던진 익셉션은 그 스레드 안에서 처리해야 한다. 던진 익셉션을 스레드 안에서 잡지 못하면 C++ 런타임은 std::terminate()를 호출해서 애플리케이션 전체를 종료시킨다. 한 스레드에서 던진 익셉션을 다른 스레드에서 잡을 수는 없다. 그래서 멀티스레드 환경에서 익셉션을 처리하는 과정에 여러 가지 문제가 발생한다.

표준 스레드 라이브러리를 사용하지 않고도 스레드 사이에 발생한 익셉션을 처리할 수 있지만 굉장히 힘들다. 이를 위해 표준 스레드 라이브러리는 다음과 같은 익셉션 관련 함수를 제공한다. 이 함수는 std::exception 뿐만 아니라 int, string, 커스텀 익셉션 등에도 적용된다.

  • exception_ptr current_exception() noexcept;
    • 이 함수는 catch 블록에서 호출하며, 현재 처리할 익셉션을 가리키는 exception_ptr 객체나 그 복사본을 리턴한다. 현재 처리하는 익셉션이 없으면 널 exception_ptr 객체를 리턴한다. 이때 참조하는 익셉션 객체는 exception_ptr 타입의 객체가 존재하는 한 유효하다. exception_ptr의 타입은 NullablePointer이기 때문에 간단히 if 문을 작성해서 테스트하기 쉽다. 이에 대한 예제는 뒤에서 소개한다.
  • [[noreturn]] void rethrow_exception(exception_ptr p);
    • 이 함수는 exception_ptr 매개변수가 참조하는 익셉션을 다시 던진다. 참조한 익셉션을 반드시 그 익셉션이 처음 발생한 스레드 안에서만 다시 던져야 한다는 법은 없다. 그래서 여러 스레드에서 발생한 익셉션을 처리하는 용도로 딱 맞다. [[noreturn]] 속성은 이 함수가 정상적으로 리턴하지 않는다는 것을 선언한다.
  • template<class E> exception_ptr make_exception_ptr(E e) noexcept;
    • 이 함수는 주어진 익셉션 객체의 복사본을 참조하는 exception_ptr 객체를 생성한다. 실질적으로 다음 코드의 축약이다. try  { tyrow e; } catch(…) { return current_exception(); }

이러한 함수로 스레드에서 발생한 익셉션을 처리하는 방법을 살펴보자. 다음 코드는 일정한 작업을 수행한 뒤 익셉션을 던지는 함수를 정의한다. 이 함수는 별도 스레드로 실행한다.

void doSomeWork()
{
for (int i = 0; i < 5; ++i)
{
cout << i << endl;
}
cout << "Thread throwing a runtime_error exception..." << endl;
throw runtime_error("Exception from thread");
}

다음 threadFunc() 함수는 doSomeWork()가 던진 익셉션을 모두 받도록 try/catch 블록으로 묶는다. threadFunc()는 exception_ptr& 타입 인수 하나만 받는다. 익셉션을 잡았다면 current_exception() 함수를 이용하여 처리할 익셉션에 대한 레퍼런스를 받아서 exception_ptr 매개변수에 대입한다. 그런 다음 스레드는 정상적으로 종료한다.

void threadFunc(exception_ptr& err)
{
try
{
doSomeWork();
}
catch (...)
{
cout << "Thread caught exception, returning exception..." << endl;
err = current_exception();
}
}

다음 doWorkInThread() 함수는 메인 스레드에서 호출된다. 이 함수는 스레드를 생성해서 그 안에 담긴 threadFunc()의 실행을 시작하는 역할을 담당한다. threadFunc()의 인수로 exception_ptr 타입 객체에 대한 레퍼런스를 지정한다. 일단 스레드가 생성되면 doWorkInThread() 함수는 join() 메서드를 이용하여 이 스레드가 종료될 때까지 기다리고 그 후 에러 객체가 발생하는지 검샇나다.

exception_ptr은 NullablePointer 타입이기 때문에 if 문으로 간단히 검사할 수 있다. 이 값이 널이 아니라면 현재 스레드에 그 익셉션을 다시 던진다. 이 예제에서는 메인 스레드가 현재 스레드다. 이 익셉션을 메인 스레드에서 다시 던지기 때문에 한 스레드에서 다른 스레드로 전달된다.

void doWorkInThread()
{
exception_ptr error;

// 스레드를 구동한다.
thread t{ threadFunc, ref(error) };

// 스레드가 종료할 때까지 기다린다.
t.join();

// 스레드에 익셉션이 발생했는지 검사한다.
if (error)
{
cout << "Main thread received exception, rethrowing it..." << endl;
rethrow_exception(error);
}
else
{
cout << "Main thread did not receive any exception" << endl;
}
}

여기서 구현한 main() 함수는 간단하다. doWorkInThread()를 호출하고, doWorkInThread()에서 생성한 스레드가 던진 익셉션을 받도록 try/catch 블록을 작성한다.

int main()
{
try
{
doWorkInThread();
}
catch (const exception& e)
{
cout << "Main function caught: '" << e.what() << "'" << endl;
}
}

이 장에서는 예제를 최대한 간결하게 구성하기 위해 main() 함수에서 join()을 호출해서 메인 스레드를 블록 시키고 스레드가 모두 마칠 때까지 기다린다. 물론 실전에서는 이렇게 메인 스레드를 블록 시키면 안 된다. 예컨대 GUI 애플리케이션에서 메인 스레드를 블록시키면 UI가 반응하지 않게 된다.

이럴 때는 스레드끼리 메시지로 통신하는 기법을 사용한다. 예컨대 앞서 본 threadFunc() 함수는 current_exception() 결과의 복제본을 인자로 하여 UI 스레드로 메시지를 보낼 수 있다. 하지만 앞서 설명했듯이 이렇게 하더라도 생성된 스레드에 대해 join()이나 detach()를 호출해야 한다.

아토믹 연산 라이브러리

아토믹 타입(atomic type)을 사용하면 동기화 기법을 적용하지 않고 읽기와 쓰기를 동시에 처리하는 아토믹 접근(atomic access)이 가능하다. 아토믹 연산을 사용하지 않고 변수의 값을 증가시키면 스레드에 안전하지 않다. 컴파일러는 먼저 메모리에서 이 값을 읽고, 레스스터로 불러와서 값을 증가시킨 다음, 그 결과를 메모리에 다시 저장한다.

그런데 이 과정에서 같은 메모리 영역을 다른 스레드가 건드리면 데이터 경쟁이 발생한다. 예컨대 다음 코드는 스레드에 안전하지 않게 작성돼 데이터 경쟁이 발생하는 상황을 보여준다. 

int counter = 0;  // 전역 변수
++counter; // 여러 스레드에서 실행한다.

이 변수에 std::atomic 타입을 적용하면 뮤텍스 객체와 같은 동기화 기법을 따로 사용하지 않고도 스레드에 안전하게 만들 수 있다. 앞서 나온 코드를 이렇게 고치면 다음과 같다.

atomic<int> counter(0);  // 전역 변수
++counter; // 여러 스레드에서 실행한다.

아토믹 타입을 사용하려면 <atomic> 헤더 파일을 인클루드 해야 한다. C++ 표준은 언어에서 제공하는 모든 기본 타입마다. 네임드(이름이 지정된) 정수형 아토믹 타입을 정의하고 있다. 그 중 몇가지만 소개하면 다음과 같다.

네임드 아토믹 타입 동등 std::atomic 타입
atomic_bool atomic<bool>
atomic_char atomic<char>
atomic_uchar atomic<unsigned char>
atomic_int atomic<int>
atomic_uint atomic<unsigned int>
atomic_long atomic<long>
atomic_ulong atomic<unsigned long>
atomic_llong atomic<long long>
atomic_ullong atomic<unsigned long long>
atomic_wchar_t atomic<wchar_t>

아토믹 타입을 사용할 때는 동기화 메커니즘으로 명싲거으로 사용하지 않아도 된다. 하지만 특정 타입에 대해 아토믹 연산으로 처리할 때는 뮤텍스와 같은 동기화 메커니즘을 내부적으로 사용하기도 한다.

예컨대 연산을 아토믹 방식으로 처리하는 인스트럭션을 타깃 하드웨어에서 제공하지 않을 수 있다. 이럴 때는 아토믹 타입에 대해 is_lock_free() 메서드를 호출해서 잠그지 않아도 되는지(lock-free 인지) 즉, 명시적으로 동기화 메커니즘을 사용하지 않고도 수행할 수 있는지 확인한다.

std::atomic 클래스 템플릿은 정수 타입 뿐만 아니라 다른 모든 종류의 타입에 대해서도 적용할 수 있다. 예컨대 atomic<double>이나 atomic<MyType>과 같이 쓸 수 있다. 단, MyType을 쉽게 복제할 수 있어야 한다. 지정한 타입의 크기에 따라 내부적으로 동기화 메커니즘을 사용해야 할 수도 있다.

다음 예제를 보면 Foo와 Bar를 쉽게 복제할 수 있다. 다시 말해 이 둘에 대해 std::is_trivially_copyable_v가 true다. 하지만 atomic<Foo>는 lock-free가 아니고, atomic<Bar>는 lock-free다.

class Foo { private: int mArray[123]; };
class Bar { private: int mInt; };

int main()
{
atomic<Foo> f;
cout << is_trivially_copyable_v<Foo> << " " << f.is_lock_free() << endl;

atomic<Bar> b;
 cout << is_trivially_copyable_v<Bar> << " " << b.is_lock_free() << endl;
}

// 실행 결과
// 1 0
// 1 1

일정한 데이터를 여러 스레드가 동시에 접근할 때 아토믹을 사용하면 메모리 순서, 컴파일러 최적화 등과 같은 문제를 방지할 수 있다. 기본적으로 아토믹이나 동기화 메커니즘을 사용하지 않고서 동일한 데이터를 여러 스레드가 동시에 읽고 쓰는 것은 위험하다.

아토믹 타입 사용 예

(예시 코드 생략)

이처럼 코드에 동기화 메커니즘을 따로 추가하지 않고도 스레드에 안전하고 데이터 경쟁이 발생하지 않게 만들 수 있다. ++counter 연산을 수행하는데 필요한 불러오기(load), 증가, 저장 작업을 하나의 아토믹 트랜잭션으로 처리해서 중간에 다른 스레드가 개입할 수 없기 때문이다.

그런데 이렇게 하면 성능 문제가 발생한다. 아토믹이나 동기화 메커니즘을 사용할 때 동기화 작업으로 인해 성능이 떨어지기 때문에 이 부분을 처리하는데 걸리는 시간을 최소화 하도록 구성해야 한다. 

앞서 본 예제처럼 간단한 코드라면 increment()가 로컬 변수에 대해 결과를 계산하도록 만들고, 루프를 마친 후 그 결과를 counter 레퍼런스로 추가하도록 작성하는 것이 가장 바람직하다. 이렇게 할 때도 여전히 아토믹 타입을 사용해야 한다. 여러 스레드가 counter 변수에 쓰는 작업을 수행한다는 점은 변하지 ㅇ낳기 때문이다.

아토믹 연산

C++ 표준에서는 여러 가지 아토믹 연산을 정의하고 있다. 이 절에서는 그중 몇 가지만 소개한다. 

아토믹 연산에 대한 첫 번째 예제는 다음과 같다.

bool atomic<T>::compare_exchage_strong(T& expected, T desired);

이 연산을 아토믹하게 수행하도록 구현하면 다음과 같다. 여기서는 의사 코드로 표현했다.

if (*this == expected)
{
*this = desired;
return true;
}
else
{
expected = *this;
return false;
}

얼핏 보면 좀 이상하지만 데이터 구조를 락을 사용하지 않고 동시에 접근하게 만드는데 핵심적인 연산이다. 이렇게 락-프리 동시성 데이터 구조(lock-free concurrent data structure)를 이용하면 이 데이터 구조에 대해 연산을 수행할 때 동기화 메커니즘을 따로 사용하지 않아도 된다. 하지만 이렇게 데이터 구조를 구현하는 기법은 고급 주제에 해당하며 이 책에서는 소개하지 ㅇ낳는다.

두 번째 예제는 정수 아토믹 타입에 적용되는 atomic<T>::fetch_add()를 사용하는 것이다. fetch_add()는 주어진 아토믹 타입의 현재 값을 가져와서 지정한 값만큼 증가시킨 다음, 증가시키기 전의 값을 리턴한다. 예컨대 다음과 같다.

atomic<int> value(10);
cout << "Value = " << value << endl;
int fetched = value.fetched_add(4);
cout << "Fetched = " << fetched << endl;
cout << "Value = " << value << endl;

// 실행 결과
// Value = 10
// Fetched = 10
// Value = 14

정수형 아토믹 타입은 fetch_add(), fetch_sub(), fetch_and(), fetch_or(), fetch_xor(), ++, –, +=, -=, &=, ^=, |=과 같은 아토믹 연산을 지원한다. 아토믹 포인터 타입은 fetch_add(), fetch_sub(), ++, –, +=, -=을 지원한다.

아토믹 연산은 대부분 원하는 메모리 순서를 지정하는 매개변수를 추가로 받는다. 예컨대 다음과 같다.

T atomic<T>::fetch_add(T value, memory_order = memory_order_seq_cst);

그러면 디폴트로 설정된 memory_order 값을 다른 값으로 변경할 수 있다. C++ 표준은 이를 위해 memory_order_relaxed, memory_order_consume, memory_order_acquire, memory_order_release, memory_order_acq_rel, memory_order_seq_cst를 제공한다. 모두 std 네임스페이스 아래에 정의돼 있다. 

그런데 디폴트 값이 아닌 다른 값을 지정할 일은 별로 없다. 디폴트보다 나은 성능을 보여주는 메모리 순서가 있긴 하지만 자칫 잘못하면 데이터 경쟁이 발생하거나 디버깅하기 힘든 스레드 관련 문제가 발생할 수 있다. 

상호 배제

멀티스레드 프로그램을 작성할 때는 반드시 연산의 순서를 신중하게 결정해야 한다. 스레드에서 공유 데이터를 읽거나 쓰면 문제가 발생할 수 있기 때문이다. 이러한 문제를 방지하기 위한 방법은 다양하다. 극단적으로 스레드끼리 데이터를 아예 공유하지 않게 만들 수도 있다. 하지만 공유를 막을 수 없다면 한 번에 한 스레드만 접근할 수 있도록 동기화 메커니즘을 제공해야 한다.

부울값이나 정숫값을 비롯한 스칼라 값은 앞서 소개한 아토믹 연산만으로도 충분히 동기화할 수 있다. 하지만 복잡하게 구성된 데이터를 여러 스레드가 동시에 접근할 때는 동기화 메커니즘을 사용해야 한다.

표준 라이브러리는 mutex와 lock 클래스를 통해 상호 배제 메커니즘을 제공한다. 이를 활용하면 여러 스레드를 동기화하도록 구현할 수 있다.

mutex

mutex(뮤텍스)는 상호 배제를 뜻하는 mutual exclusion의 줄임말이다. mutex의 기본 사용법은 다음과 같다.

  • 다른 스레드와 공유하는 (읽기/쓰기용) 메모리를 사용하려면 먼저 mutex 객체에 락을 걸어야(잠근 요청을 해야) 한다. 다른 스레드가 먼저 락을 걸어뒀다면 그 락이 해제되거나 타임아웃으로 지정된 시간이 경과해야 쓸 수 있다.
  • 스레드가 락을 걸었다면 공유 메모리를 마음껏 쓸 수 있다. 물론 공유 데이터를 사용하려는 스레드마다 뮤텍스에 대한 락을 걸고 해제하는 동작을 정확히 구현해야 한다.
  • 공유 메모리에 대한 읽기/쓰기 작업이 끝나면 다른 스레드가 공유 메모리에 대한 락을 걸 수 있도록 락을 해제한다. 두 개 이상의 스레드가 락을 기다리고 있다면 어느 스레드가 먼저 락을 걸어 작업을 진행할지 알 수 없다.

C++ 표준은 시간 제약이 없는 뮤텍스(non-timed mutex)와 시간 제약이 있는 뮤텍스(timed mutex) 클래스를 제공한다.

시간 제약이 없는 뮤텍스 클래스

표준 라이브러리는 std::mutex, recursive_mutex, shared_mutex라는 세 종류의 시간 제약이 없는 뮤텍스 클래스(non-timed mutex class)를 제공한다. 그중 첫 번째와 두 번째 클래스는 <mutex> 헤더 파일에 정의돼 있고, 세 번째 클래스는 C++ 17부터 추가된 것으로 <shared_mutex> 헤더 팡리에 정의돼 있다. 각 뮤텍스 마다 다음과 같은 메서드를 제공한다.

  • lock()
    • 호출하는 스레드가 락을 완전히 걸 때까지 대기한다(블록된다). 이때 대기 시간에는 제한이 없다. 스레드가 블록되는 시간을 정하려면 다음 절에서 설명하는 시간 제약이 있는 뮤텍스를 사용한다.
  • try_lock()
    • 호출하는 측의 스레드가 락을 걸도록 시도한다. 현재 다른 스레드가 락을 걸었다면 호출이 즉시 리턴된다. 락을 걸었다면 try_lock()은 true를 리턴하고, 그렇지 않으면 false를 리턴한다.
  • unlock()
    • 호출하는 측의 스레드가 현재 걸어둔 락을 해제한다. 그러면 다른 스레드가 락을 걸 수 있게 된다.

std::mutex는 소유권을 독점하는 기능을 제공하는 표준 뮤텍스다. 이 뮤텍스는 한 스레드만 가질 수 있다. 다른 스레드가 이 뮤텍스를 소유하려면 lock()을 호출하고 대기한다. try_lock()을 호출하면 락 걸기에 실패해 곧바로 리턴된다. 뮤텍스를 이미 확보한 스레드가 같은 뮤텍스에 대해 lock()이나 try_lock()을 또 호출하면 데드락이 발생하므로 조심해야 한다.

std::recursive_mutex는 mutex와 거의 같지만 이미 recursive_mutex를 확보한 스레드가 동일한 recursive_mutex에 대해 lock()이나 try_lock()을 또 다시 호출할 수 있다. recursive_mutex에 대한 락을 해제하려면 lock()이나 try_lock()을 호출한 횟수만큼 unlock() 메서드를 호출해야 한다.

shared_mutex는 공유 락 소유권(shared lock ownership) 또는 읽기-쓰기 락(reader-writer lock)이란 개념을 구현한 것이다. 스레드는 락에 대한 독점 소유권(exclusive ownership)이나 공유 소유권(shared ownership)을 얻는다. 독점 소유권 또는 쓰기 락(write lock)은 다른 스레드가 독점 소유권이나 공유 소유권을 가지고 있지 않을 때만 얻을 수 있다. 공유 소유권 또는 읽기 락(read lock)은 다른 스레드가 독점 소유권을 가지고 있지 않거나 공유 소유권만 가지고 있을 때 얻을 수 있다.

shared_mutex 클래스는 lock(), try_lock(), unlock() 메서드를 제공한다. 이 메서드는 독점 락을 얻거나 해제한다. 또한 lock_shared(), try_lock_shared(), unlock_shared()와 같은 공유 소유권 관련 메서드도 제공한다. 공유 소유권 버전의 메서드도 기존 메서드와 비슷하지만 획득하고 해제하는 대상이 공유 소유권이라는 점이 다르다.

shated_mutex에 이미 락을 건 스레드는 같은 뮤텍스에 대해 한 번 더 락을 걸 수 없다. 그러면 데드락이 발생하기 때문이다.

시간 제약이 있는 뮤텍스 클래스

표준 라이브러리는 std::time_mutex, recursive_timed_mutex, shared_timed_mutex라는 세 종류의 시간 제약이 있는 뮤텍스 클래스를 제공한다. 그중 첫 번째와 두 번째 클래스는 <mutex>에 세 번째 클래스는 <shared_mutex>에 정의돼 있다. 세 가지 클래스 모두 lock(), try_lock(), unlock() 메서드를 제공하고, shared_timed_mutex는 lock_shared(), try_lock_shared(), unlock_shared()도 제공한다. 이러한 메서드의 동작은 모두 앞 절에서 설명한 방식과 같다. 여기에 추가로 다음 메서드도 제공한다.

  • try_lock_for(rel_time)
    • 호출하는 측의 스레드는 주어진 상대 시간 동안 락을 획득하려 시도한다. 주어진 타임아웃 시간 안에 락을 걸 수 없으면 호출은 실패하고 false를 리턴한다. 주어진 타임아웃 시간 안에 락을 걸었다면 호출은 성공하고 true를 리턴한다. 
  • try_lock_untin(abs_time)
    • 호출하는 측의 스레드는 인수로 지정한 절대 시간이 시스템 시간과 길거나 초과하기 전까지 락을 걸도록 시도한다. 그 시간 내에 락을 걸수 있다면 true를 리턴한다. 지정된 시간이 경과하면 이 함수는 더는 락을 걸려는 시도를 멈추고 false를 리턴한다.

shared_time_mutex는 try_lock_shared_for()와 try_lock_shared_until()도 제공한다.

time_mutex나 shared_time_mutex의 소유권을 이미 확보한 스레드는 같은 뮤텍스에 대해 락을 중복해서 걸지 못한다. 그러면 데드락이 발생하기 때문이다.

recursive_timed_mutex를 이용하면 스레드가 락을 중복해서 걸 수 있다. 사용법은 recursive_mutex와 같다.

Caution) 앞서 소개한 뮤텍스 클래스에 대한 락/언락 메서드를 직접 호출하면 안 된다. 뮤텍스 락은 일종의 리소스라서 거의 대부분 RAII 원칙에 따라 독점적으로 획득한다. RAII는 28장에서 자세히 소개한다. C++ 표준은 RAII 락 클래스를 다양하게 제공한다. 이에 대해서는 다음 절에서 소개한다. 데드락을 방지하려면 반드시 락 클래스를 사용하는 것이 좋다. 락 객체가 스코프를 벗어나면 자동으로 뮤텍스를 언락해주기 때문에 unlock() 메서드를 일일이 정확한 시점에 호출하지 않아도 된다.

lock

lock 클래스는 RAII 원칙이 적용되는 클래스로서 뮤텍스에 락을 정확히 걸거나 해제하는 작업을 쉽게 처리하게 해준다. lock 클래스의 소멸자는 확보했던 뮤텍스를 자동으로 해제시킨다. C++ 표준에서는 std::lock_guard, unique_lock, shared_lock, scoped_lock이라는 네 가지 타입의 락을 제공한다. 그중 scoped_lock은 C++ 17부터 추가 됐다.

lock_guard

std::lock_guard는 다음 두 가지 생성자를 제공한다.

  • explicit lock_guard(mutex_type& m);
    • 뮤텍스에 대한 레퍼런스를 인수로 받는 생성자다. 이 생성자는 전달된 뮤텍스에 락을 걸려 시도하고 완전히 락이 걸릴 때까지 블록된다.
  • lock_guard(mutex_type& m, adopt_lock_t);
    • 뮤텍스에 대한 레퍼런스와 std::adopt_lock_t의 인스턴스를 인수로 받는 생성자다. std::adopt_lock이라는 이름으로 미리 정의된 adopt_lock_t 인스턴스가 제공된다. 이때 호출하는 측의 스레드는 인수로 지정한 뮤텍스에 대한 락을 이미 건 상태에서 추가로 락을 건다. 락이 제거되면 뮤텍스도 자동으로 해제된다.

unique_lock

std::unique_lock은 <mutex> 헤더에 정의된 락으로서 락을 선언하고 한참 뒤 실행될 때 락을 걸도록 지연시키는 고급 기능을 제공한다. 락이 제대로 걸렸는지 확인하려면 owns_lock() 메서드나 unique_lock에서 제공하는 bool 타입 변환 연산자를 사용한다. 이러한 변환 연산자를 사용하는 방법은 ‘시간 제약이 있는 락 사용하기’에서 자세히 소개한다. 

unique_lock은 다음과 같은 버전의 생성자를 제공한다.

  • explicit unique_lock(mytex_type& m);
    • 이 생성자는 뮤텍스에 대한 레퍼런스를 인수로 받아서 그 뮤텍스에 락을 걸려 시도하고 완전히 락이 걸릴 때까지 블록시킨다.
  • unique_lock(mutex_type& m, defer_lock_t) noexcept;
    • 이 생성자는 뮤텍스에 대한 레퍼런스와 std::defer_lock_t의 인스턴스를 인수로 받는다. std::defer_lock 이라는 이름으로 미리 정의된 defer_lock_t 인스턴스도 제공한다. unique_lock은 인수로 전달된 뮤텍스에 대한 레퍼런스를 저장하지만 곧바로 락을 걸지 않고 나중에 다시 걸도록 시도한다.
  • unique_lock(mutex_type& m, try_to_lock_t);
    • 이 생성자는 뮤텍스에 대한 레퍼런스와 std::try_to_lock_t의 인스턴스를 인수로 받는다. std::try_to_lock 이라는 이름으로 미리 정의된 try_to_lock_t 인스턴스도 제공한다. 이 버전의 락은 레퍼런스가 가리키는 뮤텍스에 대해 락을 걸려 시도한다. 실패할 경우 블록하지 않고 나중에 다시 시도한다.
  • unique_lock(mutex_type& m, adopt_lock_t);
    • 이 생성자는 뮤텍스에 대한 레퍼런스와 std::adopt_lock_t의 인스턴스를 인수로 받는다. 이 락은 호출하는 츠그이 스레드가 레퍼런스로 지정된 뮤텍스에 대해 이미 락을 건 상태라고 가정하고, 그 락에 여기서 지정된 뮤텍스를 추가한다. 락이 제거되면 뮤텍스도 자동으로 해제된다.
  • unique_lock(mutex_type& m, const chrono::time_point<Clock, Duration>& abs_time);
    • 이 생성자는 뮤텍스에 대한 레퍼런스와 절대 시간에 대한 값을 인수로 받는다 그래서 지정된 절대 시간 안에 락을 걸려 시도한다.
  • unique_lock(mutex_type& m, const chrono::duration<Rep, Period>^ rel_time);
    • 이 생성자는 뮤텍스에 대한 레퍼런스와 상대 시간을 인수로 받아서 주어진 시간 안에 인수로 지정한 뮤텍스에 락을 걸려 시도한다.

unique_lock 클래스는 lock(), try_lock(), try_lock_for(), try_lock_until(), unlock() 메서드를 제공한다.

shared_lock

shared_lock 클래스는 <shared_mutex> 헤더 파일에 정의돼 있으며, unique_lock과 똑같은 타입의 생성자와 메서드를 제공하고, 내부 공유 뮤텍스에 대해 공유 소유권에 관련된 메서드를 호출한다는 점이 다르다. 따라서 shared_lock 메서드는 lock(), try_lock()을 호출할 때 내부적으로 lock_shared(), try_lock_shared() 등과 같은 공유 뮤텍스에 대한 메서드를 호출한다.

이렇게 하는 이유는 shared_lock과 unique_lock의 인터페이스를 통일시키기 위해서다. 따라서 unique_lock을 사용하던 자리에 그대로 넣을 수 있다. 그러면 독점 락 대신 공유 락을 건다.

한 번에 여러 개의 락을 동시에 걸기

C++은 두 가지 제네릭 락 함수를 제공한다. 이 함수는 데드락이 발생할 걱정 없이 여러 개의 뮤텍스 객체를 한 번에 거는데 사용된다. 두 함수 모두 std 네임스페이스에 정의돼 있으며 22장에서 소개한 가변 인수 템플릿 함수로 정의했다.

첫 번째 함수인 lock()은 인수로 지정된 뮤텍스 객체를 데드락 발생 걱정 없이 한꺼번에 락을 건다. 이때 락을 거는 순서는 알 수 없다. 그중에서 어느 하나의 뮤텍스 락에 대해 익셉션이 발생하면 이미 확보한 락에 대해 unlock()을 호출한다. 이 함수의 프로토타입은 다음과 같다.

template<class L1, class L2, class... L3> void lock(L1&, L2&, L3&...);

try_lock()의 프로토타입도 이와 비슷하지만 주어진 모든 뮤텍스 객체에 대해 락을 걸 때 try_lock()을 순차적으로 호출한다. 모든 뮤텍스에 대해 try_lock()이 성공하면 -1을 리턴하고 어느 하나라도 실패하면 이미 확보된 락에 대해 unlock()을 호출한다. 그러면 뮤텍스 매개변수의 위치를 가리키는 0을 기준으로 매긴 인덱스 값을 리턴한다.

다음 예제는 이러한 제네릭 lock() 함수를 사용하는 방법을 보여준다. process() 함수는 먼저 두 뮤텍스에 대한 락을 하나씩 생성하고, std::defer_lock_t 인스턴스를 unique_lock의 두 번째 인수로 지정해서 그 시간 안에 락을 걸지 않게 한다. 그런 다음 std::lock()을 호출해서 데드락이 발생할 걱정 없이 두 락을 모두 건다.

mutex mut1;
mutex mut2;

void process()
{
unique_lock lock1(mut1, defer_lock); // C++ 17
unique_lock lock2(mut2, defer_lock); // C++ 17
//unique_lock<mutex> lock1(mut1, defer_lock);
//unique_lock<mutex> lock2(mut2, defer_lock);

lock(lock1, lock2);
// 락을 걸었다.
} // 락을 자동으로 해제한다.

scoped_lock

std::scoped_lock은 <mutex> 헤더파일에 정의돼 있으며, lock_guard와 비슷하지만 뮤텍스를 지정하는 인수 개수에 제한이 없다. scoped_lock을 사용하면 여러 락을 한 번에 거는 코드를 훨씬 간결하게 작성할 수 있다. 예컨대 scoped_lock을 사용하면 앞 절에서 본 process() 함수를 다음과 같이 구현할 수 있다.

mutex mut1;
mutex mut2;

void process()
{
scoped_lock locks(mut1, mut2);
// 락을 걸었다.
} // 락을 자동으로 해제한다.

여기서는 C++ 17에 추가된 생성자에 대한 템플릿 인수 추론 기능을 적용했는데, 현재 사용하는 컴파일러가 이 기능을 지원하지 않는다면 다음과 같이 작성해야 한다.

scoped_lock<mutex, mutex> locks(mut1, mut2);

std::call_once

std::call_once()와 std::one_flag를 함꼐 사용하면 같은 once_flag에 대해 여러 스레드가 call_once()를 호출하더라도 call_once의 인수로 지정한 함수나 메서드가 단 한 번만 호출되게 할 수 있다. 인수로 지정한 함수나 메서드에 대해 call_once가 단 한 번만 호출된다. 지정한 함수가 익셉션을 던지지 않을 때 이렇게 호출하는 것을 이펙티브(effective) call_once() 호출이라 부른다.

지정한 함수가 익셉션을 던지면 그 익셉션은 호출한 측으로 전달되며, 다른 호출자를 골라서 함수를 실행시킨다. 특정한 once_flag 인스턴스에 대한 이펙티브 호출은 동일한 once_flag에 대한 다른 call_once() 호출보다 먼저 끝난다.

아래 그림은 스레드 세 개로 이를 실행한 예를 보여준다. 스레드 1은 이펙티브 call_once() 호출을 수행하고, 스레드 2는 이러한 이펙티브 호출이 끝날 때까지 블록되고, 스레드 3은 스레드 1의 이펙티브 호출이 이미 끝났기 때문에 블록되지 않는다.

다음 예제는 call_once()를 사용하는 방법을 보여준다. 이 예제에서는 공유 자원을 사용하는 processFunction()을 실행하는 스레드 세 개를 구동한다. 여기서 공유하는 자원은 반드시 initializeSharedResources()로 단 한 번만 호출해서 초기화해야 한다. 이렇게 하려면 각 스레드마다 once_flag라는 전역 플래그에 대해 call_once()를 호출한다. 코드를 실행하면 단 한 스레드만 initializeSharedResources()를 정확히 한 번 실행한다. call_once() 호출이 진행되는 동안 다른 스레드는 initializeSharedResources()가 리턴할 때까지 블록된다.

once_flag gOnceFlag;

void initializeSharedResources()
{
// 여러 스레드가 사용할 공유 리소스를 초기화한다.
cout << "Shared resources initailized" << endl;
}

void processingFunction()
{
// 공유 리소스를 반드시 초기화한다.
call_once(gOnceFlag, initializeSharedResources);

// 원하는 작업을 수행한다. 이때 공유 리소스를 사용한다.
cout << "Processing" << endl;
}

int main()
{
// 스레드 세 개를 구동시킨다.
vector<thread> threads(3);

for (auto& t : threads)
{
t = thread { processingFunction };
}

// 각 스레드에 대해 join()을 호출한다.
for (auto& t : threads)
{
t.join();
}
}

이 코드를 실행한 결과는 다음과 같다.

Shared resources initialized
Processing
Processing
Processing

뮤텍스 객체 사용 방법

스레드에 안전한 스트림 출력 기능 구현하기

23.2절 ‘스레드’에서 Counter 클래스 예제를 살펴보면서 C++ 스트림에 대해서는 기본적으로 데이터 경쟁이 발생하지 않지만 여러 스레드로 출력한 결과가 뒤섞일 수 있다고 설명했다. 이렇게 결과가 뒤섞이지 않게 하려면 뮤텍스 객체를 이용하여 스트림 객체에 읽거나 쓰는 작업을 한 번에 한 스레드만 수행하도록 만들면 된다.

다음 코드는 모든 스레드가 Counter 클래스의 cout에 대한 접근을 동기화시키는 예를 보여주고 있다. 이를 위해 static mutex 객체를 추가했다. 여기서 반드시 static을 지정해야 이 클래스의 모든 인스턴스가 동일한 mutex 인스턴스를 사용할 수 있다. 그러고 나서 cout에 쓰기 전에 lock_guard로 이 mutex 객체에 락을 건다.

class Counter
{
public:
Counter(int id, int numIterations) : mId(id), mNumIterations(numIterations)
{
}

void operator()() const
{
for (int i = 0; i < mNumIterations; ++i)
{
lock_guard lock(sMutex);
cout << "Counter " << mId << " has value " << i << endl;
}
}

private:
int mId;
int mNumIterations;
static mutex sMutex;
};

mutex Counter::sMutex;

여기서는 for 문을 한 번 돌 때마다 lock_guard 인스턴스를 생성한다. 이때 락이 걸린 시간을 최소화하도록 지정해야 한다. 그렇지 않으면 다른 스레드를 너무 오래 블록시키게 된다. 예컨대 lock_guard 인스턴스를 for 문 앞에서 한 번만 생성하면 여기서 구현한 멀티스레드 효과가 사라지게 된다. for 문 전체에 대해 하나의 스레드가 락을 걸기 때문에 다른 스레드는 이 락이 해제되기 전까지 기다려야 하기 때문이다.

시간 제약이 있는 락 사용하기

다음 예제는 시간 제약이 있는 뮤텍스를 사용하는 방법을 보여준다. 앞서 본 Counter 클래스와 대체로 비슷하지만 이번에는 unique_lock과 timed_mutex를 조합해서 사용한다.

unique_lock 생성자에 200ms란 상대 시간을 인수로 지정해서 그 시간 동안 락 걸기를 시도한다. 이 시간 안에 락을 걸지 못해도 unique_lock 생성자는 그냥 리턴한다. 실제로 락이 걸렸는지는 나중에 lock 변수에 대한 if 문으로 확인한다. unique_lock은 bool 타입 변환 연산자를 제공하기 때문에 이렇게 할 수 있다.

class Counter
{
public:
Counter(int id, int numIterations) : mId(id), mNumIterations(numIterations)
{
}

void operator()() const
{
for (int i = 0; i < mNumIterations; ++i)
{
unique_lock lock(sTimedMutex, 200ms);

if (lock)
{
cout << "Counter " << mId << " has value " << i << endl;
}
else
{
// 200 ms 안에 락을 걸지 못하면 아무것도 출력하지 않는다.
}
}
}

private:
int mId;
int mNumIterations;
static timed_mutex sTimedMutex;
};

timed_mutex Counter::sTimedMutex;

이중 검사 락

이중 검사 락 패턴(double-checked lock pattern)은 사실 안티패턴(anti-pattern)이라서 사용하지 않는 것이 좋다. 하지만 이렇게 작성된 코드가 많기 때문에 소개한다.

이중 검사 락 패턴은 기본적으로 상호 배제 객체를 최대한 사용하지 않는다. 이는 상호 배제 객체를 사용할 때보다 코드를 효율적으로 작성하려는 어설픈 시도로 나온 것이다. 예컨대 뒤에서 소개할 예제보다 더 빠르게 실행되도록 atomic<bool> 대신 부울 타입을 곧바로 쓰는 것처럼, 아토믹 연산의 제약을 줄일 때 문제가 발생하기 쉽다. 이 패턴에 따라 구현하면 데이터 경쟁이 발생하기 쉽고 이를 해결하기 어렵다. 아리어니하게도 call_once()를 사용하면 실제로 실행 속도가 빨라진다. 게다가 매직 스태틱(magic static)을 사용하면 더 빠르게 만들 수 있다.

Caution) 이중 검사 락 패턴은 사용하지 마라! 대신 기본 락, 아토믹 변수, call_once(), 매직 스태틱 등을 사용하라.

이중 검사 락 패턴을 사용하면 리소스가 단 한 번만 초기화되도록 보장할 수 있다. 다음 예제는 이렇게 구현하는 방법을 보여준다. 이 코드에서 보는 것처럼 gInitialized 변수를 락을 걸기 전과 뒤에 두 번 검사하기 때문에 이중 검사 락 패턴이라 부른다. 첫 번째 검사는 필요 없을 때 락을 걸지 않도록 막아주고, 두 번째 검사는 이 변수를 한 번 검사한 뒤 락을 걸기 전까지 다른 스레드가 초기화를 수행하지 않도록 막아준다.

void initializeSharedResources()
{
// 여러 스레드가 사용할 공유 리소스를 초기화한다.
cout << "Shared resources initialized" << endl;
}

atomic<bool> gInitialized(false);
mutex gMutex;

void processingFunction()
{
if (!gInitialized)
{
unique_lock(gMutex);

if (!gInitialized)
{
initializedSharedResources();
gInitialized = true;
}
}
cout << "OK" << endl;
}

int main()
{
vector<thread> threads;

for (int i = 0; i < 5; ++i)
{
threads.push_back(thread{processingFunction});
}

for (autu& t : threads)
{
t.join();
}
}

이 코드를 실행해 보면 다음과 같이 한 스레드만 공유 리소스를 초기화한다는 것을 알 수 있다.

Shared resources initialized
OK
OK
OK
OK
OK

Note) 이 예제에서는 이중 검사 락 대신 앞서 설명한 call_once()로 구현하는 것이 바람직 하다.

조건 변수

조건 변수(condition variable)를 이용하면 다른 스레드가 조건을 설정하기 전이나 따로 지정한 시간이 경과하기 전까지 스레드의 실행을 멈추고 기다리게 할 수 있다. 그래서 스레드 통신을 구현할 수 있다. Win32 API로 멀티스레드 프로그래밍을 해본 경험이 있다면 윈도우의 이벤트 객체(event object)와 비슷하다고 보면 된다.

C++는 두 가지 조건 변수를 제공한다. 둘 다 <condition_variable> 헤더 파일에 정의돼 있다.

  • std::condition_variable: unique_lock<mutex>만 기다리는 조건 변수로서 C++ 표준에 따르면 특정한 플랫폼에서 효율을 최대로 이끌어낼 수 있다.
  • std::condition_variable_any: 커스텀 락 타입을 비롯한 모든 종류의 객체를 기다릴 수 있는 조건 변수다.

condition_variable은 다음과 같은 메서드를 제공한다.

  • notify_one()
    • 조건 변수를 기다리는 스레드 중 하나를 깨운다. 윈도우의 자동 리셋 이벤트(auto_reset event)와 비슷하다.
  • notify_all()
    • 조건 변수를 기다리는 스레드를 모두 깨운다.
  • wait(unique_lock<mutex>& lk)
    • wait()를 호출하는 스레드는 반드시 lk에 대한 락을 걸고 있어야 한다. wait()를 호출하면 lk.unlock()을 아토믹하게 호출해서 그 스레드를 블록시키고, 알림(notification)이 오길 기다린다. 다른 스레드에서 호출한 nofity_one()이나 notify_all()로 인해 블록된 스레드가 해제되면 lk.lock()을 다시 호출해서 완전히 걸 때까지 블록시킨 뒤 리턴한다.
  • wait_for(unique_lock<mutex>& lk, const chrono::duration<Rep, Period>& rel_time)
    • wait()와 비슷하지만 notify_one()이나 notify_all()이 호출되거나 지정된 시간이 만료하면 현재 스레드의 블록 상태를 해제한다.
  • wait_until(unique_lock<mutex>& lk, const chrono::time_point<Clock, Duration>& abs_time)
    • wait()와 비슷하지만 notify_one()이나 notify_all()이 호출되거나 시스템 시간이 절대 시간으로 지정한 시간을 경과하면 블록된 스레드가 해제된다.

프레디케이트 매개변수를 추가로 받는 버전의 wait(), wait_for(), wait_until()도 있다. 에컨대 wait() 버전 중에는 다음과 같이 프레디케이트를 추가로 받는 것도 있다.

while (!predicate())
{
wait(lk);
}

condition_variable_any 클래스에서 제공하는 메서드는 condition_variable과 비슷하지만 unique_lock<mutex> 뿐만 아니라 다른 모든 종류의 락 클래스도 인수로 받는다는 점이 다르다. 이렇게 받은 락 클래스는 반드시 lock()과 unlock() 메서드를 제공해야 한다.

비정상적으로 깨어나기

조건 변수를 기다리는 스레드는 다른 스레드가 notify_one()이나 notify_all()을 호출할 때까지 기다린다. 기다리는 시간은 상대 시간이나 절대 시간(특정한 시스템 시각)으로 지정한다.

그런데 이렇게 미리 지정된 시점에 다다르지 않았는데 비정상적으로 깨언라 수도 있다. 다시 말해 notify_one()이나 notify_all()을 호출한 스레드도 없고, 타임아웃도 발생하지 않았는데 스레드가 깨어나는 것이다. 그러므로 조건 변수를 기다리도록 설정했던 스레드가 그 이전에 깨어나면 그 이유를 검사해야 한다. 한 가지 방법은 프레디케이트를 인수로 받는 wait()를 사용하는 것이다. 다음 절에서 구체적인 예를 소개한다.

조건 변수 사용하기

예컨대 큐ㅔ에 담긴 원소를 백그라운드로 처리할 때 조건 변수를 활용한다고 하자. 먼저 처리할 원소를 추가할 큐를 정의한다. 백그라운드 스레드는 큐에 원소가 들어올 때까지 기다렸다가 원소가 추가되면 스레드를 깨워서 그 원소를 처리하고 다음 원소가 들어올 때까지 다시 잠든 상태로 기다린다. 큐는 다음과 같이 선언한다.

queue<string> mQueue;

주어진 시점에 한 스레드만 이 큐를 수정해야 한다. 이는 뮤텍스로 구현할 수 있다.

mutex mMutex;

원소가 추가된 사실을 백그라운드 스레드에 알려주어 다음과 같이 조건 변수를 선언한다.

condition_variable mCondVar;

큐에 원소를 추가하는 스레드는 먼저 앞에서 선언한 뮤텍스에 락부터 걸어야 한다. 그러고 나서 큐에 원소를 추가하고 백그라운드 스레드에 알려준다. 이때 실제로 락을 걸었는지에 관계 없이 notify_one()이나 notify_all()을 호출한다. 둘 다 정상적으로 처리된다.

// 뮤텍스에 락을 걸고 큐에 원소를 추가한다.
unique_lock lock(mMutex);
mQueue.push(entry);

// 스레드를 깨우도록 알림을 보낸다.
mCondVar.notify_all();

여기서 백그라운드 스레드는 무한 루프를 돌면서 알림이 오기를 기다린다. 구현 코드는 다음과 같다. 이때 프레디케이트를 인수로 받는 wait()를 이용하여 비정상적으로 깨어나지 않게 만든다. 이 프레디케이트로 큐에 실제로 원소가 추가됐는지 확인한다. wait()를 호출한 결과가 리턴되면 실제로 큐에 뭔가 추가됐다고 보장할 수 있다.

unique_lock lock(mMutex);

while (true)
{
// 알림을 기다린다.
mCondVar.wait(lock, [this]{ return !mQueue.empty(); });

// 조건 변수를 통한 알림이 도착했다. 따라서 큐에 뭔가 추가됐다는 것을 알 수 있다.
// 추가된 항목을 처리한다.
}

23.7절 ‘멀티스레드 Logger 클래스 예제’에서는 조건 변수로 다른 스레드에 알림을 보내는 구체적인 구현 방법을 소개한다.

C++ 표준은 std::notify_all_at_thread_exit(cond, lk)라는 헬퍼 함수도 제공한다. 여기서 cond는 조건 변수고, lk는 unique_lock<mutex> 인스턴스다. 이 함수를 호출하는 스레드는 lk라는 락을 이미 확보한 상태여야 한다. 이 스레드가 종료하면 다음 코드가 자동으로 실행된다.

lk.unlock();
cond.notify_all();

Note) lk 락은 스레드가 종료될 때까지 잠긴 상태를 유지한다. 따라서 데드락이 발생하지 않도록 각별히 주의한다. 예컨대 락 걸기 순서가 잘못되면 데르락이 발생할 수 있다.

promise와 future

앞서 설명했듯이 어떤 값을 계산하는 스레드를 std::thread로 만들어서 실행하면 그 스레드가 종료된 후에는 최종 결과를 받기 힘들다. 예외나 여러 가지 에러를 처리하는데도 문제가 발생한다. 스레드가 던진 익셉션을 그 스레드가 받지 않으면 C++ 런타임은 std::terminate()를 호출해서 애플리케이션 전체를 종료시킨다.

이때 future를 사용하면 스레드의 실행 결과를 쉽게 받아올 수 있을 뿐만 아니라 익셉션을 다른 스레드로 전달해서 원하는 방식으로 처리할 수 있다. 물론 익셉션이 발생한 스레드에서 벗어나지 않도록 항상 같은 스레드 안에서 익셉션을 처리하는 것이 바람직하다.

스레드의 실행 결과를 promise에 담으면 future로 그 값을 가져올 수 있다. 채널에 비유하면 promise는 입력 포트고 future는 출력 포트인 셈이다. 같은 스레드나 다른 스레드에서 실행하는 함수가 계산해서 리턴하는 값을 promise에 담으면 나중에 그 값을 future에서 가져갈 수 있다. 이 메커니즘을 결과에 대한 스레드 통신 채널로 볼 수 있다.

C++ std::future라는 표준 future를 제공한다. std::future에 있는 결과를 가져오는 방법은 다음과 같다. 여기서 T는 계산된 결과에 대한 타입이다.

future<T> myFuture  = ...; // 이에 대한 설명은 뒤에 한다.
T result = myFuture.get();

여기서 get(0을 호출해서 가져온 결과를 result 변수에 저장한다. 이때 get(0을 호출한 부분은 계산이 끝날 때까지 멈추고 기다린다(블록된다). future 하나에 대해 get()을 한 번만 호출할 수 있다. 두 번 호출하는 경우는 표준에 따로 정해져 있지 않다.

코드가 블록되지 않게 하려면 다음과 같이 future를 검사해서 결과가 준비됐는지 확인부터 한다.

// 계산이 끝난 경우
if (myFuture.wait_for(0))
{

T result = myFuture.get();
}
// 계산이 안 끝난 경우
else
{
...
}

std::promise와 std::future

C++은 promise를 구현하는 std::promise 클래스를 제공한다. promise에 대해 set_value()를 호출해서 결과를 저장하거나 set_exception()을 호출해서 익셉션을 promise에 저장할 수 있다. 참고로 특정 promise에 대해 set_value()나 set_exception()을 단 한 번만 호출할 수 있다. 여러 번 호출하면 std::future_error 익셉션이 발생한다.

A 스레드와 B 스레드가 있을 때 A 스레드가 어떤 계산을 B 스레드로 처리하기 위해 std::promise를 생성해서 B 스레드를 구동할 때 이 promise를 인수로 전달한다. 이때 promise는 복제될 수 없고 이동만 가능하다. B 스레드는 이 promise에 값을 저장한다.

A 스레드는 promise를 B 스레드로 이동시키기 전에 생성된 promise에 get_future()를 호출한다. 그러면 B가 실행을 마친 후 나온 결과에 접근할 수 있다. 이를 코드로 구현하면 다음과 같다.

void DoWork(promise<int> thePromise)
{
// 원하는 작업을 수행한다.
// 최종 결과를 promise에 저장한다.
thePromise.set_value(42);
}

int main()
{
// 스레드에 전달할 promise를 생성한다.
promise<int> myPromise;

// 이 promise에 대한 future를 가져온다.
auto theFuture = myPromise.get_future();

// 스레드를 생성하고 앞서 만든 promise를 인수로 전달한다.
thread theThread{ DoWork, std::move(myPromise) };

// 원하는 작업을 수행한다.

// 최종 결과를 가져온다.
int result = theFuture.get();
cout << "Result: " << result << endl;

// 스레드를 join한다.
theThread.join();
}

Note) 이 코드는 단지 promise와 future의 사용법을 보여주기 위한 것이다. 먼저 스레드를 생성해서 계산을 수행한 뒤 future에 대해 get()을 호출한다. 그러면 최종 결과가 나올 때까지 블록된다. 하지만 이렇게 작성하면 성능이 크게 떨어진다. 실전에서는 future에 최종 결과가 나왔는지 주기적으로 검사하도록 구현하거나(앞서 소개한 wait_for()로) 조건 변수와 같은 동기화 기법을 사용하도록 구현한다. 그러면 결과가 나오기 전에 무조건 멈춘 뒤 기다리지 않고 다른 작업을 수행할 수 있다.

std::packaged_task

std::packaged_task를 이용하면 앞서 소개한 std::promise를 명싲거으로 사용하지 않고도 promise를 구현할 수 있다. 다음 코드는 이를 위한 구체적인 방법을 보여준다.

여기서는 먼저 packaged_task를 생성해서 CalculateSum()을 실행한다. 이 packaged_task에 대해 get_future()를 호출해서 future를 가져온다. 스레드를 구동해서 이 packaged_task를 그곳으로 이동 시킨다. 이때 packaged_task는 복제되지 않는다는 점에 주의한다. 스레드가 구동되고 나면 받아온 future에 대해 get()을 호출해서 결과를 가져온다. 이때 결과가 나오기 전까지 블록된다.

여기서 CalculateSum()은 promise에 저장하는 작업을 하지 않아도 된다. packaged_task가 promise를 자동으로 생성하고, 호출한 함수(여기서는 CalculateSum())의 결과를 promise에 알아서 저장해준다. 이때 발생한 익셉션도 promise에 함께 저장된다.

int CalculateSum(int a, int b) { return a + b; }

int main()
{
// packaged_task를 생성해서 CalculateSum을 실행한다.
packaged_task<int(int, int)> task(CalculateSum);

// 새로 생성한 packaged_task로부터 CalculateSum의 결과를 담을 future를 받는다.
auto theFuture = task.get_future();

// 스레드를 생성한 뒤 앞에서 만든 packaged_task를 이동시키고 인수를 적절히 전달해서 작업을 수행한다.
thread theThread{ std::move(task), 39, 3 };

// 다른 작업을 수행한다.

// 결과를 가져온다.
int result = theFuture.get();
cout << result << endl;

// 스레드를 조인한다.
theThread.join();
}

std::async

스레드로 계산하는 작업을 C++ 런타임으로 좀 더 제어하고 싶다면 std::async()를 사용한다. std::async()는 실행할 함수를 인수로 받아서 그 결과를 담은 future를 리턴한다. 지정한 함수를 async()로 구동하는 방법은 두 가지다.

  • 함수를 스레드로 만들어 비동기식으로 구동한다.
  • 스레드를 따로 만들지 않고, 리턴된 future에 대해 get()을 호출할 때 동기식으로 함수를 실행한다.

async()에 인수를 주지 않고 호출하면 런타임이 앞에 나온 두 가지 방법 중 하나를 적절히 고른다. 이때 시스템에 장착된 CPU의 코어 수나 동시에 수행되는 작업의 양에 따라 방법이 결정된다. 다음과 같이 정책을 나타내는 인수를 지정하면 이러한 선택 과정에 가이드라인을 제시할 수 있다.

  • launch::async: 주어진 함수를 다른 스레드에서 실행시킨다.
  • launch::deferred: get()을 호출할 때 주어진 함수를 현재 스레드와 동기식으로 실행시킨다.
  • launch::async | launch::deferred: C++ 런타임이 결정한다. (디폴트 동작)

async()를 사용하는 예는 다음과 같다.

int calculate()
{
return 123;
}

int main()
{
auto myFuture = async(calculate);
// auto myFuture = async(launch::async, calculate);
// auto myFuture = async(launch::deferred, calculate);

// 다른 작업을 수행한다.

// 결과를 가져온다.
int result = myFuture.get();
cout << result << endl;
}

이 예제에서 볼 수 있듯이 std::async()는 원하는 계산을 비동기식으로 처리하거나(다른 스레드에서) 동기식으로 처리해서(현재 스레드에서) 나중에 결과를 가져오도록 구현하는 가장 쉬운 방법이다.

Caution) async()를 호출해서 리턴된 future는 실제 결과가 담길 때까지 소멸자에서 블록된다. 다시 말해 async()를 호출한 뒤 리턴된 future를 가져가지(캡쳐하지) 않으면 async()가 블록되는 효과가 발생한다. 예컨대 다음 코드는 calculate()를 동기식으로 호출한다. async(calculate);  이 문장에서 async()는 future를 생성해서 리턴한다. 이렇게 리턴된 future를 캡쳐하지 않으면 임시 future 객체가 생성된다. 그래서 이 문장이 끝나기 전에 소멸자가 호출되면서 결과가 나올 때까지 블록된다.

익셉션 처리

future의 가장 큰 장점은 스레드끼리 익셉션을 주고 받는데 활용할 수 있다는 것이다. future에 대해 get()을 호출해서 계산된 결과를 리턴하거나, 이 future에 연결된 promise에 저장된 익셉션을 다시 던질 수 있다. packaged_task나 async()를 사용하면 구동된 함수에서 던진 익셉션이 자동으로 promise에 저장된다. 이때 promise를 std::promise로 구현하면 set_exception()을 호출해서 거기에 익셉션을 저장한다. async()를 사용하는 예는 다음과 같다.

int calculate()
{
throw runtime_error("Exception thrown from calculate()");
}

int main()
{
// 강제로 비동기식으로 실행하도록 launch::async 정책을 지정한다.
auto myFuture = async(launch::async, calculate);

// 다른 작업을 실행한다.

// 결과를 가져온다.
try
{
int result = myFuture.get();
cout << result << endl;
}
catch (const exception& ex)
{
cout << "Caught exception: " << ex.what() << endl;
}
}

std::shared_future

std::future<T>의 인수 T는 이동 생성할 수 있어야 한다. future<T>에 대해 get()을 호출하면 future로부터 결과가 이동돼 리턴된다. 그러므로 future<T>에 대해 get()을 한 번만 호출 할 수 있다.

get()을 여러 스레드에 대해 여러 번 호출하고 싶다면 std::shared_future<T>를 사용한다. 이때 T는 복제 생성할 수 있어야 한다. shared_future는 std::future::share()로 생성허가나 shared_future 생성자에 future를 전달하는 방식으로 생성한다. 이때 future는 복제될 수 없다. 따라서 shared_future 생성자에 이동시켜야 한다.

shared_future는 여러 스레드를 동시에 꺠울 때 사용한다. 예컨대 다음 코드는 람다 표현식 두 개를 서로 다른 스레드에서 비동기식으로 실행한다. 각 람다 표현식은 가장 먼저 promise에 값을 설정해서 스레드가 구동됐다는 사실을 알리는 일부터 한다. 그런 다음 signalFuture에 대해 get()을 호출해서 블록시켰다가 future를 통해 매개변수가 설정되면 각 스레드를 실행한다. 각 람다 표현식은 promise를 레퍼런스로 캡쳐한다. signalFuture는 값으로 캡쳐한다. 따라서 두 표현식 모두 signalFuture의 복제본을 갖고 있다. 메인 스레드는 async()를 이용해 두 람다 표현식을 서로 다른 스레드에서 비동기식으로 실행시킨다. 그러고 나서 두 스레드가 구동될 때까지 기다리다가 두 스레드 모두 깨우도록 signalPromise에 매개변수를 지정한다.

promise<void> thread1Started, thread2Started;

promise<int> signalPromise;
auto signalFuture = signalPromise.get_future().share();
// shared_future<int> signalFuture(signalPromise.get_future());

auto function1 = [&thread1Started, signalFuture] {
thread1Started.set_value();

// 매개변수가 설정될 때까지 기다린다.
int parameter = signalFuture.get();

// ...
};

auto function2 = [&thread2Started, signalFuture] {
thread2Started.set_value();

// 매개변수가 설정될 때까지 기다린다.
int parameter = signalFuture.get();

// ...
};

// 두 람다 표현식을 비동기식으로 구동한다
// async()에서 리턴한 future를 까먹지 말고 캡쳐한다.
auto result1 = async(launch::async, function1);
auto result2 = async(launch::async, function2);

// 두 스레드 모두 구동될 때까지 기다린다.
thread1Stated.get_future().wait();
thread2Stated.get_future().wait();

// 이제 두 스레드 모두 매개변수가 설정되기를 기다린다.
// 두 스레드를 깨우는 매개변수를 설정한다.
signalPromise.set_value(42);

멀티스레드 Logger 클래스 예제

(생략)

스레드 풀

프로그램을 구동할 때부터 종료할 때까지 스레드를 필요할 때마다 생성했다 삭제하는 식으로 구현하지 않고 필요한 수만큼 스레드 풀(thread pool)을 구성해도 된다. 주로 스레드에서 특정한 종류의 이벤트를 처리할 때 이 기법을 적용한다.

일반적으로 프로세스 코어 수만큼 스레드를 생성하는 것이 적절하다. 스레드 수가 코어 수보다 많으면 다른 스레드가 실행되는 동안 기다려야 하는 스레드가 생겨서 오버헤드가 증가할 수 있다. 이상적인 스레드 수는 코어 수와 일치하는 경우지만, 어디까지나 I/O 연산처럼 중간에 블록되지 않고 계산 작업만 수행하는 경우에만 적용되는 기준이다. 스레드가 블록되면 코어 수보다 많은 스레드를 수용하게 된다. 최적의 스레드 수는 작업의 성격에 따라 다르며, 처리량을 정확히 측정해봐야 알 수 있다.

처리할 작업이 서로 다를 수도 있기 때문에 스레드 풀에서 가져온 스레드가 입력값으로 수행할 작업을 표현하는 함수 객체나 람다 표현식을 입력받게 만드는 경우가 흔하다.

스레드 풀에서 가져온 스레드는 이미 생성된 상태기 때문에 입력된 내용을 바탕으로 스레드를 새로 만들어서 구동할 때보다 OS 입장에서 훨씬 효율적으로 스케쥴링할 수 있다. 게다가 스레드 풀을 사용하면 생성될 스레드 수를 플랫폼 상황에 따라 한 개부터 수천 개까지 유연하게 관리할 수 있다.

스레드 풀을 구현하는 라이브러리가 다양하게 나와 있다. 예컨대 인텔의 스레드 빌딩 블록(Threading Building Block, TBB)과 마이크로소프트의 패러렐 패턴즈 라이브러리(Parallel Patterns Library, PPL)가 있다. 스레드 풀을 직접 구현하지 말고 이런 라이브러리를 활용하는 것이 바람직하다. 스레드 풀을 직접 구현하고 싶다면 객체 풀(object pool)과 비슷한 방식으로 만들면 된다.

바람직한 스레드 디자인과 구현을 위한 가이드라인

  • 표준 라이브러리에서 제공하는 병렬 알고리즘을 활용한다.
    • 표준 라이브러리는 방대한 종류의 라이브러리를 제공한다. C++ 17부터는 그중 60개 이상이 병렬 실행을 지원한다. 멀티스레드 코드를 직접 구현하기보다는 가능하면 표준 라이브러리의 병렬 알고리즘을 사용하는 것이 좋다. 
  • 애플리케이션을 종료하기 전에 반드시 조인해야 할 thread 객체가 하나도 남지 않게 한다.
    • 모든 thread 객체에 대해 join()이나 detach()를 호출했는지 확인한다. 조인할 예정인 thread 소멸자는 std::terminate()를 호출하게 된다. 그러면 모든 스레드와 애플리케이션이 갑자기 종료된다.
  • 동기화 메커니즘이 없는 동기화 방식이 최고다.
    • 멀티스레드 프로그래밍을 할 때 공유 데이터를 다루는 스레드가 그 데이터를 읽기만 하고 쓰지 않게 또는 다른 스레드가 읽지 않은 부분만 쓰도록 구성하면 코드를 훨씬 쉽게 구현할 수 있다. 그러면 동기화 메커니즘을 따로 구현할 필요 없으며, 데이터 경쟁이나 데드락도 발생하지 않는다.
  • 가능하면 싱글 스레드 소유권 패턴을 적용한다.
    • 다시 말해 데이터 블록을 한 번에 한 스레드만 소유하게 만든다. 데이터를 소유한다(데이터의 소유권을 갖는다)는 말은 다른 스레드가 그 데이터를 읽거나 쓸 수 없다는 뜻이다. 스레드가 데이터에 대한 작업을 마치면 그 데이터에 대한 소유권을 다른 스레드로 넘길 수 있다. 그러면 그 스레드만 데이터 소유권을 갖게 돼 동기화 메커니즘이 필요 없다.
  • 아토믹 타입과 아토믹 연산을 최대한 많이 사용한다.
    • 아토믹 타입과 아토믹 연산을 사용하면 데이터 경쟁과 데드락이 발생하지 않게 만들기 쉽다. 동기화 작업을 알아서 처리해주기 때문이다. 아토믹 타입과 연산으 ㄹ제공하지 않는 환경에서 데이터를 공유해야 한다면 상호 배제와 같은 동기화 메커니즘을 반드시 제공해서 적절히 동기화시켜야 한다.
  • 변경될 수 있는 공유 데이터는 락으로 보호한다.
    • 변경될 수 있는 공유 데이터를 여러 스레드가 동시에 쓸 수 있는데 아토믹 타입과 아토믹 연산을 사용할 수 없다면 반드시 락 메커니즘을 이용하여 여러 스레드의 읽기 및 쓰기 연산을 동기화 시켜야 한다.
  • 락을 거는 기간은 짧을수록 좋다.
    • 공유 데이터를 락으로 보호할 때는 최대한 빨리 해제한다. 한 스레드가 락을 걸고 있으면 그 락을 기다리는 다른 스레드가 블록돼 전체 성능이 떨어질 수 있다.
  • 여러 개의 락을 걸 때는 직접 구현하지 말고 std::lock()이나 std::try_lock()을 사용한다.
    • 여러 스레드가 락을 여러 개 걸어야 한다면 반드시 모든 스레드를 똑같은 순서로 걸어야 한다. 그렇지 않으면 데드락이 발생할 수 있다. 이렇게 여러 개의 락을 걸 때는 제네릭 함수인 std::lock()이나 std::try_lock()을 사용한다.
  • RAII 락 객체를 사용한다.
    • 락이 제때 자동으로 해제되도록 lock_guard, unique_lock, shared_lock, scoped_lock과 같은 RAII 클래스를 사용한다.
  • 멀티스레드를 지원하는 프로파일러를 활용한다.
    • 그러면 멀티스레드로 구현한 애플리케이션에서 발생하는 성능 저하 지점뿐만 아니라 현재 생성된 스레드가 시스템의 처리량을 최대로 활용하고 있는지 쉽게 알아낼 수 있다. 멀티스레드를 지원하는 프로파일러의 예로 마이크로소프트웨어 비주얼 스튜디오에서 제공하는 프로파일러가 있다.
  • 멀티스레드를 지원하는 디버거를 활용한다.
    • 대부분의 디버거는 멀티스레드 애플리케이션을 디버깅하는데 필요한 최소 기능을 제공한다. 적어도 애플리케이션에서 현재 구동하고 있는 스레드 목록을 조회하거나 그 중 원하는 스레드의 콜 스택을 조회하는 기능은 갖추고 있어야 한다. 그러면 각각의 스레드가 현재 실행되는 현황을 정확히 볼 수 있기 때문에 데드락 검사와 같은 작업을 수행할 수 있다.
  • 멀티스레드를 지원하는 디버거를 활용한다.
    • 대부분의 디버거는 멀티스레드 애플리케이션으 디버깅하는데 필요한 최소 기능을 제공한다. 적어도 애플리케이션에서 현재 구동하고 있는 스레드 목록을 조회하거나 그중 원하는 스레드 콜 스택을 조회하는 기능은 갖추고 있어야 한다. 그러면 각각의 스레드가 현재 실행되는 현황을 정확히 볼 수 있기 때문에 데드락 검사와 같은 작업을 수행할 수 있다.
  • 스레드가 많을 때는 필요 때마다 생성했다가 삭제하지 말고 스레드 풀을 이용한다.
    • 동적으로 생성했다 삭제하는 스레드 수가 많을수록 성능 저하 폭이 크다. 이럴 때는 스레드 풀을 이용해서 기존에 생성된 스레드를 최대한 재사용하는 것이 좋다.
  • 하이레벨 멀티스레딩 라이브러리를 사용한다.
    • 현재 시점에서 C++ 표준은 멀티스레드 코드를 작성하는데 아주 기본적인 기능만 제공한다. 이런 기능을 제대로 활용하기란 쉽지 않다. 따라서 스레드 관련 기능을 직접 구현하지 말고, 인텔의 스레딩 빌딩 블록(TBB)나 마이크로소프트의 패러렐 패턴즈 라이브러리(PPL)과 같은 하이레벨 관점으로 멀티스레딩을 지원하는 라이브러리를 활용하는 것이 좋다. 멀티 스레드 프로그램을 에러 없이 정확히 동작하게 만들기란 쉽지 않다. 또한 직접 구현한 것이 생각보다 유용하지 않을 수도 있다.

전문가를 위한 C++/ 고급 템플릿

(전체가 아니라 C#과 차이가 있는 부분을 중심으로 요약 정리)

템플릿 매개변수에 대한 심화 학습

(생략)

클래스 템플릿 부분 특수화

(생략)

오버로딩으로 함수 템플릿 부분 특수화 흉내내기

(생략)

템플릿 재귀

C++의 템플릿은 단순히 클래스나 함수를 정의하는 것보다 많은 일을 할 수 있는데, 그중 하나가 템플릿 재귀(template recursion)이다. 구체적인 구현 방법을 살펴보기 전에 먼저 템플릿 재귀가 필요한 이유를 알아보자.

N차원 Grid: 첫 번째 시도

지금까지 본 Grid 템플릿은 2차원까지만 지원해서 활용 범위가 제한됐다. 예컨대 3D 틱택토나 4차원 행렬을 계산하는 수학 프로그램을 구현할 수 없다. 물론 원하는 차원마다 템플릿이나 클래스를 새로 만들어도 되긴 하지만 이러면 코드가 중복된다.

또 다른 방법은 일차원 Grid만 만들어두고, 이 Grid를 원소의 타입으로 갖는 Grid를 인스턴수화 하는 방식으로 원하는 차원에 대한 Grid를 만들 수 있다. 이떄 상위 Grid의 원소로 사용하는 일차원 Grid는 실제 원소의 타입으로 인스턴스화 한다. 다음 코드는 OneDGrid 클래스 템플릿의 구현코드를 보여준다. 

template<typename T>
class OneDGrid
{
public:
explicit OneDGrid(size_t size = kDefaultSize);
virtual ~OneDGrid() = default;

T& operator[](size_t x);
const T& operator[](size_t x) const;

void resize(size_t newSize);
size_t getSize() const { return mElements.size(); }

static const size_t kDefaultSize = 10;

private:
std::vector<T> mElements;
};

template<typename T>
OneDGrid<T>::OneDGrid(size_t size)
{
resize(size);
}

template<typename T>
void OneDGrid<T>::resize(size_t newSize)
{
mElements.resize(newSize);
}

template<typename T>
T& OneDGrid<T>::operator[](size_t x)
{
return mElements[x];
}

template<typename T>
const T& OneDGrid<T>::operator[](size_t x) const
{
return mElements[x];
}

이렇게 구현한 OneDGrid를 이용해서 다음과 같이 다차원 그리드를 만들 수 있다.

OneDGrid<int> singleDGrid;
OneDGrid<OneDGrid<int>> twoDGrid;
OneDGrid<OneDGrid<OneDGrid<int>>> threeDGrid;
singleDGrid[3] = 5;
twoDGrid[3][3] = 5;
threeDGrid[3][3][3] = 5;

이렇게 해도 사용하는데 문제는 없지만 선언하는 부분이 좀 지저분하다. 다음 절에서 좀 더 개선해 보자.

진정한 N차원 Grid

템플릿 재귀를 활용하면 진정한 N차원 그리드를 구현할 수 있다. 다음 선언문에서 보듯이 그리드의 치원은 본질적으로 재귀적인 속성이 있기 때문이다.

OneDGrid<OneDGrid<OneDGrid<int>>> threeDGrid;

여기서 각각의 OneDGrid를 재귀의 한 단계로 볼 수 있다. int에 대한 OneDGrid는 재귀의 베이스 케이스 역할을 한다. 다시 말해 3차원 그리드는 int에 대한 일차원 그리드에 대한 일차원 그리드에 대한 일차원 그리드다. 이때 재귀 문장을 길게 나열할 필요 없이 다음과 같이 작성하면 알아서 N차원 그리드로 풀어서 써 준다.

NDGrid<int, 1> singleDGrid;
NDGrid<int, 2> twoDGrid;
NDGrid<int, 3> threeDGrid;

여기 나온 NDGrid 클래스 템플릿은 원소의 타입과 차원을 지정하는 정수를 인수로 받는다. 여기서 핵심은 NDGrid의 원소 타입이 템플릿 매개변수 리스트에 지정된 원소 타입이 아니라 현재 Grid보다 한 차원 낮은 NDGrid라는데 있다. 다시 말해 3차원 그리드는 2차원 그리드의 벡터고, 2차원 그리드는 1차원 그리드의 벡터다.

이렇게 재귀적으로 구성하려면 베이스 케이스를 지정해야 한다. 1차원으로 NDGrid를 부분 특수화하고, 원소를 NDGrid가 아닌 템플릿 매개변수로 지정한 타입으로 지정해야 한다.

이렇게 일반화한 NDGrid 템플릿의 정의 코드는 다음과 같다.

template<typename T, size_t N>
class NDGrid
{
public:
explicit NDGrid(size_t size = kDefaultSize);
virtual ~NDGrid() = default;

NDGrid<T, N=1>& operator[](size_t x);
const NDGrid<T, N-1>& operator[](size_t x) const;

void resize(size_t newSize);
size_t getSize() const { return mElements.size(); }

static const size_t kDefaultSize = 10;

private:
std::vector<NDGrid<T, N-1>> mElements;
};

여기서 mElements는 NDGrid<T, N-1>의 vector로서 재귀 단계에 해당한다. 또한 operator[]는 원소 타입에 대한 레퍼런스를 리턴한다. 이것 역시 T가 아닌 NDGgrid<T, N-1>이다.

베이스 케이스에 대한 템플릿 저으이 코드는 다음과 같이 차원이 1인 부분 특수화로 작성한다.

template<typename T>
class NDGrid<T, 1>
{
public:
explicit NDGrid(size_t size = kDefaultSize);
virtual ~NDGrid() = default;

T& operator[](size_t x);
const T& operator[](size_t x) const;

void resize(size_t newSize);
size_t getSize() const { return mElements.size(); }

static const size_t kDefaultSize = 10;

private:
std::vector<T> mElements;
};

여기서 재귀 단계가 끝난다. 원소 타입은 다른 템플릿의 인스턴스가 아닌 T다.

이렇게 구현할 때 템플릿 재귀 정의를 제외한 가장 까다로운 부분은 그리드의 각 차원의 크기를 적절히 정하는 것이다. 여기서는 각 차원의 크기가 같은 N 차원 Grid를 만들었다. 차원마다 크기를 다르게 구현하는 방법은 이보다 훨씬 복잡하다. 하지만 이렇게 단순한 경우에도 여전히 문제는 남아 있다.

예컨대 사용자가 지정한 크기(예: 20이나 50)로 배열을 생성해야 한다. 그러기 위해서는 생성자에 정수 크기를 받는 매개변수가 있어야 한다. 그런데 하위 그리드의 vector 크기를 동적으로 변경할 때 이 크기값을 하위 그리드 원소로 전달할 수 없다. vector는 디폴트 생성자로 객체를 만들기 때문이다. 따라서 vector에 있는 각 그리드 원소마다 resize()를 일일이 호출해야 한다. 이렇게 구현한 코드는 다음과 같다. 여기서 베이스 케이스에 대해서는 크기를 조절할 필요가 없다. 원소 타입이 그리드가 아닌 T이기 때문이다.

제네릭 NDGrid 템플릿의 구현 코드는 다음과 같다.

template<typename T, size_t N>
NDGrid<T, N>::NDGrid(size_t size)
{
resize(size);
}

template<typename T, size_t N>
void NDGrid<T>::resize(size_t newSize)
{
mElements.resize(newSize);

// vector에 대해 resize()를 호출하면 NDGrid<T, N-1> 원소에 대한 영인수 생성자를 호출해서 디폴트 크기로 원소가 생성된다.
// 따라서 각 원소마다 명싲거으로 resize()를 재귀호출하는 방식으로 중첩된 Grid 원소의 크기를 조정한다.
for(auto& element : mElements)
{
element.resize(newSize);
}
}

template<typename T, size_t N>
NDGrid<T, N-1>& NDGrid<T, N>::operator[](size_t x)
{
return mElements[x];
}

template<typename T, size_t N>
const NDGrid<T, N-1>& NDGrid<T, N>::operator[](size_t x) const
{
return mElements[x];
}

이제 베이스 케이스에 대한 부분 특수화를 구현해보자. 특수화를 구현하는 코드를 하나도 상속하지 않기 때문에 다시 작성해야 할 부분이 많다. 

template<typename T>
NDGrid<T, 1>::NDGrid(size_t size)
{
resize(size);
}

template<typename T>
void NDGrid<T, 1>::resize(size_t newSize)
{
mElements.resize(newSize);
}

template<typename T>
T& NDGrid<T, 1>::operator[](size_t x)
{
return mElements[x];
}

template<typename T>
const T& NDGrid<T, 1>::operator[](size_t x) const
{
return mElements[x];
}

이렇게 작성한 코드의 사용법은 다음과 같다.

NDGrid<int, 3> my3DGrid;
my3DGrid[2][1][2] = 5;
my3DGrid[1][1][1] = 5;
cout << my3DGrid[2][1][2] << endl;

가변 인수 템플릿

일반적으로 템플릿의 매개변수는 개수가 고정돼 있다. 하지만 가변 인수 템플릿은 템플릿 매개변수의 개수가 고정돼 있지 않다. 예컨대 다음과 같이 템플릿 매개변수의 개수를 지정하지 않게 정의할 수 있다. 이때 Types라는 매개변수 팩(parameter pack)을 사용한다.

template<typename... Types>
class MyVariadicTemplate {};

Note) typename 뒤에 붙은 … 은 가변 인수 템플릿에 대한 매개변수 팩을 정의하는 구문이다. 매개변수 팩은 다양한 수의 인수를 받을 수 있다. 점 세 개의 앞이나 뒤에 공백을 넣어도 된다.

예컨대 임의 개수의 타입에 대해 MyVariadicTemplate을 인스턴스화하면 다음과 같다.

MyVariadicTemplate<int> instance1;
MyVariadicTemplate<string, double, list<int>> instance2;

심지어 인수 없이 템플릿을 인스턴스화 할 수도 있다.

MyVariadicTemplate<> instance3;

가변 인수 템플릿을 인스턴스화 할 때 반드시 템플릿 인수를 지정하게 하려면 다음과 같이 정의한다.

template<typename T1, typename... Types>
class MyVariadicTemplate { };

이렇게 정의한 상태에서 MyVariadicTemplate을 인수 없이 인스턴스화하면 컴파일 에러가 발생한다.

타입에 안전한 가변 길이 인수 리스트

가변 인수 템플릿을 사용하면 타입에 안전한 가변 길이 인수 리스트를 만들 수 있다. 다음 예제는 processValues()라는 가변 인수 템플릿을 정의한 것이다. 이 템플릿은 인수의 타입과 개수가 일정하지 않더라도 타입에 안전하게 처리한다.

processValues() 함수는 가변 길이 인수 리스트로 주어진 각각의 인수마다 handleValue()를 호출한다. 그러므로 처리하려는 타입마다 handleValue() 함수를 구현해야 한다. 이 예제에서는 int, double, string 타입을 사용한다.

void handleValue(int value) { cout << "Integer: " << value << endl; }
void handleValue(double value) { cout << "Double: " << value << endl; }
void handleValue(string_view value) { cout << "String: " << value << endl; }

void processValues() { /* 베이스 케이스에 대해서는 특별히 할 일이 없다. */ }

template<typename T1, typename... Tn>
void processValues(T1 arg1, Tn... args)
{
handleValue(arg1);
processValues(args...);
}

위 예제에서 점 새 개 연산자가 세 번 나오는데, 두 가지 용도로 사용했다. 첫 번째 용도는 템플릿 매개변수 리스트의 typename 뒤와 함수 매개변수 리스트의 Tn 타입 뒤에 적은 것처럼 매개변수 팩을 표현하는 것이다. 매개변수 팩은 가변 인수를 받는다.

두 번쨰 용도 …를 함수 바디 매개변수 이름인 args 뒤에 붙여서 매개변수 팩 풀기 연산을 하는 것이다. 다시 말해 매개변수 팩으 풀어서(unpack/expand) 개별 인수로 분리한다. 기본적으로 이 연산자는 좌변을 인수로 받고, 팩에 있는 모든 템플릿 매개변수에 대해 반복하면서 각 인수를 콤마로 구분해서 하나씩 대입한다. 다음 코드를 보자.

processValues(args...);

이렇게 하면 args 매개변수 팩을 개별 인수로 풀고, 각각을 콤마로 구분한다. 그러고 나서 펼쳐진 인수 리스트로 processValues() 함수를 호출한다. 이 템플릿은 최소한 T1이라는 템플릿 매개변수를 받는다. processValues()를 args…에 대해 재귀적으로 호출하면 매 단계마다 매개변수를 하나씩 줄이면서 재귀적으로 호출한다.

processValues() 함수를 재귀적으로 구현했기 때문에 재귀 호출이 종료하는 조건도 반드시 지정해야 한다. 여기서는 인수를 받지 않는 processValues() 함수를 구현하는 방식으로 지정했다. 

이렇게 작성한 processValues() 가변 인수 템플릿을 다음과 같이 테스트할 수 있다.

processValues(1, 2, 3.56, "test", 1.1f);

이때 재귀 호출되는 과정은 다음과 같다.

processValues(1, 2, 3.56, "test", 1.1f);
handleValue(1);
processValues(2, 3.56, "test", 1.1f);
handleValue(2);
processValues(3.56, "test", 1.1f);
handleValue(3.56);
processValues("test", 1.1f);
handleValue("test");
processValues(1.1f);
handleValue(1.1f);
processValues();

여기서 명심할 부분은 이 메서드의 가변 길이 인수 리스트는 타입에 매우 안전하다는 점이다. processValues() 함수는 실제 타입에 맞게 오버로딩된 handleValue()를 알아서 호출한다. C++의 다른 코드처럼 자동 캐스팅할 수 있다. 예컨대 앞에 나온 예제에서 1.1f를 자동으로 float으로 캐스팅한다. 하지만 processValues()를 호출할 때 handleValue()를 지원하지 않는 타입으로 인수를 지정하면 컴파일 에러가 발생한다.

앞서 구현한 코드에 한 가지 문제가 있는데, 재귀적으로 호출했기 때문에 매번 processValues()를 호출할 때마다 매개변수가 복제된다. 그러면 인수의 타입에 따라 오버헤드가 커질 수 있다. processValues()에 값이 아닌 레퍼런스로 전달하면 복제를 줄일 수 있을거라 생각하기 쉽지만 아쉽게도 그렇게 하면 리터럴에 대해 processValues()를 호출할 수 없게 된다.

non-const 레퍼런스를 사용하면서 리터럴값을 사용하게 하려면 포워드 레퍼런스(forward reference)를 사용하면 된다. 다음 코드는 포워드 레퍼런스인 T&&를 사용했고, 모든 매개변수에 대해 퍼펙트 포워딩(perfect forwarding)을 적용하도록 std::forward()를 사용했다.

여기서 퍼펙트 포워딩이란 processValues()에 우측값(rvalue)이 전달되면 우측값 레퍼런스로 포워드(forward)되고, 좌측값(lvalue)이나 좌측값 레퍼런스가 전달되면 좌측값 레퍼런스로 포워드 된다는 뜻이다.

void processValues() { /* 베이스 케이스에 대해서는 특별히 할 일이 없다. */ }

template<typename T1, typename... Tn>
void processValues(T1&& arg1, Tn&&... args)
{
handleValue(std::forward<T1>(arg1));
processValues(std::forward<Tn>(args)...);
}

다음 문장에 대해서는 보충 설명이 필요하다.

processValues(std::forward<Tn>(args)...);

… 연산자는 매개변수 팩을 풀 때(unpack) 사용한다. 이 연산자는 매개변수 팩에 있는 각 인수를 std::forward()로 호출하고, 그들을 콤마로 구분해서 분리한다. 예컨대 args란 매개변수 팩이 A1, A2, A3 타입으로 된 a1, a2, a3라는 인수로 구성됐다고 하자. 이 팩을 풀려면 다음과 같이 호출한다.

processValues(std::forward<A1>(a1), std::foward<A2>(a2), std::forward<A3>(a3));

매개변수 팩을 사용하는 함수의 바디 안에서 이 팩에 담긴 인수의 개수를 알아내는 방법은 다음과 같다. 

int numOfArgs = sizeof...(args);

가변 인수 템플릿을 실제로 활용하는 예로 보안과 타입에 안전한 printf() 류의 함수를 구현하는 경우를 들 수 있다.

가변 개수의 믹스인 클래스

매개변수 팩은 거의 모든 곳에서 사용할 수 있다. 예컨대 다음 코드는 매개변수 팩을 이용하여 MyClass에 대한 가변 개수의 믹스인 클래스를 정의한다.

class Mixin1
{
public:
Mixin1(int i) : mValue(i) {}
virtual void Mixin1Func() { cout << "Maxin1: " << mValue << endl; }
private:
int mValue;
};

class Mixin2
{
public:
Mixin2(int i) : mValue(i) {}
virtual void Mixin2Func() { cout << "Maxin2: " << mValue << endl; }
private:
int mValue;
};

template<typename... Mixins>
class MyClass : public Mixins...
{
public:
MyClass(const Mixins&... mixins) : Mixins(mixins)... {}
virtual ~MyClass() = default;
};

이 코드는 먼저 믹스인 클래스 두 개를 정의하고 각 클래스마다 정수를 인수로 받아서 저장하는 생성자와 각 인스턴스의 정보를 화면에 출력하는 함수를 하나씩 정의했다. 가변 인수 템플릿인 MyClass는 매개변수 팩인 typename… Mixins를 사용하여 다양한 수의 믹스인 클래스를 받는다.

이 클래스는 이렇게 전달된 모든 믹스인 클래스를 상속하고, 생성자에서도 같은 수의 인수를 받아서 각자 상속한 믹스인 클래스를 초기화한다. 여기서 … 연산자는 기본적으로 좌변의 내용을 인수로 받아서 팩에 있는 템플릿 매개변수에 대해 루프를 돌면서 콤마로 구분하면서 푼다. 이렇게 정의한 클래스는 다음과 같이 사용한다.

MyClass<Minxin1, Minxin2> a(Mixin1(11), Mixin2(22));
a.Mixin1Func();
a.Mixin2Func();

MyClass<Mixin1> b(Mixin1(33));
b.Mixin1Func();
//b.Mixin2Func(); // 컴파일 에러

MyClass<> c;
//c.Mixin1Func(); // 컴파일 에러
//c.Mixin2Func(); // 컴파일 에러

b에 대해 Mixin2Func()를 호출하면 컴파일 에러가 발생하는데, b는 Mixin2 클래스를 상속하지 않았기 때문이다.

폴딩 표현식

C++ 17부터 추가된 폴딩 표현식(folding expression)이란 기능을 활용하면 가변 인수 템플릿에서 매개변수 팩을 보다 쉽게 다룰 수 있다. 다음 표는 C++에서 지원하는 네 가지 종류의 폴딩 표현식을 보여주고 있다. 여기서 Θ 자리에 나올 수 있는 연산자는 + – * / % ^ & | << >> += -= *= /= %= ^= &= |= <<= >>= = == != < > <= >= && || , .* ->* 등이 있다.

이름 표현식 펼친 형태
단항 우측 폴드 (pack Θ …) pack0 Θ(… Θ(packn-1 Θ packn))
단항 좌측 폴드 (… Θ pack) ((pack0 Θ pack1) Θ … ) Θ packn
이항 우측 폴드 (pack Θ … Θ Init) pack0 Θ (… Θ (packn-1 Θ (packn Θ Init)))
이항 좌측 폴드 (Init Θ … Θ pack) (((Init Θ pack0) Θ pack1) Θ …) Θ packn

앞서 본 processValue() 함수 템플릿은 다음과 같이 재귀적으로 정의 했다.

void processValues() { /* 베이스 케이스에 대해서는 특별히 할 일이 없다. */ }

template<typename T1, typename... Tn>
void processValues(T1 arg1, Tn... args)
{
handleValue(arg1);
processValues(args...);
}

재귀적으로 저으이했기 때문에 재귀를 멈출 베이스 케이스를 지정해야 한다. 폴딩 표현식을 사용하면 단항 우측 폴드를 이용한 함수 템플릿 하나로 구현할 수 있다. 그러므로 베이스 케이스를 따로 지정하지 않아도 된다.

template<typename... Tn>
void processValues(Tn... args)
{
(handleValue(args), ...);
}

기본적으로 함수 본문에 있는 점 세 개 (…) 연산자로 폴딩한다. 이 문장이 펼쳐지면서 매개변수 팩에 있는 인수마다 handleValue()를 호출하며 결과가 콤바로 구분돼 담긴다. 예컨대 args가 a1, a2, a3라는 세 인수로 구성된 매개변수 팩에 대해 단항 우측 폴드가 다음과 같이 펼쳐진다.

(handleValue(a1), (handleValue(a2), handleValue(a3)));

또 다른 예를 보자. printValues() 함수 템플릿은 주어진 인수를 각각 한 줄씩 구분해서 콘솔에 출력한다.

template<typename... Values>
void printValues(const Values&... values)
{
((cout << values << endl), ...);
}

여기서 values가 v1, v2, v3라는 세 인수로 구성된 매개변수 팩이라면 단항 우측 폴드로 인해 다음과 같이 펼쳐진다.

((cout << v1 << endl), ((cout << v2 << endl), (cout << v3 << endl)));

printvalues()에 원하는 수만큼 인수를 얼마든지 지정해서 호출할 수 있다.

printValues(1, "test", 2.34);

이 예제에서는 폴딩에 콤마 연산자를 사용했지만 거의 모든 연산자와 함께 사용할 수도 있다. 예컨대 다음 코드는 주어진 값을 모두 더한 결과를 구하는 가변 인수 함수 템플릿을 이항 좌측 폴드로 정의하고 있다. 이항 좌측 폴드에는 반드시 Init 값을 지정해야 한다. 따라서 sumValues()는 두 개의 템플릿 타입 매개변수(Init의 타입을 지정하는 일반 매개변수와 0개 이상의 인수를 받을 수 있는 매개변수 팩)로 구성된다.

template<typename T, typename... Values>
double sumValues(const T& init, const Values&... values)
{
return (init + ... + values);
}

만약 values가 v1, v2, v3라는 세 인수로 구성된 매개변수 팩이라면 이항 좌측 폴드를 펼친 결과는 다음과 같다.

return (((init + v1) + v2 + v3);

이렇게 만든 sumValues() 함수 템플릿을 사용하는 방법은 다음과 같다.

cout << sumvalues(1, 2, 3.3) << endl;
cout << sumValues(1) << endl;

이렇게 템플릿을 정의하면 인수를 최소 한 개 이상 지정해야 한다. 따라서 다음과 같이 작성하면 컴파일 에러가 발생한다.

cout << sumValues() << endl;

메타 프로그래밍

메타 프로그래밍은 책 한 권을 쓸 정도로 방대한 주제이므로 간단히 소개만 하겠다.

템플릿 메타 프로그래밍은 실행 시간이 아닌 컴팡리 시간에 연산을 수행할 목적으로 사용한다. 기본적으로 C++ 위에 정의된 프로그래밍 언어다.

컴파일 시간에 팩토리얼 계산하기

템플릿 메타 프로그래밍을 이용하면 실행 시간이 아닌 컴파일 시간에 계산을 수행할 수 있다. 다음 코드는 어떤 수의 팩토리얼을 컴파일 시간에 계산하는 예를 보여준다. 여기서는 앞서 사용한 템플릿 재귀 코드를 구현했다. 그래서 재귀 템플릿과 재귀를 멈추기 위한 베이스 템플릿을 작성해야 한다. 팩토리얼의 수학 정의에 따르면 0 팩토리얼은 1이기 때문에 이 값을 베이스 케이스로 지정한다.

template<unsigned char f>
class Factorial
{
public:
static const unsigned long long val = (f * Factorial<f-1>::val);
};

template<>
class Factorial<0>
{
public:
static const unsigned long long val = 1;
};

int main()
{
cout << Factorial<6>::val << endl;
return 0;
}

Note) 여기 나온 팩토리얼 계산이 수행되는 시점은 컴파일 시간이라는 점을 명심한다. 이렇게 컴파일 시간에 계산된 결과는 실행 시간에서 볼 때 정적 상숫값이므로 ::val로 접근한다.

(이하 예시 생략)

루프 언롤링

템플릿 메타 프로그래밍의 두 번째 예로 반복문을 실행 시간에 처리하지 않고, 컴파일 시간에 일렬로 펼쳐놓는 방식으로 처리하는 루프 언롤링(loop unrolling)이란 기법이 있다. 참고로 루프 언롤링은 꼭 필요할 때만 사용하는 것이 좋다. 굳이 언롤링하도록 작성하지 않아도 컴파일러의 판단에 따라 자동으로 언롤링하기 때문이다.

이번 예제도 템플릿 재귀로 작성한다. 컴팡리 시간에 루프 안에서 작업을 처리해야 하기 때문에 각 재귀 단계마다 Loop 템플릿이 i – 1에 대해 인스턴스화된다. 0에 도달하면 재귀가 종료 된다.

template<int i>
class Loop
{
public:
template<typename FuncType>
static inline void Do(FuncType func)
{
Loop<i-1>::Do(func);
func(i);
}
};

template<>
class Loop<0>
{
public:
template <typename FuncType>
static inline void Do(FuncType /* func */) {}
};

이렇게 작성한 Loop를 사용하는 방버은 다음과 같다.

void DoWork(int i) { cout << "DoWork(" << i << ")" << endl; }

int main()
{
Loop<3>::Do(DoWork);
}

이렇게 작성하면 컴파일러는 DoWork() 함수를 세 번 호출하는 문장으로 루프를 펼친다. 이 코드를 실행한 결과는 다음과 같다.

DoWork(1)
DoWork(2)
DoWork(3)

람다 표현식을 이용해서 매개변수를 한 개 이상 받게 만들 수도 있다.

void DoWork2(string str, int i)
{
cout << "DoWork2(" << str << ", " << i << ")" << endl;
}

int main()
{
Loop<2>::Do([](int i) { DoWork2("TestStr", i); });
}

이 코드는 먼저 string과 int를 인수로 받는 함수를 구현한다. main() 함수는 람다 표현식을 이용하여 반복문을 한 번씩 돌 때마다 첫 번째 매개변수로 주어진 스트링 (“TestStr”)에 대해 DoWork2()를 호출한다. 이 코드를 컴파일해서 실행하면 다음과 같은 결과가 나온다.

DoWork2(TestStr, 1)
DoWork2(TestStr, 2)

tuple 출력하기

이번에는 std::tuple에 있는 각 원소를 화면에 출력하는 기능을 템플릿 메타 프로그래밍으로 구현해보자. tuple은 타입이 별도로 지정된 값을 원하는 만큼 담을 수 있다. tuple의 크기와 값의 타입은 컴파일 시간에 결정된다. 하지만 튜플은 원소에 대해 반복하는 메커니즘을 기본적으로 제공하지 않는다. 다음 코드는 템플릿 메타프로그래밍으로 tuple의 원소에 대해 컴파일 시간에 루프를 돌 수 있도록 구현하는 예이다.

다른 템플릿 메타프로그래밍 예제와 마찬가지로 이번에도 템플릿 재귀를 사용한다. tuple_print 클래스 템플릿은 템플릿 매개변수를 두 개 받는다. 하나는 tuple 타입이고, 다른 하나는 초기화할 때 설정할 튜플의 크기를 나타내는 정수다. 생성자에서 이 템플릿을 재귀적으로 인스턴스화하고 매번 호출될 때마다 정숫값을 하나씩 감소시킨다. 이 정숫값이 0에 도달하면 재귀를 멈추도록 tuple_print 부분을 특수화한다. main() 함수는 이렇게 정의한 tuple_print 클래스 템플릿을 사용하는 방법을 보여주고 있다.

template<typename TupleType, int n>
class tuple_print
{
public:
tuple_print(const TupleType& t)
{
tuple_print<TupleType, n-1> tp(t);
cout << get<n-1>(t) << endl;
}
};

template<typename TupleType>
class tuple_print<TupleType, 0>
{
public:
tuple_print(const TupleType&) {}
};

int main()
{
using MyTuple = tuple<int, string, bool>;
MyTuple t1(16, "Test", true);
tuple_print<MyTuple, tuple_size<MyTuple>::value> tp(t1);
}

main() 함수의 코드를 보면 tuple_print 템플릿을 사용하는 문장이 다소 복잡하게 표현돼 있다. tuple의 정확한 타입과 tuple의 크기를 템플릿 매개변수로 지정하기 때문이다. 템플릿 매개변수를 자동으로 추론하는 헬퍼 함수 템플릿을 사용하면 이 부분을 좀 더 간결하게 표현할 수 있다. 예컨대 다음과 같다.

template<typename TupleType, int n>
class tuple_print_helper
{
public:
tuple_print_helper(const TupleType& t)
{
tuple_print_helper<TupleType, n-1> tp(t);
cout << get<n-1>(t) << endl;
}
};

template<typename TupleType>
class tuple_print_helper<TupleType, 0>
{
public:
tuple_print_helper(const TupleType&) {}
};

template<typename T>
void tuple_print(const T& t)
{
tuple_print_helper<T, tuple_size<T>::value> tph(t);
}

int main()
{
auto t1 = make_tuple(167, "Testing", false, 2.3);
tuple_size(t1);
}

가장 먼저 tuple_print 클래스 템플릿을 적은 부분을 tuple_print_helper로 바꿨다. 그런 다음 tuple_print()라는 간단한 함수 템플릿을 구현했다. 이 템플릿은 tuple의 타입을 템플릿 타입 매개변수로 받으며, tuple 자체에 대한 레퍼런스 함수 매개변수로 받는다. 이 함수 템플릿의 본문에서는 tuple_print_helper 클래스 템플릿을 인스턴스화 한다.

main() 함수는 이렇게 간략하게 수정한 버전을 사용하는 방법을 보여준다. 이렇게 하면 tuple의 구체적인 타입은 몰라도 되기 때문에 make_tuple()에 auto를 적용할 수 있다. tuple_print() 함수 템플릿을 호출하는 코드가 다음과 같이 굉장히 간단해졌다.

tuple_print(t1);

함수 템플릿 매개변수는 직접 지정할 필요 없다. 인수를 보고 컴파일러가 추론하기 때문이다.

constexpr if

C++ 17부터 constexpr if가 추가됐다. constexpr if는 실행 시간이 아닌 컴팡리 시간에 수행된다. constexpr if의 조건을 만족하지 않으면 컴파일되지 않는다. 이 구문을 사용하면 템플릿 메타 프로그래밍 코드를 훨씬 간결하게 작성할 수 있다. 또한 SFINAE에서도 굉장히 유용하게 쓰인다.

예컨대 앞에서 tuple의 원소를 화면에 출력하는 코드에 constexpr if를 적용해서 다음과 같이 간결하게 표현할 수 있다. 이렇게 하면 템플릿 재귀의 베이스 케이스를 더는 지정하지 않아도 된다. constexpr if 문에서 재귀가 멈추기 때문이다.

template<typename TupleType, int n>
class tuple_print
{
public:
tuple_print(const TupleType& t)
{
if constexpr(n > 1)
{
tuple_print_helper<TupleType, n-1> tp(t);
}
cout << get<n-1>(t) << endl;
}
};

template<typename TupleType>
class tuple_print<const T& t>
{
tuple_print_helper(T, tuple_size<T>::value> tph(t);
};

이렇게 하면 클래스 템플릿 자체를 제거하고, 그 자리에 다음과 같이 간단히 구현한 tuple_print_helper라는 함수 템플릿을 넣어도 된다.

template<typename TupleType, int n>
class tuple_print_helper<const TupleType& t>
{
if constexpr(n > 1)
{
tuple_print_helper<TupleType, n-1>(t);
}
cout << get<n-1>(t) << endl;
};

template<typename T>
void tuple_print(const T& t)
{
tuple_print_helper<T, tuple_size<T>::value>(t);
}

이 코드에 나온 두 메서드를 하나로 합쳐서 다음과 같이 좀 더 간결하게 표현할 수 있다.

template<typename TupleType, int n = tuple_size<TupleType>::value>
void tuple_print(const TupleType& t)
{
if constexpr(n > 1)
{
tuple_print<TupleType, n-1>(t);
}
cout << get<n-1>(t) << endl;
}

이렇게 바꿔도 이전과 같은 방식으로 호출 할 수 있다.

auto t1 = make_tuple(167, "Testing", false, 2.3);
tuple_size(t1);

폴딩으로 컴파일 시간 정수 시퀀스 사용하기

C++은 <utility> 헤더 파일에 정의된 std::integer_sequence를 이용한 컴파일 시간 정수 시퀀스를 제공한다. 이 기능은 템플릿 메타 프로그래밍에서 인덱스의 시퀀스, 즉 size_t 타입에 대한 정수 시퀀스를 컴파일 시간에 생성하는데 주로 사용된다. 이를 위해 std::index_sequence도 제공한다. 주어진 매개변수 팩과 같은 길이의 인덱스 시퀀스를 생성할 때는 std::index_sequence_for를 사용하면 된다.

튜플을 출력하는 코드를 다음과 같이 가변 인수 템플릿, 컴파일 시간 인덱스 시퀀스 그리고 C++ 17부터 제공하는 폴딩 표현식으로 구현할 수 있다.

template<typename Tuple, size_t... Indices>
void tuple_print_helper(const Tuple& t, index_sequence<Indices...>)
{
((cout << get<Indices>(t) << endl), ...);
}

template<typename... Args>
void tuple_print(const tuple<Args...>& t)
{
tuple_print_helper(t, index_sequence_for<Args...>());
}

이렇게 작성해도 이전과 똑같은 방식으로 호출할 수 있다.

auto t1 = make_tuple(167, "Testing", false, 2.3);
tuple_size(t1);

이렇게 호출하면 tuple_print_helper() 함수 템플릿에 있는 단항 우측 폴드 표현식이 다음과 같이 펼쳐진다.

(((cout << get<0>(t) << endl),
((cout << get<1>(t) << endl),
((cout << get<2>(t) << endl),
(cout << get<3>(t) << endl)))));

타입 트레이트

타입 트레이트를 이용하면 타입에 따라 분기하는 동작을 컴팡리 시간에 처리할 수 있다. 예컨대 특정한 타입을 상속하는 타입, 특정한 타입으로 변환할 수 있는 타입, 정수 계열의 타입을 요구하는 템플릿 등을 작성할 수 있다. C++ 표준에서는 이를 위해 몇 가지 헬퍼 클래스를 제공한다. 타입 트레이드에 관련된 기능은 모두 <type_traits> 헤더 파일에 정의돼 있다.

타입 트레이트는 몇 가지 범주로 나눌 수 있다.

(상세 분류 생략)

타입 트레이트는 C++에서도 상당히 고급 기능에 속한다. 여기서는 몇 가지 활용 사례만 소개한다.

타입 범주에 속한 타입 트레이트

타입 트레이트를 사용하는 템플릿의 예를 보기 전에 먼저 is_integral과 같은 클래스의 작동 방식을 좀 더 살펴볼 필요가 있다. C++ 표준에서는 다음과 같이 integral_constant 클래스를 정의하고 있다.

template<class T, T v>
struct integral_constant
{
static constexpr T value = v;
using value_type = T;
using type = integral_constant<T, v>;
constexpr operator value_type() const noexcept { return value; }
constexpr value_type operator()() const noexcept { return value; }
};

또한 bool_constant, true_type, false_type과 같은 타입 앨리어스도 정의하고 있다.

template<bool B>
using bool_constant = integral_constant<bool, B>;

using true_type = bool_constant<true>;
using false_type = bool_constant<false>;

이 코드는 true_type과 false_type이란느 두 가지 타입을 정의한다. true_type::value로 접근하면 true란 값을 구하고, false_type::value로 접근하면 false란 값을 구할 수 있다. 또한 true_type::type으로 접근하면 true_type이란 타입ㅇ르 구할 수 있다. false_type도 마찬가지로 적용할 수 있다. is_integral이나 is_class 같은 클래스는 true_type이나 false_type을 상속한다. 예컨대 is_integral을 다음과 같이 bool 타입에 대해 특수화할 수 있다.

template<> struct is_integral<bool> : public true_type { };

이렇게 하면 is_integral<bool>::value란 문장으로 true란 값을 구할 수 있다. 특수화하는 코드는 직접 작성하지 않아도 된다. 표준 라이브러리에서 기본으로 제공하기 때문이다.

타입 범주를 사용하는 가장 간단한 예는 다음과 같다.

if (is_integral<int>>::value)
{
cout << "int is integral" << endl;
}
else
{
cout << "int is not integral" << endl;
}

if (is_class<string>::value)
{
cout << "string is a class" << endl;
}
else
{
cout << "string is not a class" << endl;
}

여기서는 is_integral로 int가 정수 타입인지 검사하고, is_class로 string이 클래스인지 확인한다.

C++ 17부터 value 멤버가 있는 트레이트마다 트레이트 이름 뒤에 _v가 붙은 가변 템플릿이 추가됐다. 그래서 some_trait<T>::value라고 적지 않고, some_trait_v<T>와 같은 형태로 표현할 수 있다. 앞에 나온 코드를 이러한 헬퍼를 사용하도록 수정하면 다음과 같다.

if (is_integral_v<int>>)
{
cout << "int is integral" << endl;
}
else
{
cout << "int is not integral" << endl;
}

if (is_class_v<string>)
{
cout << "string is a class" << endl;
}
else
{
cout << "string is not a class" << endl;
}

물론 타입 트레이트를 이렇게 활용할 일은 거의 없다. 그보다는 타입의 특정한 속성을 기준으로 코드를 생성하기 위해 템플릿과 함께 사용할 때 유용하다. 다음에 나온 템플릿 함수가 바로 이러한 예를 보여준다.

이 코드는 타입을 템플릿 매개변수로 받는 process_helper() 함수 템플릿을 두 가지 방식으로 오버로딩하도록 정의한다. 첫 번째 매개변수는 값이고, 두 번째 매개변수는 true_type이나 false_type 중 한 인스턴스다. process() 함수 템플릿은 매개변수 하나만 받아서 process_helper()를 호출한다.

template<typename T>
void process_helper(const T& t, true_type)
{
cout << t << " is an integral type" << endl;
}

template<typename T>
void process_helper(const T& t, false_type)
{
cout << t << " is an non-integral type" << endl;
}

template<typename T>
void process(const T& t)
{
process_helper(t, typename is_integral<T>::type());
}

process_helper()를 호출할 때 두 번째 인수를 다음과 같이 지정했다.

typename is_integral<T>::type()

이 인수는 is_integral을 이용하여 T가 정수 계열 타입인지 검사한다. 그 결과로 나오는 integral_constant 타입을 ::type으로 접근하면 true_type이나 false_type 중 하나가 나온다.

process_helper() 함수는 true_type이나 false_type 중 한 인스턴스를 두 번째 매개변수로 받는다. 그래서 ::type 뒤에 빈 소괄호를 붙였다. 여기서 process_helper()에 대한 두 가지 오버로딩 버전은 true_type과 false_type이란 타입에 대해 이름 없는 매개변수를 받는다. 이렇게 이름이 없는 이유는 함수 본문에서 이 매개변수를 사용하지 않기 때문이다. 이 매개변수는 여러 가지 오버로딩 버전 중 하나를 결정하는데만 사용된다.

이렇게 작성한 코드를 사용하는 방법은 다음과 같다.

process(123);
process(2.2);
process("Test"s);

앞의 예제를 다음과 같이 함수 템플릿 하나만으로 표현할 수 있다. 하지만 이렇게 작성하면 타입 트레이트로 주어진 타입에 맞는 오버로딩 버전을 결정하는 예를 볼 수 없다.

template<typename T>
void process(const T& t)
{
if constexpr (is_integral_v<T>)
{
cout << t << " is an integral type" << endl;
}
else
{
cout << t << " is an non-integral type" << endl;
}
}

타입 관계 활용 방법

타입 관계에 대한 예로 is_same, is_base_of, is_convertible 등이 있다. 여기서는 is_same을 사용하는 방법에 대한 예제만 소개한다. 나머지 타입 관계도 사용법은 비슷한다.

다음 코드에 나온 same() 함수 템플릿은 is_same 타입 트레이트를 이용하여 주어진 두 인수의 타입이 서로 같은지 검사한 뒤 결과에 따라 메시지를 출력한다.

template<typename T1, typename T2>
void same(const T1& t1, const T2& t2)
{
bool areTypesTheSame = is_same_v<T1, T2>;
cout << "'" << t1 << "' and '" << t2 << "' are ";
cout << (areTypesTheSame ? "the same types" : "different types") << endl;
}

int main()
{
same(1, 32);
same(1, 3.01);
same(3.01, "Test"s);
}

enable_if 사용법

enable_if는 C++의 난해한 특성 중 하나인 SFINAE(substitution failure is not an error, 치환 실패는 에러가 아니다)에 기반을 두고 있다. 이 절에서는 SFINAE의 기본 개념만 소개한다.

오버로딩된 함수가 여러 개 있을 때 enable_if를 이용하여 특정한 타입 트레이트에 따라 오버로딩된 함수 중 일부를 끌 수 있다. enable_if 트레이트는 오버로딩 함수들에 대한 리턴 타입을 기준으로 분기할 때 주로 사용한다. enable_if는 템플릿 타입 매개변수를 두 개 받는다. 하나는 부울값이고, 다른 하나는 타입인데 디폴트값은 void다. 

부울값을 true로 지정하면 enable_if는 중첩된 타입을 가지며, ::type으로 접근할 수 있다. 이렇게 중첩된 타입의 타입은 두 번째 템플릿 타입 매개변수로 지정한 타입이다. 부울값을 false로 지정하면 중첩된 타입이 생기지 않는다.

C++ 표준은 enable_if 처럼 type 멤버를 가진 트레이트에 대한 앨리어스 템플릿을 몇 가지 정의하고 있다. 각각의 이름은 트레이트 이름 뒤에 _t가 붙어 있다. 예컨대 다음 문장을

typename enable_if<..., bool>::type

다음과 같이 간략하게 표현할 수 있다.

enable_if t<..., bool>

앞 절에서 본 same() 함수 템플릿을 다음과 같이 enable_if를 사용하여 오버로딩 버전인 check_type() 함수 템플릿으로 표현할 수 있다. 이때 check_type() 함수는 주어진 두 값의 타입이 같은지 여부에 따라 true나 false 중에서 하나를 리턴한다. check_type()에서 아무 것도 리턴하고 싶지 않다면 return 문을 삭제하고, enable_if 문의 두 번째 템플릿 타입 매개변수도 삭제하거나 void로 지정한다.

template<typename T1, typename T2>
enable_if_t<is_same_v<T1, T2>, bool> check_type(const T1& t1, const T2& t2)
{
cout << "'" << t1 << "' and '" << t2 << "' ";
cout << "are the same types" << endl;
return true;
}

template<typename T1, typename T2>
enable_if_t<!is_same_v<T1, T2>, bool> check_type(const T1& t1, const T2& t2)
{
cout << "'" << t1 << "' and '" << t2 << "' ";
cout << "are different types" << endl;
return false;
}

int main()
{
check_type(1, 32);
check_type(1, 3.01);
check_type(3.01, "Test"s);
}

이 코드는 check_type()을 두 가지 버전으로 정의한다. 두 버전의 리턴 타입은 모두 enable_if에 대한 중첩 타입인 bool이다. 먼저 is_same_v로 두 타입이 같은지 검사하고, 그 결과를 enable_if_t로 전달한다. enable_if_t의 첫 번째 인수가 true면 enable_if_t는 bool 타입을 갖고 그렇지 않으면 타입이 없다. 바로 여기서 SFINAE가 적용된다.

main()의 첫 문장을 컴파일 할 때 정숫값 두 개를 받는 check_type()이 있는지 찾는다. 먼저 소스 코드에 있는 첫 번째 check_type() 함수 템플릿을 보고 T1과 T2를 모두 정수로 만들어서 이 함수 템플릿의 인스턴스를 사용할 수 있다고 추론한다. 그런 다음 리턴 타입을 알아낸다. 두 인수 모두 정수라서 is_same_v<T1, T2>의 결과는 true가 된다. 그래서 enable_if_t<true, bool>의 타입은 bool이 된다. 이렇게 인스턴스화하는 과정에서 아무런 문제가 발생하지 않으면 컴파일러는 이 버전의 check_type()을 적용한다.

그런데 main()의 두 번째 문장을 컴파일할 때도 적절한 check_type() 함수를 찾는 작업을 또 수행한다. 먼저 check_type()을 찾아서 T1을 int로, T2를 double로 설정해서 오버로딩하도록 처리한다. 그런 다음 리턴 타입을 결정하는데, 이번에는 T1과 T2가 서로 타입이 다르기 때문에 is_same_t<T1, T2>의 결과는 false가 된다. 그래서 enable_if_t<false, bool>은 타입을 표현하지 않고, check_type() 함수의 리턴 타입도 지정하지 않는다. 컴파일러는 이 에러를 발견해도 SFINAE를 적용하기 때문에 실제로 컴파일 에러가 발생하지 않는다. 그 대신 지금까지 하던 작업을 조용히 역추적해서 다른 check_type() 함수를 찾는다. 아제 두 번째 check_type()이 !is_same_v<T1, T2>에 대해 true가 된다는 것을 발견하고 enable_if_t<true, bool>의 타입이 bool이 돼 정상적으로 인스턴스화된다.

enable_if를 여러 버전의 생성자에 적용할 때 리턴 타입에는 적용할 수 없다. 생성자는 원래 리턴 타입이 없기 때문이다. 이럴 때는 생성자에 디폴트값을 가진 매개변수를 하나 더 추가해서 enable_if를 적용하면 된다.

enable_if를 사용할 때는 조심해야 한다. 특수화나 부분 특수화와 같은 다른 기법으로는 도저히 적합한 오버로딩 버전을 찾기 힘들 때만 사용하는 것이 좋다. 예컨대 잘못된 타입으로 템플릿을 사용할 때 그냥 컴파일 에러만 발생시키고 싶다면 SFINAE를 적용하지 말고 27장에서 설명하는 static_assert()를 사용한다.

물론 enable_if를 사용하는 것이 좋을 때도 있다. 예컨대 vector와 비슷한 기능을 정의하는 커스텀 클래스에서 복제 함수를 제공할 때 enable_if와 is_trivially_copyable 타입 트레이트를 활용하면 단순히 복제할 수 있는 타입을 비트 단위 복제로 처리하도록 특수화할 수 있다. 예컨대 memcpy()를 사용하도록 복제 함수를 특수화할 수 있다.

Caution) SFINAE를 적용하는 방법은 상당히 까다롭고 복잡하다. SFINAE와 enable_if를 적용할 떄 여러 가지 오버로딩 버전 중 엉뚱한 버전을 비활성화하게 되면 알 수 없는 컴파일 에러가 발생하는데 에러 메시지만 보고 문제의 원인을 찾기 굉장히 힘들다.

constexpr if로 enable_if 간결하게 표현하기

앞의 예제에서 볼 수 있듯이 enable_if를 사용하면 코드가 굉장히 복잡해질 수 있다. C++ 17부터 추가된 constexpr if 기능을 활용하면 enable_if를 활용하는 코드를 훨씬 간결하게 표현할 수 있다. 예컨대 다음과 같이 정의된 두 클래스를 살펴보자.

class IsDoable
{
public:
void doit() const { cout << "IsDoable::doit()" << endl; }
};

class Derived : public IsDoable { };

doit() 메서드가 제공된다면 이를 호출하고 그렇지 않으면 콘솔에 에러 메시지를 출력하는 call_doit() 이란 함수 템플릿을 만들어보자. 이때 enable_if를 이용해서 주어진 타입이 IsDoable을 상속했는지 확인할 수 있다.

template<typename T>
enable_if_t<is_base_of_v<IsDoable, T>, void> call_doit(const T& t)
{
t.doit();
}

template<typename T>
enable_if_t<!is_base_of_v<IsDoable, T>, void> call_doit(const T&)
{
cout << "Cannot call doit()!" << endl;
}

이 템플릿을 다음과 같이 사용할 수 있다.

Derived d;
call_doit(d);
call_doit(123);

// 실행 결과
// IsDoable::doit()
// Cannot call doit()!

C++ 17부터 추가된 constexpr if를 활용하면 이 코드를 다음과 같이 좀 더 간결하게 표현할 수 있다.

template<typename T>
void call_doit(const T& [[maybe_unused]] t)
{
if constexpr (is_base_of_v<IsDoable, T>)
{
t.doit();
}
else
{
cout << "Cannot call doit()!" << endl;
}
}

기존 if 문으로는 절대로 이렇게 할 수 없다. 일반 if 문은 모든 분기문이 반드시 컴파일 돼야 하기 때문에 IsDoable을 상속하지 않은 타입을 T에 지정하면 에러가 발생한다. 이 예제는 t.doit()이 나오는 문장에서 컴파일 에러가 발생한다. 하지만 constexpr if 문을 사용하여 IsDoable을 상속하지 않은 타입을 지정하면 t.doit()이란 문장 자체를 아예 컴파일하지 ㅇ낳게 된다.

여기서 C++ 17부터 추가된 [[maybe_unused]] 어트리뷰트를 사용한 점도 주목한다. IsDoable을 상속하지 않은 타입으로 T를 지정하면 t.doit()이 컴파일 되지 않기 떄문에 call_doit()을 인스턴스화한 코드에서 매개변수 t가 사용되지 않는다. 대다수의 컴파일러는 이렇게 사용하지 않는 매개변수가 있을 때 경고나 에러 메시지를 출력한다. 이때 [[maybe_unsed]] 속성을 지정하면 이러한 매개변수 t에 대해 경고나 에러가 발생하지 않는다.

is_base_of 타입 트레이트 대신 C++ 17부터 추가된 is_invocable 트레이트를 사용해도 된다. 이 트레이트는 주어진 함수가 주어진 인수 집합에 대해 호출되는지 검사한다. is_invocable 트레이트로 call_doit() 을 구현하면 다음과 같다.

template<typename T>
void call_doit(const T& [[maybe_unused]] t)
{
if constexpr (is_invocable_v<decltype(&IsDoable::doit), T>)
{
t.doit();
}
else
{
cout << "Cannot call doit()!" << endl;
}
}

논리 연산자 트레이트

논리 연산자에 대해서도 세 가지 트레이트(conjunction, disjunction, negation)을 제공한다. _v로 끝나는 가변 템플릿도 제공된다. 이러한 트레이트는 다양ㅎ나 개수의 템플릿 타입 매개변수를 받으며, 타입 트레이트에 대해 논리 연산을 수행하는데 활용할 수 있다. 예컨대 다음과 같다.

cout << conjunction_v<is_integral<int>, is_integral<short>> << " ";
cout << conjunction_v<is_integral<int>, is_integral<double>> << " ";
cout << disjunction_v<is_integral<int>, is_integral<double>, is_integral<short>> << " ";
cout << negation_v<is_integral<int>> << " ";

// 실행 결과
// 1 0 1 0

템플릿 메타 프로그래밍 맺음말

지금까지 살펴본 것처럼 템플릿 메타 프로그래밍은 굉장히 강력한 도구지만 코드가 상당히 난해해질 위험도 있다. 또한 모든 작업을 컴파일 시간에 처리하기 때문에 문제가 발생해도 찾을 수 없다는 문제도 있다.

템플릿 메타 프로그래밍으로 코드를 작성할 때는 반드시 주석에 메타 프로그래밍을 사용하는 목적과 진행 과정을 명확히 밝히는 것이 좋다. 그렇지 않으면 다른 사람이 코드를 이해하기 굉장히 어려워진다. 심지어 본인도 나중에 다시 보면 무슨 뜻인지 알 수 없을 수도 있다.

전문가를 위한 C++/ 여러 가지 유틸리티 라이브러리

(전체가 아니라 C#과 차이가 있는 부분을 중심으로 요약 정리)

Ratio 라이브러리

ratio 라이브러리를 이용하면 유한 유리수(finite rational number)를 컴파일 시간에 정확히 표현할 수 있다. 유리수는 다음 절에서 설명할 std::chrono::duration 클래스에서 사용된다. 이 라이브러리에 관련된 내용은 모두 <ratio> 헤더 파일의 std 네임스페이스 아래에 정의돼 있다.

유리수를 구성하는 분자와 분모는 std::intmax_t 타입의 컴파일 상수로 표현한다. 이 타입은 부호 있는 저웃 타입으로 최댓값은 컴파일러마다 다르다. 여기서 제공하는 유리수는 컴파일 시간에 결정되기 때문에 다른 타입에 비해 사용법이 다소 복잡해 보일 수 있다.

ratio 객체를 정의하는 방식은 일반 객체와 다르다. 메서드를 호출할 수 없으며 타입 앨리어스처럼 사용해야 한다. 예컨대 1/60이라는 유리수를 컴파일 시간 상수로 선언하면 다음과 같다.

using r1 = ratio<1, 60>

r1 유리수의 분자와 분모는 컴파일 시간 상수이며 다음과 같이 접근한다.

intmax_t num = r1::num;
intmax_t den = r1::den;

다시 한 번 강조하면 ratio는 컴파일 시간 상수(compile-time constant)라서 분자와 분모가 컴파일 시간에 결정된다. 따라서 다음과 같이 작성하면 컴파일 에러가 발생한다.

intmax_t n = 1;
intmax_t d = 60;
using r1 = ratio<n, d>; // error

유리수는 항상 정규화(normalized, 약분)된 상태로 표현된다.  유리수 ratio<n, d>에 대해 최대공약수가 gcd일 때 분자 nm과 분모 den은 다음과 같이 결정된다.

  • num = sign(n) * sign(d) * abs(n) / gcd
  • den = abs(d) / gdc

ratio 라이브러리는 유리수의 덧셈, 뺄셈, 곱셈, 나눗셈을 지원한다. 이 연산도 모두 컴파일 시간에 처리된다. 그래서 표준 산술 연산을 적용할 수 없고, 타입 앨리어스를 이용한 특수 템플릿으로 처리해야 한다.

이러한 용도로 제공되는 산술 ratio 템플릿으로는 ratio_add, ratio_subtract, ratio_multiply, ratio_divide가 있다. 이 템플릿은 계산 결과를 새로운 ratio 타입으로 표현한다. 이 타입은 C++에 정의된 type이라는 타입 앨리어스로 접근한다.

예컨대 다음 코드는 1/60과 1/30에 대한 ratio 값을 정의한다. 그런 다음 ratio_add 템플릿으로 두 유리수를 더해서 result란 유리수를 구하는데, 그 값은 합한 결과를 약분한 1/20이다.

using r1 = ratio<1, 60>;
using r2 = ratio<1, 30>;
using result = ratio_add<r1, r2>::type;

C++ 표준은 ratio 비교 연산 템플릿(ratio_equal, ratio_not_equal, ratio_less, ratio_less_equal, ratio_greater, ratio_greater_equal)도 제공한다. 산술 ratio 템플릿과 마찬가지로 ratio 비교 연산 템플릿도 컴파일 시간에 처리된다.

이런 비교 연산 템플릿은 결과를 표현하는 std::bool_constant란 타입을 새로 정의한다. bool_constant는 타입과 컴파일 시간 상숫값을 저장하는 struct 템플릿인 std::integral_constant 중 하나다. 예컨대 integral_constant<int, 15>는 15라는 정숫값을 저장한다.

bool_constant는 bool 타입에 대한 integral_constant이다. 예컨대 bool_constant<true>는 true라는 부울 타입 값을 저장하는 integral_constant<bool, true>다. ratio 비교 연산 템플릿 결과는 bool_constant<true>나 bool_constant<false> 중 하나가 된다. bool_constant나 integral_constant에 대한 값은 value라는 데이터 멤버로 접근 할 수 있다. 

다음 예는 ratio_less를 사용하는 방법을 보여준다. boolalpha로 부울 값인 true나 false를 출력하는 방법은 13장에서 소개한다.

using r1 = ratio<1, 60>;
using r2 = ratio<1, 30>;
using result = ratio_less<r2, r1>;
cout << boolalpha << res::value << endl;

(이하 예시 생략)

chrono 라이브러리

chrono 라이브러리는 시간을 다루는 클래스로서 다음과 같은 요소로 구성돼 있다.

  • duration
  • clock
  • time_point

std::chrono 네임스페이스에 정의된 것을 사용하려면 반드시 <chrono> 헤더 파일을 인클루드 해야 한다.

duration

duration은 두 시점 사이의 시간 간격(interval)을 표현하는 클래스 템플릿으로서 틱(tick)과 틱 주기(tick period)에 대한 값을 저장한다. 틱 주기란 두 틱 사이의 초 단위 간격으로서 컴파일 시간 상수인 ratio로 표현한다. 따라서 초를 분수로 표현한 것이라고 볼 수 있다.

(이하 설명 생략)

clock

clock은 time_point와 duration으로 구성된 클래스다. 

C++ 표준은 clock을 세 가지 버전으로 정의한다. 첫 번째 버전은 system_clock으로서 시스템 관점의 실시간 클럭을 표현한다. 두 번째 버전은 steady_clock으로서 time_point가 절대로 감소하지 않도록 보장해준다. 참고로 system_clock은 언제든지 조정할 수 있기 때문에 time_point가 감소되지 않도록 보장해주지 않는다. 세 번째 버전은 high_resolution_clock으로서 최소 틱 주기를 가진다. 현재 사용하는 컴파일러의 종류에 따라 high_resolution_clock이 steady_clock이나 system_clock과 같을 수 있다.

각 버전의 clock마다 now()라는 static 메서드가 존재하는데, 이 메서드는 현재 시각을 time_point로 리턴한다.

(이하 설명 생략)

(전략) 이렇게 시간 간격이 짧을 때 정확도가 떨어지는 이유는 대다수의 OS가 밀리초 단위를 지원하기는 하지만 갱신 주기가 10ms나 15ms 정도로 다소 길기 때문이다. 그래서 타이머의 한 틱보다 짧은 단위로 발생한 이벤트가 0 단위 시간으로 보이고, 1에서 2틱 사이에 발생한 이벤트가 1 단위 시간으로 보이는 게이팅 에러(gating error)가 발생한다. 

예컨대 1ms 주기로 타이머를 갱신하는 시스템에서 44ms가 걸리는 루프의 실행시간은 30ms로 나온다. 이런 타이머로 시간을 측정할 때는 반드시 대상 연산의 실행 시간을 타이머의 최소 틱 단위보다 크게 구성해야 오차를 최소화할 수 있다.

time_point

teim_pont는 특정한 시점을 표현하는 클래스로서 에포크(epoch, 기준 시간)를 기준으로 측정한 duration으로 저장한다. time_point는 항상 특정한 clock을 기준으로 표현하는데, 이 clock의 시작 시간이 에포크가 된다.

예컨대 유닉스/리눅스 시간에 대한 에포크는 1970년 1월 1일이고, duration은 초 단위로 측정한다. 윈도우 시스템의 에포크는 1601년 1월 1일이고, duration을 100나노초 단위로 측정한다. 에포크와 duration은 OS마다 다를 수 있다.

(이하 설명 생략)

무작위수 생성

무작위수를 소프트웨어로 정확히 생성하기란 상당히 어렵다. C++ 11 이전에는 C 스타일 함수인 srand()와 rand() 만으로 무작위수를 생성할 수 밖에 없었다. 애플리케이션에서 srand() 함수를 한 번 호출한 뒤 무작위수 생성기를 시드(seed)로 초기화해야 했다. (이를 시딩(seeding)이라 부른다) 시드값은 주로 현시 시스템 시각을 사용한다.

Caution) 소프트웨어로 무작위수를 생성하려면 시드값을 잘 정해야 한다. 무작위수 생성기를 초기화할 때마다 같은 시드를 사용하면 매번 동일한 무작위수가 생성된다. 그래서 현재 시스템 시각을 시드값으로 많이 사용하는 것이다.

(예시 생략)

Note) 소프트웨어 기반 무작위수 생성기는 진정한 의미의 무작위수를 생성할 수 없다. 그래서 의사(pseudo) 무작위수 생성기라고도 부른다. 무작위인 것처럼 보이게 만든느 수학 공식에 따라 생성하기 때문이다.

기존 srand()와 rand() 함수는 유연성이 좀 떨어진다. 예컨대 무작위수의 분포를 변경할 수 없다. C++ 11부터 다양한 알고리즘과 분포로 무작위수를 생성하는 강력한 라이브러리가 추가됐다. 이 라이브러리는 <random> 헤더 파일에 정의돼 있으며, 크게 세 가지 구성 요소(엔진, 엔진 어댑터, 분포)로 구성된다. 

무작위수 엔진(engine)은 실제로 무작위수를 생성하고, 그 뒤에 생성될 무작위수의 상태를 저장한다. 무작위수 분포(distribution)는 생성된 무작위수의 범위와 그 범위 안에서 무작위수가 수학적으로 분포되는 방식을 결정한다. 무작위수 엔진 어댑터(engine adaptor)는 무작위수 엔진의 결과를 수정한다.

무작위수를 생성할 때 srand()와 rand()를 사용하지 말고 <random> 헤더 팡리에 정의된 클래스를 사용하기 바란다.

무작위수 엔진

<random>은 다음과 같은 무작위수 엔진을 제공한다.

  • random_device
  • linear_congruential_engine
  • mersenne_twister_engine
  • substract_with_carry_engine

random_device 엔진은 소프트웨어 기반이 아니다. 컴퓨터에 특수 하드웨어가 장착돼 있어야 쓸 수 있는 진정한 비결정적(non-deterministic, 결과를 예측할 수 없는) 무작위수 발생기다. 예컨대 일정한 시간 간격 동안 발생한 알파 입자의 수를 세는 방사성 동위 원소의 자연 붕괴 속도 측정 기법이있다. 컴퓨터에서 방사능이 누출될까봐 꺼림칙하다면 역바이어스 다이오드(reverse-biased diode)에서 발생하는 ‘노이즈’를 측정하는 방식처럼 다른 물리 법칙 기반의 무작위수 생성기를 사용해도 된다.

random_device의 규격을 보면 현재 사용하는 컴퓨터에 특수 하드웨어가 없을 때는 소프트웨어 알고리즘 중 아무거나 적용하도록 정의돼 있다. 어떤 알고리즘을 적용할지는 라이브러리 디자이너가 결정한다.

무작위수 발생기의 성능은 엔트로피(entropy)로 측정한다. random_device 클래스에서 제공하는 entropy() 메서드는 소프트웨어 기반 의사 무작위수 생성기를 사용할 때는 0.0을 리턴하고, 하드웨어 장치를 사용할 때는 0이 아닌 값을 리턴한다. 이때 리턴하는 0이 아닌 값은 장착된 디바이스의 엔트로피에 대한 측정치로 결정된다.

random_device 엔진의 사용법은 다소 간단하다.

random_device rnd;
cout << "Entropy: " << rnd.entropy() << endl;
cout << "Min value: " << rnd.min() << ", Max value: " << rnd.max() << endl;
cout << "Random number: " << rnd() << endl;

random_device는 대체로 의사 무작위수 생성 엔진보다 느리다. 그래서 생성해야 할 무작위수가 아주 많다면 의사 무작위수 생성 엔진을 사용하고 random_device는 이 엔진의 시드를 생성하는데만 사용하는 것이 좋다.

<random>은 random_device 외에 다음 세 가지 의사 무작위수 생성 엔진을 제공한다.

  • inear_congruential_engine(선형 합동 무작위수 엔진)
    • 상태 저장을 위한 메모리 사용량이 가장 적다. 여기서는 상태를 최종 생성된 무작위수를 포함한 정수 또는 아직 무작위수를 생성한 적이 없다면 초기 시드값을 담은 정수 하나로 표현한다. 이 엔진의 주기는 알고리즘에 대한 매개변수에 따라 다르며 최대 264까지 지정할 수 있지만 대체로 그보다 적은 수로 설정한다. 이러한 이유로 선형 합동 무작위수 엔진은 무작위 품질이 아주 높아야 할 때는 사용하지 않는 것이 좋다.
  • mersenne_twister_engine(메르센 트위스터 무작위수 엔진)
    • 소프트웨어 기반 무작위수 생성기 중에서 가장 품질이 좋다. 이 엔진의 주기는 알고리즘 매개변수에 따라 달라지지만 linear_congruential_engine보다 훨씬 길다. 상태 저장에 필요한 메모리 사용량도 이 매개변수에 따라 결정되지만 정수 하나로 표현하는 linear_congruential_engine보다 훨씬 크다. 예컨대 기본 정의된 mersenne_twister_engine mt19937의 주기는 219937 – 1이고, 상태를 2.5kbyte 가량 차지하는 625개의 정수로 표현한다. 이 엔진도 속도가 빠르다고 손꼽힌다.
  • subtract_with_carry_engine(감산 캐리/자리내림 무작위수 엔진)
    • 상태를 100byte 가량의 메모리에 저장하지만 무작위수의 생성 속도와 품질은 mersenne_twister_engine 보다 떨어진다.

random_device 엔진을 사용하는 방법은 간단하며 매개변수를 지정할 필요도 없다. 하짐나 앞서 소개한 세 가지 의사 무작위수 생성기 중 하나에 대한 인스턴스를 생성하려면 수학 매개변수를 지정해야 하는데, 이 값을 정하는 방법은 쉽지 않다. 어떤 매개변수를 지정하는가에 따라 생성된 무작위수의 품질이 크게 달라진다. 예컨대 mersenne_twister_engine 클래스는 매개변수를 다음과 같이 정의하고 있다.

template<class UIntType, size_t w, size_t n, size_t m, size_t r, UIntType a, size_t u, UIntType d, size_t s, UIntType b, size_t t, UintType c, size_t l, UIntType f>
class mersenne_twister_engine { ... }

매개변수가 무려 14개나 된다. linear_congruential_engine과 subtract_with_carry_engine 클래스도 수학 매개변수를 많이 지정해야 한다. 그래서 표준에서는 몇 가지 엔진으 미리 정의해서 제공한다. 그중 하나가 mt19937이라는 mersenne_twister_engine이다. 이 엔진은 다음과 같이 정의돼 있다.

using mt19937 = mersenne_twister_engine<uint_fast32_t, 32, 624, 397, 31, 0x9908b0df, 11, 0xffffffff, 7, 0x9d2c5680, 15, 0xefc60000, 18, 1812433253>;

매개변수의 의미를 제대로 이해하려면 메르센 트위스터 알고리즘을 깊이 이해해야 한다. 일반적으로 의사 무작위수 생성 기법을 전공한 수학자가 아니라면 이러한 매개변숫값을 변경할 일은 없다. 따라서 mt19937처럼 C++에서 제공하는 타입 앨리어스를 사용하기 바란다.

무작위수 엔진 어댑터

무작위수 엔진 어댑터는 무작위수 생성에 사용하는 엔진의 결과를 수정할 때 사용하며, 어댑터 패턴의 대표적인 예이기도 하다. C++ 라이브러리에 정의된 어댑터는 다음 세 가지다.

template<class Engine, size_t p, size_t r> class discard_block_engine { ... }
template<class Engine, size_t w, class UIntType> class independent_bits_engine { ... }
template<class Engine, size_t k> class suffle_order_engine { ... }

discard_block_engine 어댑터는 베이스 엔진에서 생성된 값 중에서 일부를 제거하는 방식으로 무작위수를 생성한다. 이 어댑터는 세 가지 매개변수를 받는데, 첫 번째는 연결할 엔진에 대한 것이고, 두 번째는 블록 크기인 p이고, 세 번째는 사용된 블록 크기인 r이다. 베이스 엔진은 p개의 무작위수를 생성하는데 사용된다. 그러면 어댑터는 p-r개의 무작위수를 제거하고, 나머지 r개의 무작위수만 리턴한다.

independent_bits_engine 어댑터는 w로 지정된 비트 수로부터 베이스 엔진이 생성한 여러가지 무작위수를 조합하는 방식으로 무작위수를 생성한다.

shuffle_order_engine 어댑터는 베이스 엔진과 똑같은 무작위수를 생성하지만 리턴 순서는 다르다. 

이러한 어댑터의 내부 작동 과정은 기반이 되는 수학 기법에 따라 다르다.

기본으로 제공하는 엔진과 엔진 어댑터

앞서 설명 했듯이 의사 무작위수 엔진과 엔진 어댑터의 매개변수는 건드리지 않는 것이 좋다. 그 대신 표준에서 정의해둔 엔진과 엔진 어댑터를 사용하는 것이 바람직하다. C++은 다음과 같은 엔진과 엔진 어댑터를 기본으로 제공한다. 모두 <random> 헤더 파일에 정의돼 있으며, 템플릿 인수가 복잡하게 지정돼 있다. 물론 이러한 인수의 의미를 몰라도 엔진과 엔진 어댑터를 사용하는데는 문제없다.

이름 템플릿
minstd_rand0 linear_congruential_engine
minstd_rand linear_congruential_engine
mt19937 mersenne_twister_engine
mt19937_64 mersenne_twister_engine
ranlux24_base subtract_with_carry_engine
ranlux48_base subtract_with_carry_engine
ranlux24 discard_block_engine
ranlux48 discard_block_engine
knuth_b shuffle_order_engine
default_random_engine 구현마다 다름

무작위수 생성하기

무작위수를 생성하기 전에 먼저 엔진 인스턴스부터 생성해야 한다. 소프트웨어 기반 엔진을 사용할 때는 분포도 지정해야 한다. 여기서 분포란 주어진 범위 안에서 숫자가 분포되는 방식을 표현하는 수학 공식이다. 추천하는 엔진 생성 방법은 앞서 소개한 기본 제공 엔진 중 하나를 그냥 사용하는 것이다.

다음 코드는 기본 제공 엔진이자 소프트웨어 기반 무작위수 생성기인 mt19937이란 메르센 트위스터 엔진을 사용하는 예를 보여준다. 기존 rand() 생성기를 사용할 떄와 마찬가지로 소프트웨어 기반 엔진도 시드로 초기화해야 한다. srand()에서는 주로 현재 시스템 시간을 시드로 사용했다. 최신 C++에서는 random_device로 시드를 생성하거나 random_device를 사용하기 힘든 상황이라면 차선책으로 시스템 시간에 기반한 시드를 사용하도록 권장한다.

random_device seeder;
const auto seed = seeder.entropy() ? seeder() : time(nullptr);
mt19937 eng(static_cast<mt19937::result_type>(seed));

다음으로 분포를 지정한다. 이 예제에서는 1부터 99사이의 범위에 대해 균등 정수 분포(uniform integer distribution)로 지정한다.

uniform_int_distributeion<int> dist(1, 99);

엔진과 분포를 정의했다면 분포에 대한 함수 호출 연산자에 엔진을 인수로 지정해서 호출한다. 그러면 무작위수가 생성된다. 예컨대 다음과 같다.

cout << dist(eng) << endl;

이 코드에서 볼 수 있듯이 소프트웨어 기반 엔진으로 무작위수를 생성하기 위해서는 항상 엔진과 분포를 지정해야 한다. 18장에서 소개한 <functional> 헤더에 정의된 std::bind() 유틸리티를 사용하면 엔진과 분포를 지정하지 않고도 무작위수를 생성할 수 있다. 다음 코드는 앞에서와 마찬가지로 mt19937 엔진과 균등 분포를 적용한 다음 std::bind()로 dist()의 첫 번째 매개변수를 eng로 바인딩해서 gen()을 정의한다. 이렇게 하면 무작위수를 생성할 때마다 인수를 지정하지 않고 gen()만 호출할 수 있다. 그런 다음 gen()을 generate() 알고리즘과 함께 조합해서 열 개의 무작위수로 구성된 vector를 만든다.

random_device seeder;
const auto seed = seeder.entropy() ? seeder() : time(nullptr);
mt19937 eng(static_cast<mt19937::result_type>(seed));
uniform_int_distribution<int> dist(1, 99);

auto gen = std::bind(dist, eng);

vector<int> vec(10);
generate(begin(vec), end(vec), gen);

for (auto i : vec) { cout << i << " "; }

get()의 타입을 정확히 몰라도 gen()을 무작위수 생성기를 사용하려는 다른 함수에 인수로 전달할 수 있다. 이때 두 가지 옵션이 있다. 하나는 std::function<int()> 타입으로 매개변수를 지정하는 것이고, 다른 하나는 함수 템플릿으로 지정하는 것이다. 앞의 예제를 fillVector() 함수에서 무작위수를 생성하는데 활용해도 된다. 이 코드는 std::function으로 구현했다.

void fillVector(vector<int>& vec, const std::function<int()>& generator)
{
generate(begin(vec), end(vec), generator);
}

함수 템플릿 버전은 다음과 같다.

template<typename T>
void fillVector(vector<int>& vec, const T& generator)
{
 generate(begin(vec), end(vec), generator);
}

이렇게 작성한 함수는 다음과 같이 사용할 수 있다.

random_device seeder;
const auto seed = seeder.entropy() ? seeder() : time(nullptr);
mt19937 eng(static_cast<mt19937::result_type>(seed));
uniform_int_distribution<int> dist(1, 99);

auto gen = std::bind(dist, eng);

vector<int> vec(10);
fillVector(vec, gen);

for (auto i : vec) { cout << i << " "; }

무작위수 분포

분포란 일정한 범위에서 숫자가 분포된 방식을 표현하는 수학 공식이다. 무작위수 발생기 라이브러리는 의사 무작위수 엔진에서 사용할 수 있도록 다음과 같이 다양하게 정의된 분포를 함께 제공한다.

(생략)

균등 메르센 트위스터 분포 예시

정규 분포 예시

optional

std::optional은 <optional>에 정의돼 있으며, 어떤 타입의 값이 있거나 없을 수 있는 것을 표현한다. optional은 함수의 매개변수를 옵션으로 지정할 때 사용한다. 또한 함수의 리턴 타입으로 지정해서 그 함수가 값을 리턴할 수도 있고, 리턴하지 않을 수도 있다는 것을 표현하기도 한다. 

optional을 이용하면 리턴값이 없다는 것을 nullptr, end(), -1, EOF 등과 같은 특수한 값으로 표현하지 않아도 된다. 

optional을 리턴하는 함수를 작성하는 예는 다음과 같다.

optional<int> getData(bool giveIt)
{
it (giveIt)
{
return 42;
}
return nullopt; // 또는 그냥 return {}; 이라고만 적는다.
}

이렇게 작성한 함수는 다음과 같이 호출한다.

auto data1 = getData(true);
auto data2 = getData(false);

optional에 값이 실제로 있는지 확인하려면 has_value() 메서드를 호출하거나 optional을 곧바로 if 문의 조건으로 사용하면 된다.

cout << "data1.has_value = " << data1.has_value() << endl;

if (data2)
{
cout << "data2 has a value" << endl;
}

optional에 값이 있다면 value() 메서드나 역참조 연산자로 값을 가져올 수 있다.

cout << "data1.value = " << data1.value() << endl;
cout << "data1.value = " << *data1 << endl;

값이 없는 optional에 value() 메서드를 호출하면 bad_optional_access 익셉션이 발생한다. value_or()을 호출하면 optional에 값이 있으면 그 값을 리턴하고, 값이 없으면 다른 값을 리턴한다.

cout << "data2.value = " << data2.value_or(0) << endl;

참고로 optional에 레퍼런스를 직접 저장할 수는 없다. 그래서 optional<T&>와 같이 적으면 작동하지 않는다. 레퍼런스를 담고 싶다면 optional<T*>, optional<reference_wrapper<T>> 또는 optional<reference_wrapper<const T>>로 표현한다. std::reference_wrapper<T>는 std::ref(), reference_wrapper<const T>는 cref()로 생성할 수 있다.

variant

std::variant는 주어진 타입 집합 중에서 어느 한 타입의 값을 가지며, <variant>에 정의돼 있다. variant를 정의하려면 여기에 담길 수 있는 타입들을 반드시 지정해야 한다. 예컨대 정수, 스트링, 부동소수점수 중 하나를 담을 수 있는 variant를 정의하려면 다음과 같이 정의한다.

variant<int, string, float> v;

이렇게 별도로 초기화하지 않고 선언된 variant는 디폴트값을 첫 번째 타입인 int로 설정한다. 이렇게 variant를 디폴트로 생성하려면 반드시 여기에 지정한 첫 번째 타입이 디폴트 생성을 지원해야 한다. 예컨대 다음 코드에서 Foo는 디폴트 생성할 수 없기 때문에 컴파일 에러가 발생한다.

class Foo { public: Foo() = delete; Foo(int) {} };
class Bar { public: Bar() = delete; Bar(int) {} };

int main()
{
variant<Foo, Bar> v;
}

이 코드에서 Foo와 Bar 모두 디폴트 생성할 수 없다. 그래도 디폴트 생성하고 싶다면 variant의 첫 번째 타입을 std::monostate로 지정ㅎ나다.

variant<monostate, Foo, Bar> v;

다음과 같이 대입 연산자를 이용하면 variant에 특정한 값을 저장할 수 있다.

variant<int, string, float> v;
v = 12;
v = 12.5f;
v = "An std::string"s;

variant는 언제나 값 하나만 가질 수 있다. 그래서 위 세 가지 대입문에서 첫 문장으로 인해 정수 12가 저장되지만 그 다음 문장에서 부동소수점수로 변경되고, 마지막에는 string으로 바뀐다.

variant에 현재 저장된 값의 타입에 대한 인덱스를 알고 싶다면 index()를 호출하면 된다. std::holds_alternative() 함수 템플릿을 이용하면 variant가 인수로 지정한 타입의 값을 담고 있는지 확인할 수 있다.

cout << "Type index: " v.index() << endl;
cout << "Contains an int: " << holds_alternative<int>(v) << endl;

std::get<index>()나 std::get<T>()를 이용하면 variant에 담긴 값을 가져올 수 있다. 이 함수를 호출할 때 가져오려는 값의 타입이나 인덱스를 잘못 지정하면 bad_variant_access 예외가 발생한다.

cout << std::get<string>(v) << endl;

try
{
cout << std::get<0>(v) << endl;
}
catch (const bad_variant_access& ex)
{
cout << "Exception: " << ex.what() << endl;
}

이 익셉션이 발생하지 않게 하려면 std::get_if<index>()나 std::get_if<T>() 헬퍼 함수를 사용한다. 이 함수는 variant에 대한 포인터를 인수로 받아서 요청한 값에 대한 포인터를 리턴한다. 에러가 발생하면 nullptr를 리턴한다.

string* theString = std::get_if<string>(&v);
int* theInt = std::get_if(int>(&v);
cout << "retrieved string: " << (theString ? *theString : "null") << endl;
cout << "retrieved int: " << (theInt ? *theInt : 0) << endl;

std::visit() 헬퍼 함수도 있는데, variant에 대한 비지터(방문자) 패턴을 적용할 떄 사용한다. 예컨대 다음과 같이 클래스에 함수 호출 연산자가 다양한 버전으로 오버로딩 돼 있을 때 각 타입을 variant로 표현할 수 있다.

class MyVisitor
{
public:
void operator()(int i) { cout << "int " << i << endl; }
void operator()(const string& s) { cout << "string " << s << endl; }
void operator()(float f) { cout << "float " << f << endl; }
};

이렇게 정의된 클래스를 다음과 같이 std::visit() 로 호출할 수 있다.

visit(MyVisitor(), v);

이렇게 하면 오버로딩된 함수 호출 연산자 중에서 현재 variant에 저장된 값에 적합한 것이 호출된다.

optional과 마찬가지로 variant에도 레퍼런스를 직접 저장할 수 없다. 포인터를 저장하거나 reference_wrapper<T> 또는 reference_wrapper<const T>의 인스턴스로 저장해야 한다.

any

std::any는 모든 타입의 값을 저장하는 클래스다. any 인스턴스를 생성했다면 이 인스턴스에 대해 값이 있는지 있다면 타입은 뭔지 조회할 수 있다. any에 담긴 값을 구하려면 any_cast()를 사용해야 한다. 오류가 발생하면 bad_any_cast 익셉션이 발생한다.

(이하 설명 생략)

tuple

std::tuple은 std::pair를 일반화한 클래스이다. tuple은 여러 수를 하나로 묶어서 저장할 수 있고, 각각의 타입도 따로 지정할 수 있다. pair와 마찬가지로 tuple도 크기와 값의 타입이 고정돼 있으며 컴파일 시간에 결정된다.

tuple은 tuple 생성자로 만든다. 이때 템플릿 타입과 실젯값을 모두 지정한다.

using MyTuple = tuple<int, string, bool>;
MyTuple t1(16, "Test", true);

std::get<i>()는 tuple의 i번째 원소를 가져온다.

cout << "t1 = (" << get<0>(t1) << ", " << get<1>(t1) << ", " << get<2>(t1) << ")" << endl;

get<i>()가 정확한 타입을 리턴했는지 확인하려면 <typeinfo>에 정의된 typeid()를 호출하면 된다.

cout << "Type of get<1>(t1) = " << typeid(get<1>(t1)).name() << endl;

또한 튜플의 원소를 가져올 때 인덱스를 지정하지 않고 std::get<T>()에 조회할 원소의 타입(T)을 지정하는 방식으로 튜플의 원소를 가져올 수도 있다. 이때 요청한 타입으로 된 원소가 여러 개라면 컴파일 에러가 발생한다.

아쉽게도 tuple에 담긴 값에 대해 반복하기는 쉽지 않다. 루프문 안에서 get<i>(mytuple)만 호출하는 식으로 간단히 구현할 수 없다. i의 값이 컴팡리 시간에 확정돼야 하기 때문이다. 이럴 때는 템플릿 메타 프로그래밍으로 구현하면 된다. 이에 대해서는 22장에 소개한다.

tuple의 크기는 std::tuple_size 템플릿으로 알아낼 수 있다.

cout << "Tuple size = " << tuple_size<MyTuple>::value << endl;

tuple의 타입을 정확히 모르면 decltype()로 알아낼 수 있다.

cout << "Tuple size = " << tuple_size<decltype(t1)>::value << endl;

C++ 17부터 생성자에서 템플릿 인수를 추론하는 기능이 추가됐다. 그래서 tuple을 생성할 때 템플릿 타입 매개변수를 생략하면 생성자에 전달된 인수의 타입을 컴파일러가 알아낸다. 예컨대 다음과 같이 작성해도 t1이라는 tuple을 앞서와 마찬가지로 정수와 string과 부울 타입으로 구성하도록 정의할 수 있다.

std::tuple t1(16, "Test"s, true);

타입 추론 기능이 적용되기 때문에 &로 레퍼런스를 지정할 수 없다. 레퍼런스나 const 레퍼런스를 담은 tuple을 템플릿 인수 추론 기능을 적용해서 생성하려면 다음 코드와 같이 ref()나 cref()를 사용해야 한다. 

double d = 3.14;
string str1 = "Test";
std::tuple t2(16, ref(d), cref(d), ref(str1));

(이하 생략)

C++ 17에서 제공하는 템플릿 인수 추론 기능을 사용하지 않고도 std::make_tuple()이란 유틸리티 함수로 tuple을 생성할 수 있다. 이 헬퍼 함수 템플릿도 실젯값을 지정하는 방식으로 tuple을 생성할 수 있다. 타입은 컴파일 시간에 자동으로 결정된다.

auto t2 = std::make_tuple(16, ref(d), cref(d), ref(str1));

tuple 분리하기

tuple을 개별 원소로 분리하는 방법은 두 가지다. 하나는 C++ 17부터 추가된 구조적 바인딩을 사용하는 것이고, 다른 하나는 std::tie()을 이용하는 것이다.

구조적 바인딩

구조적 바인딩을 이용하면 tuple을 개별 원소에 대한 변수로 쉽게 분리할 수 있다.

tuple t1(16, "Test"s, true);
auto[i, str, b] = t1;

구조적 바인딩으로 tuple을 분리할 때는 개별 원소를 생략할 수 없다. tuple에 담긴 원소가 세 개라면 구조적 바인딩에 지정하는 변수도 세 개여야 한다. 원소를 생략하고 싶다면 tie()를 사용한다.

tie

구조적 바인딩을 적용하지 않고 tuple을 분리하려면 std::tie()라는 유틸리티 함수를 활용하면 된다. 이 함수는 레퍼런스로 구성된 tuple을 생성한다. 

tuple<int, string, bool> t1(16, "Test", true);
int i = 0;
string str;
bool b = false;
tie(i, str, b) = t1;

tie()를 이용하면 분리하고 싶지 않은 원소를 생략할 수 있다. 함수를 호출할 때 분리하고 싶지 않은 원소의 자리에 std::ignore 값을 적으면 된다.

tuple<int, string, bool> t1(16, "Test", true);
int i = 0;
string str;
bool b = false;
tie(i, std::ignore, b) = t1;

연결

std::tuple_cat()을 이용하면 두 tuple을 하나로 연결할 수 있다.

tuple<int, string, bool> t1(16, "Test", true);
tuple<double, string> t2(3.14, "string 2");
auto t3 = tuple_cat(t1, t2);

비교

튜플은 ==, !=, <, >, <=, >= 같은 비교 연산자도 제공한다. 이러한 비교 연산자로 tuple 끼리 비교하려면 그 tuple에 있는 원소의 타입도 이 연산을 지원해야 한다.

tuple<int, string> t1(123, "def");
tuple<int, string> t2(123, "abc");

if (t1 < t2)
{
cout << "t1 < t2" << endl;
}
else
{
cout << "t1 >= t2" << endl;
}

// 실행 결과
// t1 >= t2

make_from_tuple

std::make_from_tuple<T>는 T 타입의 생성자에 tuple 원소를 인수로 전달해서 T 객체를 만든다. 다음 클래스를 보자.

class Foo
{
public:
Foo(string str, int i) : mStr(str), mInt(i) { }
private:
string mStr;
int mInt;
};

이때 make_from_tuple()을 다음과 같이 호출할 수 있다.

auto myTuple = make_tuple("Hello world", 42);
auto foo = make_from_tuple<Foo>(myTuple);

make_from_tuple()에 전달하는 인수가 반드시 tuple일 필요는 없지만, 최소한 std::get<>()과 std::tuple_size를 지원해야 한다. 이를 만족하는 예로 std::array와 std::pair가 있다.

이 함수를 실제로 사용할 일은 많지 않지만, 템플릿을 이용한 메타 프로그래밍을 하면서 코드를 범용적으로 구성할 때 굉장히 유용하다.

apply

std::apply()는 호출 가능 개체(예: 함수, 람다 표현식, 함수 객체 등)를 호출하는데, 이때 지정한 tuple의 원소를 인수로 전달한다.

int add(int a, int b) { return a + b; }
...
cout << apply(add, std::make_tuple(39, 3)) << endl;

make_from_tuple()과 마찬가지로 이 함수도 실전에서 자주 사용하지 않고 템플릿 메타 프로그래밍할 때 제네릭 코드를 구현하는데 유용하다.

파일시스템 지원 라이브러리

path

파일시스템 지원 라이브러리의 기본 구성요소는 경로를 표현하는 path다. path는 절대 경로와 상대 경로를 표현할 수 있으며, 파일 이름이 포함될 수도 있고 빠질 수도 있다. 예컨대 다음 코드는 다양한 path 생성 예를 보여준다. 

path p1(LR"D:\Foo\Bar)");
path p2(L"D:/Foo/Bar");
path p3(L"D:/Foo/Bar/MyFile.txt");
path p4(LR"(..\SomeFolder)");
path p5(L"/usr/lib/X11");

예컨대 path를 string으로 변환하거나(c_str() 메서드를 사용해서) 스트림에 추가하면 이 코드를 실행하는 시스템의 네이티브 포맷으로 변환된다.

(예시 생략)

append() 메서드나 operator/=를 이용하면 path에 다른 항목을 추가할 수 있다. 이때 경로 구분자가 자동으로 추가된다. 예컨대 다음과 같다.

path p(L"D:\\Foo");
p.append("Bar");
p /= "Bar";
cout << p << endl;

concat()이나 operator+=를 사용하면 path에 다른 스트링을 연결할 수 있다. 그런데 이번에는 append()와 달리 경로 구분자가 추가되지 않는다.

path p(L"D:\\Foo");
p.concat("Bar");
p += "Bar";
cout << p << endl;

path는 경로의 구성 요소에 대해 반복문을 수행할 수 있도록 반복자도 제공한다. 예컨대 다음과 같다.

path p(LR"(C:\Foo\Bar)");
for (const auto& component : p)
{
cout << component << endl;
}

path 인터페이스는 remove_filename(), replace_filename(), replace_extension(), root_name(), parent_path(), extension(), has_extension(), is_absolute(), is_relative() 등과 같은 연산도 제공한다.

directory_entry

path는 파일시스템에 존재하는 디렉터라니 파일을 표현하기만 한다. 그래서 path가 표현하는 디렉터리나 파일이 실제로는 시스템에 없을 수 있다. 파일시스템에 디렉터리나 파일이 실제로 존재하는지 확인하려면 path로부터 directory_entry를 생성해야 한다. 인수로 지정한 디렉터리나 파일이 시스템에 존재하지 않으면 directory_entry가 생성되지 않는다.

directory_entry 인터페이스는 is_directory(), is_regular_file(), is_socket(), is_symlink(), file_size, last_write_time()을 비롯한 다양한 연산을 제공한다.

다음 코드는 파일 크기를 조회하기 위해 path에서 directory_entry를 생성하는 예를 보여준다.

path myPath(L"c:/windows/win/ini");
directory_entry dirEntry(myPath);
if (dirEntry.exists() && dirEntry.is_regular_file())
{
cout << "File size: " << dirEntry.file_size() << endl;
}

헬퍼 함수

헬퍼 함수는 다양하게 제공된다. 예컨대 파일이나 디렉터리를 복제하는 copy(), 파일시스템에 디렉터리를 새로 만드는 create_directory(), 주어진 디렉터리나 파일이 실제로 존재하는지 조회하는 exists(), 파일의 크기를 알아내는 file_size(), 파일의 최종 수정 시각을 알아내는 last_write_time(), 파일을 삭제하는 remove(), 임시 파일을 저장하기 위한 디렉터리를 구하는 temp_directory_path(), 파일시스템의 여유 공간을 조회하는 space() 등이있다.

(이하 생략)

directory_iterator와 recursive_directory_iterator

주어진 디렉터리에 속한 파일이나 하위 디렉터리(서브디렉터리)에 대해 재귀적으로 반복하는 코드를 작성하려면 다음과 같이 recursive_directory_iterator를 사용하면 된다.

void processPath(const path& p)
{
if (!exists(p))
{
return;
}

auto begin = recursive_directory_iterator(p);
auto end = recursive_directory_iterator();

for (auto iter = begin; iter != end; ++iter)
{
const string spacer(iter.depth() * 2, ' ');
auto& entry = *iter;

if (is_regular_file(entry))
{
cout << spacer << "File: " << entry;
cout << " (" << file_size(entry) << " bytes)" << endl;
}
else if (is_directory(entry))
{
std::cout << spacer << "Dir: " << entry << endl;
}
}
}

directory_iterator로도 디렉터리의 내용을 기준으로 반복문을 작성할 수 있다. 이때 재귀 호출 부분은 직접 구현한다. 예컨대 앞서 나온 코드와 똑같이 작동하는 코드를 recursive_directory_iterator 대신 directory_iterator를 사용해서 구현하면 다음과 같다.

void processPath(const path& p, size_t level = 0)
{
if (!exists(p))
{
return;
}

const string spacer(level * 2, ' ');

if (is_regular_file(p))
{
cout << spacer << "File: " << p;
cout << " (" << file_size(p) << " bytes)" << endl;
}
else if (is_directory(p))
{
std::cout << spacer << "Dir: " << p << endl;

for (auto& entry : directory_iterator(p))
{
processPath(entry, level + 1);
}
}
}

전문가를 위한 C++/ 스트링 현지화와 정규표현식

(전체가 아니라 C#과 차이가 있는 부분을 중심으로 요약 정리)

현지화

C나 C++ 프로그래밍을 배울 때 각각의 문자를 아스키(ASCII) 코드로 표현하는 바이트로 취급했다. 아스크 코드는 7비트로 구성됐으며 주로 8비트 char 타입으로 표현한다. 

정말 좋은 프로그램은 전 세계적으로 사용되지만, 당장 전 세계 사용자를 대상으로 삼지 않더라도 나중에 현지와를 지원하거나 다양한 로케일을 인식할 수 있도록 디자인하는 것이 좋다.

스트링 리터럴 현지화하기

현지화에서 가장 중요한 원칙 중 하나는 소스 코드에 특정 언어로 된 스트링을 절대로 넣으면 안 된다는 것이다. 단 개발 과정에서 임시로 사용하는 디버그 스트링은 예외다.

MS는 윈도우 애플리케이션을 개발하는 환경에서는 현지화에 관련된 스트링을 STRINGTABLE이란 리소스로 따로 빼둔다. 다른 플랫폼도 이러한 장치가 마련돼 있다. 그래서 애플리케이션을 다른 언어에 맞게 변환할 때 다른 코드를 건드릴 필요 없이 이 리소스에 담긴 내용만 번역하면 된다. 그리고 이러한 번역 작업을 도와주는 도구도 많이 나와 있다.

현지화 할 수 있도록 소스 코드를 구성하려면 스트링 리터럴을 조합하는 문장으로 만들면 안된다. 설령 각 스트링을 현지화할 수 있더라도 말이다. 예컨대 다음과 같다.

cout << "Read " << n << " bytes" << endl;

문장을 이렇게 구성하면 네덜란드어처럼 어순이 전혀 다른 언어로 현지화하기 힘들다. 이 스트링을 제대로 현지화할 수 있게 구성하려면 다음과 같이 작성한다.

cout << Format(IDS_TRANSFERRED, n) << endl;

여기서 IDS_TRANSFERRED는 스트링 리소스 테이블에 담긴 항목 중 하나다. 

와이드 문자

모든 언어가 한 문자를 1바이트에 담을 수 있는 것은 아니다. C++은 wchar_t라는 와이드 문자(확장 문자) 타입을 기본으로 제공한다. 한국어나 아랍어처럼 아스키 문자를 사용하지 않는 언어는 C++에서 wchar_t 타입으로 표현하면 된다.

하지만 C++ 표준은 wchar_t의 크기를 명확히 정의하지 않고 있다. 어떤 컴파일러는 16비트를 사용하는 반면 다른 컴파일러는 32비트로 처리하기도 하낟. 그래서 크로스 플랫폼을 지원하도록 코드를 작성하려면 wchar_t의 크기가 일정하다고 가정하면 위험하다.

영어권이 아닌 사용자를 대상으로 하는 프로그램을 작성한다면 처음부터 wchar_t 타입으로 작성하는 것이 좋다. 스트링이나 문자 리터럴을 wchar_t 타입으로 지정하려면 리터럴 앞에 L을 붙이면 된다. 그러면 와이드 문자 인코딩을 적용한다. 예컨대 wchar_t 타입의 값을 m이란 문자로 초기화하려면 다음과 같이 작성한다.

wchar_t myWideCharacter = L'm';

흔히 사용하는 타입이나 클래스마다 와이드 문자 버전이 존재한다. string 클래스의 와이드 문자 버전은 wstring이다. 스트림에 대해서도 w라는 접두어를 이용한 명명 규칙이 적용된다. 예컨대 와이드 문자 버전의 파일 출력 스트림으로 wofstream이 있고 입력 스트립은 wifstream이다. 또한 현지화를 지원하도록 스트링이나 스트림을 w가 붙은 버전으로 대체해서 코드를 작성하는 작업도 나름 재미가 쏠쏠하다.

cout, cin, cerr, clog도 각각 wcout, wcin, wcerr, wclog라는 와이드 문자 버전이 있다. 사용법은 일반 버전과 같다.

서구권이 아닌 문자 집합

와이드 문자를 도입한 것만으로도 현지화에 큰 도움이 된다. 한 문자에 필요한 공간의 크기를 원하는 대로 정할 수 있기 때문이다. 문자가 차지하는 공간을 결정했다면 아스키 코드처럼 각 문자를 코드 포인트(code point)라 부르는 숫자 형태로 표현할 수 있다.

이렇게 직접 정의한 문자 집합이 아스키 코드와 다른 점은 8비트로 제한되지 않는다는 것뿐이다. 이때 프로그래머의 모국어를 포함한 다른 언어를 포괄해야 하기 때문에 문자와 코드 포인트 사이의 대응 관계는 얼마든지 달라질 수 있다.

유니버설 문자 집합(Universal Character Set, UCS)은 ISO 10646이라는 국제 표준이고, 유니코드도 국제 표준 문자 집합이다. 둘 다 십만 개 이상의 문자를 담고 있으며, 각 문자마다 고유 이름과 코드 포인트가 정해져 있다. 두 표준에서 서로 겹치는 문자와 코드 포인트도 있고, 두 표준 모두 인코딩(encoding) 방식을 따로 정해두고 있다. 

예컨대 유니코드는 한 문자를 1-4개의 8비트로 인코딩하는 UTF-8 방식, 한 문자를 1-2개의 16비트값으로 인코딩하는 UTF-16 방식, 유니코드 문자를 정확히 32비트로 인코딩하는 UTF-32 방식이 있다.

애플리케이션마다 사용하는 인코딩 방식이 얼마든지 다를 수 있다. 아쉽게도 C++ 표준은 와이드 문자의 크기를 명확히 정해두지 않았다. 윈도우 환경에서는 16비트인 반면 32비트로 표현하는 플랫폼도 있다. 따라서 와이드 문자로 인코딩하는 프로그램에서 크로스 플랫폼을 지원하게 하려면 이러한 점을 반드시 인지해야 한다. 참고로 C++에서 제공하는 char16_t와 char32_t를 이용하면 이런 문제에 대처하는데 도움이 된다. 현재 지원하는 문자 타입을 정리하면 다음과 같다.

  • char: 8비트 값을 담는다. 주로 아스키 문자나 한 문자를 1-4개의 char로 인코딩하는 UTF-8 방식의 유니코드 문자를 저장하는데 사용된다.
  • char16_t: 최소 16비트 값을 담는다. 이 타입은 문자 하나를 1-2개의 char16_t로 인코딩하는 UTF-16 방식의 유니코드 문자를 저장하는 기본 타입으로 사용된다.
  • char32_t: 최소 32비트 값을 담는다. 이 타입은 UTF-32로 인코딩된 유니코드 문자를 하나의 char32_t로 저장하는데 사용된다.
  • wchar_t: 와이드 문자를 표현하는 타입으로 구체적인 크기와 인코딩 방식은 컴파일러마다 다르다.

wchar_t에 비해 char16_t나 char32_t가 좋은 점은 문자가 차지하는 크기를 컴파일러에 관계 없이 항상 최소 16비트 또는 32비트로 보장할 수 있다는 것이다. 반면 wchar_t에는 이러한 최소 기준이 없다.

C++ 표준은 다음과 같은 매크로도 정의하고 있다.

  • __STDC_UTF_32__: 컴파일러에서 지원한다면 char32_t는 UTF-32 인코딩을 적용하고, 그렇지 않으면 char32_t에 적용되는 인코딩 방식은 컴파일러에 따라 달라진다.
  • __STDC_UTF_16__: 컴파일러에서 지원한다면 char16_t는 UTF-16 인코딩을 적용하고, 그렇지 않으면 char16_t에 적용되는 인코딩 방식은 컴파일러에 따라 달라진다.

스트링 리터럴 앞에 특정한 접두어를 붙여서 타입을 지정할 수 있다. C++에서 제공하는 스트링 접두어는 다음과 같다.

  • u8: UTF-8 인코딩을 적용한 char 스트링
  • u: char16_t 스트링 리터럴을 표현하며, 컴파일러에 __STDC_UTF_16__이 정의돼 있으면 UTF-16을 적용한다.
  • U: char32_t 스트링 리터럴을 표현하며, 컴파일러에 __STDC_UTF_32__이 정의돼 있으면 UTF-32을 적용한다.
  • L: wchar_t 스트링 리터럴을 표현하며, 인코딩 방식은 컴파일러마다 다르다.

이러한 스트링 리터럴은 모두 일반 스트링 리터럴 접두어인 R과 조합할 수 있다. 예컨대 다음과 같다.

const char* s1 = u8R"Raw UTF-8 encoded string literal)";
const wchar_t* s2 = LR"Raw wide string literal)";
const char16_t* s3 = uR"Raw char16_t string literal)";
const char32_t* s4 = UR"Raw char32_t string literal)";

만일 스트링 리터럴을 UTF-8 유니코드 인코딩 방식(u8)으로 표현하거나 현재 컴파일러에 __STDC_UTF_16__이나 __STDC_UTF_32__가 정의돼 있다면 \uABCD 표기법을 사용해서 코드 포인트를 표현하는 방식으로 원하는 유니코드 문자를 스트링 리터럴에 추가할 수 있다.

예컨대 \u03C0은 파이(π)를 표현하고 \u00B2는 제곱(2)을 표현한다. 그래서 πr2 이란 수식을 다음과 같이 표현할 수 있다.

const char* formula = u8"\u03C0 r\u00B2";

마찬가지로 문자 리터럴 앞에도 이러한 접두어를 붙여서 타입을 구체적으로 지정하 룻 있다. C++은 문자 리터럴에 대해 u, U, L 뿐만 아니라 C++ 17부터 u8 접두어도 지원한다. 예컨대 다음과 같다.

u'a', U'a', L'a', u8'a'

C++은 std::string 클래스 외에 wstring, u16string, u32string도 제공한다. 각각 다음과 같이 정의돼 있다.

  • using string = basic_string<char>;
  • using wstring = basic_string<wchar_t>;
  • using u16string = basic_string<char16_t>;
  • using u32string = basic_string<char32_t>;

멀티바이트 문자(multibyte character)란 여러 바이트로 구성된 문자로서 인코딩 방식은 컴파일러마다 다를 수 있다. 마치 유니코드를 UTF-8을 사용하여 1-4개의 8비트로 표현하거나, UTF-16을 사용하여 1-2개의 16비트 값으로 표현하는 방식과 비슷하다. char16_t/char32_t와 멀티바이트 문자를 서로 변환하는 mbrtoc16, c16rtomb, mbrtoc32, c32rtomb 등의 변환 함수도 제공된다.

하지만 아쉽게도 char16_t와 char32_t에 대한 지원은 여기까지다. 다음 절에 소개하는 변환 클래스가 몇 가지 더 있지만 cout이나 cin에 대해 char16_t나 char32_t를 지원하는 버전은 없다. 그래서 char16_t나 char32_t 타입으로 된 스트링을 콘솔에 출력하거나 사용자로부터 입력 받기 상당히 까다롭다.

char16_t나 char32_t에 대한 고급 기능을 원한다면 서드파티 라이브러리를 찾아보는 수밖에 없다. 참고로 유니코드와 세계화(globalization)를 지원하는 대표적인 라이브러리로 ICU(International Components for Unicode)가 있다.

변환

C++ 표준에서는 다양한 방식으로 인코딩된 문자를 쉽게 변환하도록 codevt라는 클래스 템플릿을 제공한다. <locale> 헤더 파일을 보면 다음과 같은 네 가지 인코딩 변한 클래스가 정의돼 있다.

클래스 설명
codecvt<char, char, mbstate_t> 항등 변환. 즉 같은 것끼리 변환해서 실질적으로 변환이 일어나지 않는다.
codecvt<char16_t, char, mbstate_t> UTF-16과 UTF-8을 변환한다.
codecvt<char32_t, char, mbstate_t> UTF-32와 UTF-8을 변환한다.
codecvt<wchar_t, char, mbstate_t> (구현마다 달리 정의한) 와이드 문자와 내로우 문자 인코딩을 변환한다.

C++ 17 이전 버전에서는 <codecvt> 헤더 파일에 codecvt_utf8, codecvt_utf16, codecvt_utf8_utf16이라는 세 가지 코드 변환 패싯(facet)이 정의돼 있었다. 이러한 패싯은 두 가지 변환 인터페이스(wstring_convert와 wbuffer_convert)와 함께 사용했다.

그런데 C++ 17부터 이러한 세 가지 변환 패싯을 비롯하여 <codecvt>라는 헤더 파일 전체와 두 가지 변환 인터페이스가 폐기 됐다. 따라서 이 책에서는 자세히 설명하지 않는다. C++ 표준 위원회는 이러한 것들이 에러 처리에 불리하다는 이유로 폐기하도록 결정했다. 잘못된 유니코드 스트링으로 인해 보안 위험이 발생할 수 있으며, 실제로 시스템 보안 침투를 위한 공격 벡터로 활용된 사례도 있었기 때문이다.

또한 API 마저도 복잡하고 이해하기 힘들게 구성돼 있다. 따라서 C++ 표준 위원회에서 보다 적합하고 안전하고 사용하기 쉬운 대안을 마련할 때까지는 ICU와 같은 서드파티 라이브러리를 사용하길 추천한다.

로케일과 패싯

문자 집합은 나라마다 데이터를 표현하는 방식이 다른 여러 가지 요소 중 한 예에 불과하다. 영국과 미국처럼 사용하는 문자가 비슷한 나라마저도 날짜와 화폐를 표현하는 방식이 다르다.

이러한 특정한 데이터를 문화적 배경에 따라 그룹으로 묶는 방식을 C++에서는 로케일이라 부른다. 로케일은 날짜 포맷, 시간 포맷, 숫자 포맷 등으로 구성되는데, 이러한 요소를 패싯이라 부른다. 

로케일의 예로 U.S. English가 있고, 패싯의 예로 날짜 포맷이 있다. C++는 그 밖에 다양한 패싯을 기본으로 제공할 뿐만 아니라 이를 커스터마이즈하거나 새로운 패싯을 추가하는 기능도 제공한다.

로케일을 쉽게 다루게 해주는 서드파티 라이브러리도 많이 나와 이싿. 대표적인 예가 boost.locale이다. 이 라이브러리는 ICU를 기반으로 구축한 것으로 대조(collation)와 변환(conversion)을 지원하고, 스트링 전체를 한 번에 대문자로 변환하는 기능도 제공한다.

로케일 사용법

I/O 스트림을 사용할 때는 데이터의 포맷을 특정한 로케일에 맞춘다. 로케일은 스트림에 붙일 수 있는 객체로서 <locale> 헤더 파일에 정의돼 있다. 로케일 이름은 구현마다 다르다. POSIX 표준에서는 언어와 지역을 두 글자로 표현하고 그 뒤에 옵션으로 인코딩 방식을 붙여서 표현한다. 

예컨대 영어권 중에서 미국에 대한 로케일은 en_US로 표현하고, 영국에 대해서는 en_GB로 표기한다. 한국어는 ko_KR에 유닉스 확장 완성형 인코딩은 euc_KR로 UTF-8은 utf8을 옵션으로 붙여서 표기한다.

윈도우 환경에서는 두 가지 포맷으로 로케일 이름을 표현한다. 바람직한 첫 번째 방식은 POSIX 포맷과 비슷한데, 언더스코어 대신 대시를 사용한다는 점이 다르다. 두 번째 포맷은 예전 방식으로 다음과 같이 표현한다.

lang[_country_region[.code_page]]

대괄호 사이에 나온 부분은 모두 옵션이다. 몇 가지 예를 제시하면 다음 표와 같다.

  POSIX 윈도우 예전 윈도우
미국식 영어 en_US en-US English_United States
영국식 영어 en_GB en-GB English_Grate Britain

대부분의 OS는 사용자가 로케일을 지정하는 메커니즘을 제공한다. C++에서는 std::locale의 객체 생성자에 공백 스트링을 인수로 전달하면 사용자 환경에 저으이된 locale로 생성한다.

이 객체를 생성한 뒤부터는 locale 정보를 조회할 수 있고, 그 결과에 따라 코드를 작성할 수 있다. 다음 코드는 스트림의 imbue() 메서드를 이용하여 사용자 환경에 지정된 로케일을 사용하도록 설정하는 예를 보여준다. 이 코드를 실행하면 wcout에 보낸 데이터가 모두 시스템에 설정된 포맷으로 표현된다.

wcout.imbue(locale(""));
wcout << 32767 << endl;

이렇게 하면 현재 시스템에 설정된 로케일이 미국식 영어일 때 32,767로 출력되고 네덜란드 벨기에로 지정돼 있다면 32.767로 출력된다.

디폴트 로케일은 사용자 로케일이 아니라 클래식/뉴트럴(classic/neutral) 로케일이다. 클래식 로케일은 ANSI C 관례를 따르며 C로 표기한다. 이러한 클래식 로케일 C는 미국식 영어에 대한 로케일과 거의 같지만 약간 차이가 있다. 예컨대 숫자에 구두점을 붙이지 않는다.

wcout.imbue(locale("C"));
wcout << 32767 << endl;

이 코드를 실행하면 구두점이 없는 숫자가 출력된다.

다음 코드는 미국식 영어에 대한 로케일을 명시적으로 지정했다. 그래서 32767이란 숫자가 시스템 로케일과 별개로 미국식 표기법을 적용하여 쉼표를 붙여서 출력된다.

wcout.imbue(locale("en-US"));
wcout << 32767 << endl;

locale 객체로부터 로케일 정보를 조회할 수 있다. 예컨대 다음 코드는 사용자 시스템에 설정된 로케일에 대한 locale 객체를 생성한다. name() 메서드를 이용하면 이 로케일을 표현하는 C++ string을 구할 수 있다. 이렇게 구한 string 객체에 대해 find() 메서드를 호출하면 인수로 지정한 서브스트링을 검색할 수 있다. 지정한 서브스트링을 찾지 못하면 string::npos를 리턴한다. 이 코드는 윈도우 이름과 POSIX 이름을 모두 검사한다. 현재 로케일이 미국 영어인지 여부에 따라 둘 중 한 메시지가 출력된다.

locale loc("");

if (loc.name().find("en_US") == string::npos && loc.name().find("en-US") == string::npos)
{
wcout << L"Welcome non-U.S. English speaker!" << endl;
}
else
{
wcout << L"Welcome U.S. English speaker!" << endl;
}

Note) 나중에 프로그램에서 다시 읽을 데이터를 파일에 쓸 때는 클래식 로케일 “C”를 지정하는 것이 좋다. 그렇지 않으면 파싱하기 힘들어진다. 반면 사용자 인터페이스에 데이터를 출력할 때는 사용자 로케일 ” “를 지정하는 것이 좋다.

문자 분류

<locale> 헤더 파일을 보면 std::isspace(), isblank(), iscntrl(), isupper(), islower(), isalpha(), isdigit(), ispunct(), isxdigit(), isalnum(), isprint(), isgraph() 등과 같은 문자 분류 함수가 정의돼 있다. 이들 함수는 두 개의 매개변수를 받는다. 사용자 환경에 대한 로케일을 적용해서 isupper()을 호출하는 예는 다음과 같다.

bool result = isupper('A', locale(""));

문자 변환

<locale> 헤더에는 std::toupper()와 std::tolower()라는 문자 변환 함수도 정의돼 있다. 이들 함수는 두 개의 매개변수(변환할 문자와 변환에 적용할 로케일)을 받는다.

패싯 사용법

특정한 로케일에서 패싯을 구하려면 std::use_facet() 함수를 호출하면 된다. 이때 use_facet()의 인수로 locale을 지정한다. 예컨대 다음 문장은 POSIX 로케일 이름을 사용하여 영국식 영어 로케일을 지정했을 때 화폐 금액에 대한 표준 구두법을 조회한다.

use_facet<moneypunct<wchar_t>>(locale("en_GB"));

이 문장에서 가장 안쪽에 있는 템플릿 타입은 사용할 문자의 타입을 지정한다. 주로 wchar_t나 char를 사용한다. 템플릿 클래스가 중첩돼서 좀 복잡하지만 영국식 화폐 금액 구두법에 대한 모든 정보를 담은 객체를 구할 수 있다. 표준 패싯에서 제공하는 데이터는 모두 <locale> 헤더와 관련 파일에 정의돼 있다.

다음 표는 C++ 표준에서 정한 패싯의 범주를 보여준다. 각 패싯에 대한 자세한 사항은 표준 라이브러리 레퍼런스를 참고하기 바란다.

패싯 설명
ctype 문자 분류 패싯
codecvt 패싯 변환
collate 스트링을 사전 순으로 비교한다.
time_get 날짜와 시간을 파싱한다.
time_put 날짜와 시간을 포매팅한다.
num_get 숫자값을 파싱한다.
num_put 숫자값을 포매팅한다.
numpunct 숫자값에 대한 포매팅 매개변수를 정의한다.
money_get 화폐금액을 파싱한다.
money_put  화폐금액을 포매팅한다.
moneypunct 화폐 금액에 대한 포매팅 매개변수를 정의한다. 

(코드 예시 생략)

정규표현식

정규 표현식은 표준 라이브러리에서 제공하는 강력한 기능 중 하나로 <regex> 헤더 파일에 정의돼 있다. 이는 스트링 처리에 특화된 작은 언어라고 볼 수 있는데, 처음 보면 상당히 복잡해 보이지만 익숙해지면 스트링을 한결 간편하게 다룰 수 있다. 정규 표현식은 다음과 같이 다양한 스트링 연산에 활용된다.

  • 검증: 입력 스트링이 형식을 제대로 갖췄는지 확인한다.
  • 판단: 주어진 입력이 표현하는 대상을 확인한다.
  • 파싱: 입력 스트링에서 정보를 추출한다.
  • 변환: 주어진 서브스트링을 검색해서 다른 스트링으로 교체한다.
  • 반복: 주어진 서브스트링이 나온 부분을 모두 찾는다.
  • 토큰화: 정해진 구분자를 기준으로 스트링을 나눈다.

주어진 스트링에 대해 위의 연산을 직접 구현해도 되지만 정규 표현식을 활용하는 방식을 추천한다. 스트링을 처리하는 코드를 정확하고 안전하게 작성하기 쉽지 않을 수 있기 때문이다.

정규 표현식에 대해 본격적으로 설명하기 전에 몇 가지 용어를 알아둘 필요가 있다.

  • 패턴(pattern): 정규표현식은 스트링을 표현하는 패턴이다.
  • 매치(match): 주어진 정규표현식과 주어진 범위 [first, last) 안에 있는 모든 문자 사이에 일치하는 부분이 있는지 검사한다.
  • 탐색(search): 주어진 범위 [first, last) 안에 주어진 정규 표현식에 매치되는 서브스트링이 있는지 검사한다.
  • 치환(replace, 교체): 주어진 범위에서 서브스트링을 찾아서 치환 패턴(substitution pattern)에 일치하는 서브스트링으로 교체한다.

정규표현식에 대한 문법이 다양한데, C++은 다음과 같이 여러 문법을 제공한다.

  • ECMAScript: ECMAScript 표준 기반의 문법이다. ECMAScript는 ECMA-262 규격으로 표준화된 스크립트 언어다. JavaScript, ActionScript, JScript 등은 모두 ECMAScript 표준을 기반으로 만든 언어다.
  • basic: 기본 POSIX 문법
  • extended: 확장된 POSIX 문법
  • awk: POSIX awk 유틸리티에서 사용하는 문법
  • grep: POSIX grep 유틸리티에서 사용하는 문법
  • egrep: POSIX grep 유틸리티에서 -E 매개변수를 지정했을 때 사용하는 문법

위에서 소개한 문법 중에서 잘 알고 있는 것이 있다면 정규표현식 라이브러리의 syntax_option_type 옵션에 원하는 문법을 지정해서 정규표현식을 작성하면 된다. C++에서는 ECMAScript를 정규표현식의 기본 문법으로 사용한다.

ECMAScript 문법

정규표현식 패턴은 매치하려는 문자들을 표현한 것이다. 정규표현식에서 다음과 같은 특수 문자를 제외한 나머지 문자는 표현식에 명시된 그대로 매치한다.

^ $ \ . * + ? ( ) [ ] { } |

특수 문자에 대해 매치할 때는 그 문자 앞에 역슬래시(\)를 붙여 이스케이프(escape, 특수 문자로 해석되지 않고 매치할 문자로 해석하도록 탈출) 시킨다.

앵커

특수 문자 중에서 ^와 $를 앵커(anchor)라 부른다. ^는 줄 끝을 표현하는 문자의 바로 다음 지점을 가리키고, $는 줄 끝을 표현하는 바로 그 지점을 표현한다. 기본적으로 ^와 $는 스트링의 시작과 끝을 표현하는데, 이 동작은 끌 수도 있다.

와일드카드

와일드카드(wildcard) 문자인 .는 줄바꿈(newline, 개행) 문자를 제외한 모든 문자를 매치한다. 

선택 매치

수직선 |은 ‘또는’ 관계를 표현한다.

그룹

소괄호 ( )는 부분 표현식(subexpression) 또는 캡쳐 그룹(capture group)을 표현한다. 캡쳐 그룹은 다음 목적으로 사용된다.

  • 캡쳐 그룹은 원본 스트링에서 부분 시퀀스를 찾을 때 사용되며 캡쳐 그룹에 해당하는 부분 표현식을 리턴한다.
  • 매치하는 동안 역참조(back reference)를 위해 캡쳐 그룹을 활용하기도 한다.
  • 치환 작업에서 특정한 항목을 찾을 때도 캡쳐 그룹을 활용한다.

반복

다음 네 가지 반복 문자(repeat) 중 하나를 활용하면 정규표현식의 일부분을 반복 적용할 수 있다.

  • *는 그 앞에 나온 패턴을 0번 이상 매치한다.
  • +는 그 앞에 나온 패턴을 1번 이상 매치한다.
  • ?는 그 앞에 나온 패턴을 0번 또는 1번만 매치한다.
  • { … } 는 반복 횟수가 제한됐다는 것을 표현한다.
    • a{n}는 a를 정확히 n번 반복한 것을 매치한다. a{n,}는 a를 n번 이상 반복한 것을 매치한다. a{n, m}은 a를 n번 이상, m번 이하로 반복한 것을 매치한다.

앞서 소개한 반복 문자는 주어진 문장에서 정규표현식에 매치된 항목 중에서도 가장 긴 것을 찾는 그리디(greedy) 방식이 적용된다. 이와 반대로 논-그리디(non-greedy) 방식을 적용하려면 반복 문자 뒤에 ?를 붙이면 된다. 예컨대 *?, +?, ??, { … }? 등과 같다. 논-그리디 방식으로 작동할 때는 반복 횟수를 최소화하는 방식으로 매치한다.

연산 우선순위

정규표현식의 연산도 우선순위가 정해져 있다. 규칙은 다음과 같다.

  • 원소(element): a와 같은 기본 구성 요소를 말한다.
  • 한정자(quantifier): +, *, ?, { … } 등은 왼쪽에 나온 원소에 우선 적용된다. (예: b+)
  • 연결(concatenation): 한정자보다 우선순위가 낮다. (예: ab+c)
  • 선택(alternation): 우선순위가 가장 낮다. (예: |)

소괄호를 어떻게 묶느냐에 따라 우선순위를 바꾸 수 있다.

문자 집합 매치

알파벳을 매치하고 싶을 때 (a|b|c|…|z)와 같이 문자를 일일이 명시하면 번거롭기도 하고 캡쳐 그룹이 생성돼버린다. 이럴 때는 일정한 구간 또는 집합의 문자를 지정하는 특수 문법을 사용하는 것이 좋다. 또한 매치 대상에 부정(not) 연산을 적용시킬 수도 있다. 

문자 집합(character set)은 [c1, c2, … cn] 처럼 대괄호로 묶어서 지정한다. 그러면 c1, c2, … cn 중에서 일치하는 문자를 찾는다. 예컨대 [abc]라고 적으면 a, b 또는 c와 매치하는 것을 찾는다. 여기서 맨 앞에 ^를 적으면 부정 연산이 적용돼 ^ 뒤에 나온 문자를 제외한 나머지를 찾는다. 예컨대 다음과 같다.

  • ab[cde]: abc, abd, abe
  • ab[^cde]: abf, abp 등은 매치하지만 abc, abd, abe는 매치하지 않는다.

^, [, ]와 같은 특수 문자 자체를 매치하고 싶으면 이스케이프시켜야 한다. 예컨대 [\[\^\]]라고 적으면 [, ^, ]를 매치할 수 있다.

[abcd…XYZ]처럼 문자를 일일이 나열하는 방식으로 문자 집합을 지정해도 되지만 이렇게 작성하면 번거로울 뿐 아니라 실수가 나올 여지도 많다. 이럴 때는 다음의 두 방법 중 하나를 사용한다.

첫 번째 방법은 대괄호를 이용한 범위 지정 표기법을 사용하여 [a-zA-Z]와 같이 작성한다. 이 표현식을 a부터 z, A-Z 사이에 나오는 문자를 모두 매치한다. 하이픈(-)을 매치하려면 반드시 이스케이프 시켜야 한다.

두 번째 방법은 문자 클래스(character class)를 지정하는 것이다. 이렇게 하면 특정한 종류의 문자만 지정할 수 있다. 문법은 [:name:]과 같다. 사용할 수 있는 문자 클래스의 종류는 로케일마다 다르지만, 모든 로케일에서 공통적으로 인식할 수 있는 것을 정리하면 다음 표와 같다. 각 문자 클래스의 구체적인 의미도 로케일마다 다르다. 이 표는 표준 C 로케일을 기준으로 작성했다.

문자 클래스 설명
digit 숫자
d digit와 같다.
xdigit 숫자(digit)와 16진수를 표현하는데 사용되는 문자(‘a’, ‘b’, ‘c’, ‘d’, ‘e’, ‘f’, ‘A’, ‘B’, ‘C’, ‘D’, ‘E’, ‘F’)
alpha 알파벳 문자. 표준 C 로케일에서는 소문자와 대문자를 모두 포함한다.
alnum alpha클래스와 digit 클래스를 합친 것
w alnum과 같다.
lower 소문자(로케일에서 지원하는 경우)
upper 대문자(로케일에서 지원하는 경우)
blank 일종의 공백 문자로서 문장에서 단어끼리 구분하는데 사용된다. 표준 C 로케일에서는 ‘ ‘와 ‘\t’이 여기에 해당한다.
space 공백 문자. 표준 C 로케일에서는 ‘ ‘, ‘\t’, ‘\n’, ‘\r’, ‘\v’, ‘\f’ 등이 여기에 해당한다.
s space와 같다.
print 출력할 수 있는 문자로서 출력 대상(예: 모니터 화면)에서 일정한 공간을 차지한다. 이 문자는 제어 문자(cntrl)와 반대다. 예컨대 소문자, 대문자, 숫자, 구두점 문자, 공백 문자 등이 있다.
cntrl 제어 문자. 출력할 수 있는 문자(print)와 반대로서 화면에 같은 출력 대상에서 공간을 차지하지 않는다. 표준 C 로케일에서 여기에 해당하는 문자로는 ‘\f’ (다음 페이지, 폼 피드, form feed), ‘\n'(줄 바꿈, new line), ‘\r'(맨 앞으로 이동. 캐리지 리턴, carriage return) 등이 있다.
graph 그래픽 표현 문자. 출력할 수 있는 문자(print)에서 공백(‘ ‘)을 제외한 문자
punct 구두점 문자. 표준 C 로케일에서는 graph에 속한 문자에서 alnum을 제외한 문자가 여기에 해당한다. 예컨대 ‘!’, ‘#’, ‘@’, ‘}’ 등이 있다.

문자 클래스는 문자 집합 안에서 사용한다. 예컨대 [[:alpha:]]*는 영어권 로케일에서 [a-zA-z]*와 같다.

숫자를 비롯한 몇몇 매치 작업은 자주 수행하기 때문에 축약 표현을 제공한다. 예컨대 [:digit:]을 간단히 [:d:]로 표기할 수 있다. 이는 [0-9]와 같다. 일부 문자 클래스는 이스케이프 표기법(\)으로 더 줄여서 표현한다. 예컨대 [:digit:]을 \d로 표기해도 된다.

단어 경계

단어 경계(word boundary)는 다음 조건을 만족한다.

  • 첫 문자가 단어용 문자(문자, 숫자, 언더스코어 중 하나)로 시작하면 스트링의 시작이다. 표준 C 로케일에서는 [A-Za-z0-9_]에 해당한다. 디폴트 설정은 스트링의 싲가을 매치하는데, 원하면 끌 수 있다. (regex_constants::match_not_bow)
  • 마지막 문자가 단어용 문자로 끝나면 스트링의 끝이다. 디폴트 설정은 스트링의 끝을 매치하는데, 원하면 끌 수 있다. (regex_constants::match_not_ eow)
  • 단어의 첫 문자는 단어용 문자지만, 그 앞에 나온 문자는 단어용 문자가 아니다.
  • 단어의 끝 문자는 단어용 문자가 아니지만, 그 앞에 나온 문자는 단어용 문자다.

이러한 단어 경계를 매치하려면 \b를 지정한다. 반대로 단어 경계를 제외한 나머지를 매치하려면 \B를 지정한다.

역참조

역참조(back reference)를 이용하면 정규표현식 안에 있는 캡쳐 그룹을 참조할 수 있다. \n은 n번째 캡쳐 그룹을 가리킨다. 이때 n > 0 이다. 예컨대 (\d+)-.*-\1은 다음과 같은 포맷의 스트림을 매치한다.

  • (\d+) 캡쳐 그룹에 캡쳐된 한 개 이상의 숫자
  • 그 뒤에 대시가 나온다.
  • 그 뒤에 0개 이상의 문자(.*)가 나온다.
  • 그 뒤에 대시(-)가 나온다.
  • 마지막으로 첫 번째 캡쳐 그룹(\1)에 의해 캡쳐된 것과 똑같은 숫자가 나온다.

이 표현식은 123-abc-123, 1234-a-1234 등은 매치하지만 123-abc-1234, 123-abc-321 등은 매치하지 않는다.

미리보기

정규표현식은 ?= 패턴으로 표기하는 양의 미리보기(positive lookahead)와 ?! 패턴으로 표기하는 음의 미리보기(negative lookahead)를 지원한다. 양의 미리보기라면 지정한 패턴이 반드시 나와야 하고, 음의 미리보기라면 그 패턴이 나오지 않아야 한다. 이때 미리보기 패턴에 매치된 문자는 정규 표현식 매치 결과에 포함되지 않는다.

예컨대 a(?!b) 패턴은 음의 미리보기를 지정하여 a 문자 뒤에 b가 나오지 않는 것을 매치한다. a(?=b) 패턴은 양의 미리보기를 지정하여 a 문자 뒤에 b가 나오지만 b는 매치 결과에 포함하지 않는다.

좀 더 복잡한 예를 보자. 다음 정규표현식은 입력 스트링 중에서 소문자와 대문자가 최소 하나 있고, 구두점도 하나 있고, 길이도 최소 여덟 글자 이상인 것을 매치한다. 예컨대 패스워드가 일정한 기준에 만족하는지 검사할 때는 다음과 같은 패턴을 정의한다.

(?=.*[[:lower:]])(?=.*[[:upper:]])(?=.*[[:punct:]]).{8, }

정규표현식과 로 스트링 리터럴

정규표현식에는 특수 문자가 많이 나온다. 이런 문자는 C++ 스트링 리터럴에 쓸 때 주로 이스케이프해야 했던 것들이다. 예컨대 정규표현식에 \d를 쓰면 숫자를 매치하는데, \는 C++에서 특수 문자라서 정규표현식을 스트링 리터럴에 담을 때는 \\d와 같이 이스케이프해야 한다. 그렇지 않으면 C++ 컴파일러가 \d란 문자로 해석해 버린다.

여기에 백슬래시(\) 문자를 매치하는 구문을 추가하려면 정규표현식이 더욱 복잡해진다. \는 정규표현식 문법 안에서도 특수 문자이기 때문에 \\와 같이 이스케이프시켜야한다. 게다가 \ 문자는 C++ 스트링 리터럴에서도 특수만자다. 따라서 백슬래시를 매치하는 정규표현식을 C++ 스트링 리터럴에 담을 때는 \\\\로 표기해야 한다.

로(raw) 스트링 리터럴을 사용하면 복잡한 정규표현식을 C++ 코드에서 보다 읽기 쉽게 표현할 수 있다.

예컨대 다음과 같이 작성된 정규표현식이 있다면

"( |\\n|\\r|\\\\)"

로 스트링 리터럴로 다음과 같이 훨씬 보기 좋게 바꿀 수 있다.

R"(( |\n|\r|\\))"

regex 라이브러리

정규표현식 라이브러리에 관련된 내용은 모두 <regex> 헤더 파일의 std 네임스페이스 아래에 정의돼 있다. 정규표현식 라이브러리에서 기본으로 제공해둔 템플릿 타입은 다음과 같다.

  • basic_regex: 특정한 정규표현식을 표현하는 객체
  • match_results: 정규표현식에 매치된 서브스트링으로서 캡처 그룹을 모두 포함한다. sub_match의 묶음인 셈이다.
  • sub_match: 반복자와 입력 시퀀스에 대한 쌍을 담은 객체다. 여기 나온 반복자는 매치된 캡쳐 그룹을 표현한다. 쌍에는 매치된 캡쳐 그룹에서 첫 번째 문자를 가리키는 반복자와 매치된 캡쳐 그룹에서 마지막 바로 다음 번째 지점을 가리키는 반복자가 담겨 있다. 이 객체는 매치된 캡쳐 그룹을 스트링으로 리턴하는 str() 메서드도 제공한다.

이 라이브러리는 regex_match(), regex_search(), regex_replace()라는 세 가지 핵심 알고리즘을 제공한다. 각각 원본 스트링을 string, 문자 배열 또는 시작/끝 반복자 쌍으로 입력받는 버전이 존재한다. 여기서 반복자 타입은 다음 중 하나다.

  • const char*
  • const wchar_t*
  • string::const_iterator
  • wstring::const_iterator

참고로 양방향 반복자로 작동하는 반복자라면 어떤 것도 사용할 수 있다. 

regex 라이브러리는 다음과 같이 정규표현식 반복자에 대해 두 가지 타입을 정의하고 있다. 원본 스트링에서 주어진 패턴에 매치되는 문자를 모두 찾고 싶다면 이 타입을 잘 알아둘 필요가 있다.

  • regex_iterator: 원본 스트링에서 패턴에 매치되는 모든 항목에 대해 반복한다.
  • regex_token_iterator: 원본 스트링에서 패턴에 매치되는 모든 캡쳐 그룹에 대해 반복한다.

C++ 표준은 이 라이브러리를 보다 쉽게 사용할 수 있도록 다음과 같이 타입 앨리어스를 정의하고 있다.

using regex = basic_regex<char>;
using wregex = basic_regex<wchar_t>;

using csub_match = sub_match<const char*>;
using wsub_match = sub_match<const wchar_t*>;
using ssub_match = sub_match<string::const_iterator>;
using wssub_match = sub_match<wstring::const_iterator>;

using cmatch = match_result<const char*>;
using wmatch = match_result<const wchar_t*>;
using smatch = match_result<string::const_iterator>;
using wsmatch = match_result<wstring::const_iterator>;

using cregex_iterator = regex_iterator<const char*>;
using wcregex_iterator = regex_iterator<const wchar_t*>;
using sregex_iterator = regex_iterator<string::const_iterator>;
using wsregex_iterator = regex_iterator<wstring::const_iterator>;

using cregex_token_iterator = regex_token_iterator<const char*>;
using wcregex_token_iterator = regex_token_iterator<const wchar_t*>;
using sregex_token_iterator = regex_token_iterator<string::const_iterator>;
using wsregex_token_iterator = regex_token_iterator<wstring::const_iterator>;

regex_match()

rege_match() 알고리즘은 주어진 원본 스트링을 주어진 정규표현식 패턴으로 비교한다. 주어진 원본 스트링 전체가 이 패턴에 매치되면 true를 그렇지 않으면 false를 리턴한다.

사용법은 아주 간단하다. regex_match() 알고리즘은 여섯 가지 버전이 제공되는데, 각 버전마다 인수의 타입은 다르지만 모두 다음과 같은 형식을 따른다.

template<...>
bool regex_match(InputSequence[, MatchResults], RegEx[, Flags]);

InputSequence는 다음 중 하나로 표현한다.

  • 원본 스트링에 대한 시작과 끝 반복자
  • std::string
  • C 스타일 스트링

옵션으로 MatchResults란 매개변수도 지정할 수 있다. 이 값은 match_results에 대한 레퍼런스로서 매치 결과를 여기서 받을 수 있다. regex_match()가 false를 리턴하면 match_results::empty()나 match_results::size()만 호출할 수 있다. 다른 메서드의 호출 결과는 정확히 알 수 없다. regex_match()가 true를 리턴하면 매치되는 결과가 있다는 뜻이므로 match_results 객체를 이용하여 정확히 매치된 결과를 살펴볼 수 있다.

RegEx 매개변수는 매치의 기준이 될 정규표현식이다. 옵션으로 Flags 매개변수도 지정할 수 있다. 이 매개변수는 매치 알고리즘에 대한 옵션을 지정하는데, 대부분 디폴트값을 사용한다. 

regex_match() 예제

(예제 생략)

regex_search()

regex_match() 알고리즘은 원본 스트링 전체가 정규표현식에 매치될 때만 true를 리턴하고 나머지 경우는 false를 리턴한다. 그래서 일부분만 매치되는 경우는 찾을 수 없다. 만약 서브스트링도 찾고 싶다면 regex_search() 알고리즘을 사용해야 한다. 이 알고리즘은 원본 스트링 중에서 주어진 패턴에 일치하는 부분을 탐색한다. regex_search() 알고리즘은 총 여섯 가지 버전이 있는데, 모두 다음과 같은 형식을 따른다.

template<...>
bool regex_search(InputSequence[, MatchResults], RegEx[, Flags]);

각 버전은 입력 스트링에서 매치된 결과가 한 부분이라도 있으면 true를 리턴하고 그렇지 않으면 false를 리턴한다. 매개변수는 regex_match()의 매개변수와 비슷하다.

regex_search() 알고리즘의 두 버전은 탐색할 입력 스트링에 대한 시작과 끝 반복자를 매개변수로 받는다. 이 버전을 이용하여 루프를 돌며 regex_search()를 호출할 때마다 시작과 끝 반복자를 조작하는 방식으로 주어진 원본 스트링에서 패턴에 매치되는 모든 항목을 찾도록 코드를 작성하는 경우가 있는데 절대로 이렇게 하면 안된다. 이렇게 작성하면 정규표현식에 앵커(^, $)나 단어 경계 등을 사용할 때 문제가 발생할 수 있다. 또한 공백도 매치되는 바람에 무한 루프에 빠질 수 있다. 이렇게 원본 스트링에 매치되는 모든 항목을 찾고 싶다면 뒤에서 소개할 regex_iterator나 regex_token_iterator를 사용한다.

Caution) 주어진 원본 스트링에 매치되는 결과를 모두 찾기 위해 루프 안에서 regex_search()를 호출하는 방식으로 구현하면 절대 안 된다. 대신 regex_iterator나 regex_token_iterator를 사용하기 바란다.

regex_search() 예제

(예제 생략)

regex_iterator

regex_iterator 예제

다음 예제는 원본 스트링을 입력 받아서 그 안에 나온 단어를 모두 찾은 다음 각각을 따옴표로 묶어서 화면에 출력한다. 이를 위해 한 개 이상의 단어를 검색하는 [\w]+란 정규 표현식을 정의했다.

이 예제는 원본 스트링을 std::string 타입으로 받기 때문에 sregex_iterator를 반복자로 사용했다. 표준 반복자 루프 패턴으로 작성했지만, 끝 반복자를 기존 표준 라이브러리 컨테이너와 약간 다르게 사용했다. 일반적으로 특정한 컨테이너에 맞는 끝 반복자를 지정하지만 regex_iterator에는 끝 반복자가 하나뿐이다. 이러한 끝 반복자는 regex_iterator 타입을 선언할 때 디폴트 생성자로 구할 수 있다.

for 문은 먼저 iter라는 시작 반복자를 생성한다. 이 반복자는 원본 스트링에 대한 시작과 끝 반복자와 패턴을 표현하는 정규표현식을 인수로 받는다. 매치되는 결과가 나올 때마다 루프의 본문이 호출되는데, 예제에서는 한 단어 단위로 호출된다.

매치된 모든 결과에 대해 반복하는 구문은 sregex_iterator로 구현했다. sregex_iterator를 역참조하면 smatch 객체를 구할 수 있다. 이 smatch 객체의 첫 번째 원소인 [0]을 접근하면 매치된 부분 스트링을 구할 수 있다.

regex reg("[\\w]+");

while (true)
{
cout << "Enter a string to split (q=quit): ";

string str;
if (!getline(cin, str) || str == "q")
{
break;
}

const sregex_iterator end;
for (sregex_iterator iter(cbegin(str), cend(str), reg); iter != end; ++iter)
{
cout << "\"" << (*iter)[0] << "\"" << endl;
}
}

이 코드를 실행한 결과는 다음과 같다.

Enter a string to split (q=quit): This, is    a test.
"This"
"is"
"a"
"test"

예제에서 볼 수 있듯이 아주 간단한 정규표현식만으로도 상당히 강력한 스트링 조작 기능을 구현할 수 있다.

참고로 regex_iterator와 regex_token_iterator는 내부적으로 정규표현식에 대한 포인터를 갖고 있다. 둘 다 우측값 정규표현식을 인수로 받는 생성자를 명시적으로 delete 하기 때문에 임시 regex 객체를 생성할 수 없다. 예컨대 다음과 같이 작성하면 컴파일 에러가 발생한다.

for (sregex_iterator iter(cbegin(str), cend(str), regex("[\\w]+")); iter != end; ++iter) { ... }

regex_token_iterator

앞 절에서 패턴에 매치된 모든 결과에 대해 반복하는 regex_iterator를 살펴봤다. 루프를 한 번 돌 때마다 이 반복자로부터 match_results 객체를 받는데, 이를 통해 캡쳐 그룹으로 매치된 부분 표현식을 추출할 수 있다.

regex_token_iterator를 이용하면 매치된 결과 중에서 캡쳐 그룹 전체 또는 선택한 캡쳐 그룹에 대해서만 루프를 돌 수 있다. 이 반복자에는 네 가지 버전의 생성자가 있는데 모두 다음 형식을 따른다.

regex_token_iterator(BidirectionalIterator a, BidirectionalIterator b, const regex_type& re [, SubMatches [, Flags]]);

네 버전 모두 입력 시퀀스를 시작과 끝 반복자로 지정한다. 옵션으로 SubMatches 매개변수를 지정할 수 있다. 이 값은 반복의 기준이 되는 캡쳐 그룹을 지정한다. SubMatches는 다음 네 가지 방식 중 하나로 지정한다.

  • 반복하려는 캡쳐 그룹의 인덱스를 나타내는 정숫값 하나로 지정한다.
  • 반복하려는 캡쳐 그룹의 인덱스를 나타내는 정숫값을 vector에 담아 지정한다.
  • 캡쳐 그룹 인덱스에 대한 initializer_list로 지정한다.
  • 캡쳐 그룹 인덱스에 대한 C 스타일 배열로 지정한다.

SubMatches 매개변수를 지정하지 않거나 그 값을 0으로 지정하면 인덱스가 0인 캡쳐 그룹을 모두 반복하는 반복자를 받게 된다. 다시 말해 정규표현식 전체에 매치되는 서브스트링에 대해 반복하게 된다. 옵션으로 매치 알고리즘을 선택하는 Flags 매개변수도 지정할 수 있지만 대부분 디폴트값을 그대로 사용한다. 

regex_token_iterator 예제

(예시 생략)

regex_token_iterator는 소위 필드 분할(field splitting) (또는 토큰화) 작업에도 활용된다. 이는 C에서 물려받은 strtok() 함수를 사용하는 것보다 훨씬 안전하고 유연하다. regex_token_iterator 생성자에서 반복할 캡쳐 그룹 인덱스를 -1로 지정하면 토큰화 모드로 작동한다. 토큰화 모드로 실행될 때는 입력 시퀀스에서 주어진 정규표현식에 매치되지 않은 모든 서브스트링에 대해 반복한다. 

다음 코드는 ,와 ;로 사용하는 스트링을 토큰화하는 예를 보여준다. 여기서 구분자 앞이나 뒤에 공백이 한 칸 이상 나올 수 있다.

regex reg(R"(\s*[,;]\s*)");

while (true)
{
cout << "Enter a string to split (q=quit): ";

string str;
if (!getline(cin, str) || str == "q")
{
break;
}

const sregex_token_iterator end;
for (sregex_token_iterator iter(cbegin(str), cend(str), reg, -1); iter != end; ++iter)
{
cout << "\"" << *iter << "\"" << endl;
}
}

regex_replace()

regex_replace() 알고리즘은 정규표현식과 매치된 서브스트링을 대체할 포맷 스트링(formatting string)을 인수로 받는다. 포맷 스트링에서 다음 표에 나온 이스케이프 시퀀스를 이용하면 매치된 서브스트링의 일부분을 참조할 수 있다.

이스케이프 시퀀스 교체할 내용
$n n 번째 캡쳐 그룹에 매치되는 스트링. 예컨대 $1은 첫 번째 캡쳐 그룹을, $2는 두 번째 캡쳐 그룹을 의미한다. 이때 n은 반드시 0보다 큰 수여야 한다.
$& 정규표현식 전체에 매치되는 스트링
$` 정규표현식에 매치된 서브스트리으이 왼쪽에 나온 입력 시퀀스의 일부분
$’ 정류표현식에 매치된 서브스트링의 오른쪽에 나온 입력 시퀀스의 일부분
$$ 달러 기호($)

regex_replace() 알고리즘은 여섯 가지 버전이 있다. 각각 인수의 타입만 다르다. 그중 네 가지 버전은 다음 형식을 따른다.

string regex_replace(InputSequence, RegEx, FormatString[, Flags]);

이 네 가지 버전은 교체 작업을 반영한 스트링을 리턴한다. InputSequence와 FormatString은 std::string이나 C 스타일 스트링으로 표현할 수 있다. RegEx 매개변수는 매치의 기준이 될 정규표현식을 지정한다. 옵션으로 지정하는 Flags 매개변수는 적용할 교체 알고리즘을 지정한다.

rege_replace() 알고리즘 중 두 가지 버전은 다음 형식을 따른다.

OutputIterator rege_replace(OuputIterator, BidirectionalIterator first, BidirectionalIterator last, RegEx, FormatString[, Flags]);

이 두 가지 버전은 지정한 출력 반복자에 교체한 결과를 쓴 다음 그 출력 반복자를 리턴한다. 그리고 입력 시퀀스는 시작과 끝 반복자로 지정한다.

regex_replace() 예제

(예시 생략)

전문가를 위한 C++/ 표준 라이브러리 알고리즘 마스터하기

(전체가 아니라 C#과 차이가 있는 부분을 중심으로 요약 정리)

표준 라이브러리는 거의 모든 컨테이너에 적용할 수 있는 제네릭 알고리즘을 다양하게 제공한다. 이러한 알고리즘을 활용하면 컨테이너에 담긴 원소를 검색하고, 정렬하고, 가공하고, 다양한 연산을 수행할 수 있다.

표준 라이브러리 알고리즘의 가장 큰 장점은 각 원소의 타입이나 컨테이너의 타입과는 독립적이라는 점이다. 게다가 모든 작업을 반복자 인터페이스만으로 처리한다.

알고리즘 개요

표준 라이브러리 알고리즘은 모두 함수 템플릿으로 구현됐으며, 여기 나온 템플릿 타입 매개변수는 반복자 타입인 경우가 많다. 반복자 자체는 이 함수에 전달된 인수로 결정한다. 함수 템플릿은 대부분 함수에 전달된 인수를 보고 템플릿 타입을 추론하기 때문에 알고리즘을 템플릿이 아닌 일반 함수처럼 호출해서 쓸 수 있다.

반복자 인수는 주로 반복자 범위 (시작과 끝의 쌍)로 표현한다. 반복자 범위를 표현할 때는 첫 번째 원소는 포함하고 마지막 원소는 제외한 반개방(half-open) 범위로 받는 컨테이너가 많다. 끝 반복자(end iterator)는 실제로 ‘마지막 바로 다음’ 항목을 가리킨다.

알고리즘에 전달할 반복자는 일정한 요건을 갖춰야 한다. 예컨대 copy_backward()는 양방향 반복자를 지정해야 하며, stable_sort()는 랜덤 액세스 반복자를 지정해야 한다. 다시 말해 이런 알고리즘은 요건을 갖추지 못한 반복자를 가진 컨테이너에 대해서는 작동하지 않는다..

(이하 설명 생략)

find()와 find_if() 알고리즘

find()는 주어진 반복자 범위에서 특정한 원소를 검색한다. 이 알고리즘은 모든 종류의 컨테이너 원소를 찾을 때 활용할 수 있다. find()는 원소를 찾으면 그 원소를 참조하는 반복자를 리턴하고, 원소를 찾지 못하면 주어진 범위의 끝을 가리키는 반복자(끝 반복자)를 리턴한다. 참고로 find()를 호출할 때는 지정한 범위에 컨테이너의 모든 원소를 담지 않아도 된다. 다시 말해 부분 집합만 지정해도 된다.

Caution) find()에서 원소를 찾지 못하면 내부 컨테이너의 끝 반복자가 아니라 함수를 호출할 때 지정한 끝 반복자를 리턴한다.

#include <algorithm>
#include <vector>
#include <iostream>
using namesapce std;

int main()
{
int num;
vector<int> myVector;

while(true)
{
cout << "Enter a number to add (0 to stop): ";
cin >> num;

if (num == 0)
{
break;
}
myVector.push_back(num);
}

while(true)
{
cout << "Enter a number to lookup (0 to stop): ";
cin >> num;

if (num == 0)
{
break;
}

auto endIt = cend(myVector);
auto it = find(cbegin(myVector), endIt, num);

if (it == endIt)
{
cout << "Could not find " << num << endl;
}
else
{
cout << "Found " << *it << endl;
}
}

return 0;
}

find()를 호출할 떄 cbegin(myVector)와 endIt을 인수로 지정했다. 여기서는 vector에 있는 원소를 모두 검색하도록 endIt을 cend(myVector)로 정의한다. 일부분만 검색하려면 이 두 반복자를 적절히 변경한다.

C++ 17부터 추가된 if 문의 이니셜라이저를 적용하면 find()를 호출하고 결과를 검사하는 작업을 다음과 같이 한 문장으로 표현할 수 있다.

if (auto it = find(cbegin(myVector), endIt, num); it == endIt)
{
cout << "Could not find " << num << endl;
}
else
{
cout << "Find " << *it << endl;
}

참고로 map과 set처럼 find()를 자체적으로 정의해서 클래스 메서드로 제공하는 컨테이너도 있다.

Caution) 제네릭 알고리즘과 동일한 기능을 제공하는 메서드가 컨테이너에 있다면 컨테이너의 메서드를 사용하는 것이 좋다. 훨씬 빠르기 때문이다.

find_if()는 인수로 검색할 원소가 아닌 프레디케이트 함수 콜백(predicate function callback)을 받는다는 점을 제외하면 find()와 같다. 프레디케이트는 true나 false를 리턴한다. find_if() 알고리즘은 지정한 범위 안에 있는 원소에 대해 인수로 지정한 프레디케이트가 true를 리턴할 때가지 계속 호출한다. 프레디케이트가 true를 리턴하면 find_if()는 그 원소를 가리키는 반복자를 리턴한다.

bool perfectScore(int num)
{
return num >= 100;
}

int main()
{
int num;
vector<int> myVector;

while(true)
{
cout << "Enter a number to add (0 to stop): ";
cin >> num;

if (num == 0)
{
break;
}
myVector.push_back(num);
}

auto endIt = cend(myVector);
auto it = find(cbegin(myVector), endIt, perfectScore);

if (it == endIt)
{
cout << "Not perfect scores" << endl;
}
else
{
cout << "Found a \"perfect\" score of " << *it << endl;
}

return 0;
}

find_if()를 호출할 때 다음과 같이 람다 표현식을 지정해도 된다. 여기서 람다 표현식의 강력함을 엿볼 수 있다. 이렇게 하면 perfectScore()란 함수를 따로 정의할 필요가 없다.

auto it = find_if(cbegin(myVector), endIt, [](int i) { return i => 100; });

accumulate() 알고리즘

컨테이너에 있는 원소를 모두 더할 때 accumulate() 함수를 이용한다. 다음 예는 vector에 담긴 정수의 산술 평균을 구한다.

double arithmeticMean(const vector<int>& nums)
{
double sum = accumulate(cbegin(nums), cend(nums), 0);
return sum / num.size();
}

accumulate() 알고리즘은 합에 대한 초깃값을 세 번째 매개변수로 받는다. 여기서는 처음부터 새로 구하도록 덧셈의 항등원인 0을 지정했다.

accumulate()의 두 번째 형태는 디폴트 연산인 덧셈 대신 다른 연산을 직접지정할 수 있다. 이때 연산을 이진(인수가 두 개인) 콜백으로 표현한다. 예컨대 주어진 원소에 대한 기하 평균을 구하는 코드는 다음과 같다.

int product(int num1, int num2)
{
return num1 * num2;
}

double geometricMean(const vector<int>& nums)
{
double mult = accumulate(cbegin(nums), cend(nums), 1, product);
return pow(mult, 1.0 / num.size());
}

여기서 product() 함수를 accumulate() 의 콜백으로 전달하고, accumulate()의 초깃값을 곱셈의 항등원인 1로 지정했다.

람다 표현식을 이용하면 다음과 같이 작성할 수 있다.

double geometricMeanLabda(const vector<int>& nums)
{
double mult = accumulate(cbegin(nums), cend(nums), 1, [](int num1, int num2) { return num1 * num2; });

return pow(mult, 1.0/num.size());
}

알고리즘과 이동 의미론

표준 라이브러리의 컨테이너와 마찬가지로 알고리즘도 이동 의미론을 적용해서 최적화할 수 있다. 이동 의미론을 구현하려면 클래스에 이동 생성자와 이동 대입 연산자를 구현하고 noexcept로 지정해야 한다.

std::function

<functional> 헤더 파일에 정의된 std::function 템플릿을 이용하면 함수를 가리키는 타입, 함수 객체, 람다 표현식을 비롯하여 호출 가능한 모든 대상을 가리키는 타입을 생성할 수 있다. std::function을 다형성 함수 래퍼(polymorphic function wrapper)라고 부르며, 함수 포인터로 사용할 수도 있고, 콜백을 구현하는 함수를 나타내는 매개변수로 사용할 수도 있다.

std::function의 템플릿 매개변수는 다른 템플릿 매개변수와 모양이 다르다. 문법은 다음과 같다.

std::function<R(ArgTypes...)>

여기서 R은 리턴 타입이고 ArgTypes는 각각을 콤마로 구분한 매개변수의 타입 목록이다.

std::function으로 함수 포인터를 구현하는 방법은 다음과 같다. 여기서는 func()를 가리키는 f1이란 함수 포인터를 생성한다. f1을 정의한 뒤에는 func나 f1이란 이름만으로 func()를 호출할 수 있다.

void func(int num, const string& str)
{
cout << "func(" << num << ", " << str << ")" << endl;
}

int main()
{
function<void(int, const string&)> f1 = func;
f1(1, "test");
return 0;
}

물론 위 예제에서 auto 키워드를 사용하면 f1 앞에 굳이 타입을 지정하지 않아도 된다. 위와 같이 작성하면 컴파일러는 f1의 타입이 std::function이 아니라 함수 포인터라고 판단한다. 그래서 void (*f1) (int, const string&) 라고 변환해버린다.

std::function 타입은 함수 포인터처럼 작동하기 떄문에 표준 라이브러리 알고리즘에 인수로 전달할 수 있다. find_if() 알고리즘에 이를 적용한 예는 다음과 같다.

bool isEven(int num)
{
return num % 2 == 0;
}

int main()
{
vector<int> vec { 1, 2, 3, 4, 5, 6, 7, 8, 9 };

function<bool(int)> fcn = isEven;
auto result = find_if(cbegin(vec), cend(vec), fcn);

if (result != cend(vec))
{
cout << "First even number: " << *result << endl;
}
else
{
cout << "No even number found" << endl;
}

return 0;
}

이 예제를 보고 나서 std::function을 굳이 사용할 필요가 없다고 생각할 수도 있지만 std::function의 진가는 콜백을 클래스 멤버 변수에 저장할 때 드러난다. 또한 함수 포인터를 매개변수로 받아야 할 때도 유용하다.

다음 코드에 나온 process() 함수는 vector에 대한 레퍼런스와 std::function을 인수로 받는다. process() 함수는 인수로 전달된 vector의 모든 원소에 대해 루프를 돌면서 각 원소에 대해 process()인 인수로 지정한 함수를 호출한다. 여기서 매개변수 f는 콜백이다.

그 뒤에 나오는 print() 함수는 인수로 지정한 값을 콘솔에 출력한다. main() 함수는 가장 먼저 정수에 대한 vector를 생성한다. 그러고 나서 process() 함수에 print()를 함수 포인터로 전달해서 호출한다. 그러면 vector에 담긴 원소를 화면에 출력한다.

main() 함수는 람다 표현식을 process() 함수의 std::function 매개변수의 값으로 지정하는 예를 보여준다. 여기서 std::function의 진가가 드러난다. 일반 함수 포인터로는 이렇게 처리할 수 없다.

void process(const vector<int>& vec, function<void(int)> f)
{
for (auto& i : vec)
{
f(i);
}
}

void print(int num)
{
cout << num << " ";
}

int main()
{
vector<int> vec { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };

process(vec, print);
cout << endl;

int sum = 0;
process(vec, [&sum](int num){ sum += num; });
cout << "sum = " << sum << endl;

return 0;
}

콜백 매개변수로 std::function을 사용하지 않고 다음과 같이 함수 템플릿으로 만들어도 된다.

template <typename F>
void processTemplate(const vector<int>& vec, F f)
{
for (auto& i : vec)
{
f(i);
}
}

이렇게 정의한 함수 템플릿은 비템플릿 process() 함수와 기능이 같다. 다시 말해 processTemplate()은 일반 함수 포인터와 람다 표현식을 모두 받을 수 있다.

람다 표현식

람다 표현식(lambda expression)이란 함수나 함수 객체를 별도로 정의하지 않고 필요한 지점에서 곧바로 함수를 직접 만들어 쓸 수 있는 일종의 익명 함수다. 람다 표현식을 잘 활용하면 코드를 깔끔하게 만들 수 있다.

문법

람다 표현식은 람다 선언자(lambda introducer)라 부르는 대괄호 []로 시작하고, 그 뒤에 람다 표현식의 본문을 담는 중괄호 {}가 나온다.

아래 예시의 람다 표현식은 auto 타입 변수인 basicLabda에 대입된다. 이렇게 정의한 람다 표현식은 두 번째 줄처럼 일반 함수를 호출하는 방식으로 실행한다.

auto basicLambda = [] { cout << "Hello from Lambda" << endl; };
basicLambda();

람다 표현식도 매개변수를 받을 수 있다. 람다 표현식의 매개변수는 일반 함수와 마찬가지로 소괄호로 묶어서 표현한다. 매개변수가 여러 개면 각각을 콤마로 구분한다. 

auto parametersLambda = [] (int value) { cout << "The value is " << value << endl; };
parametersLambda(42);

람다 표현식에서 매개변수를 받지 않을 때는 빈 소괄호만 적거나 소괄호를 생략한다. 

람다 표현식은 값을 리턴한다. 이러한 리턴값의 타입은 다음과 같이 화살표 뒤에 지정한다. 이러한 표기법을 후행(후위) 리턴 타입(trailing return type)이라 부른다. 다음 코드는 두 매개변수로부터 받은 정수를 더한 결과를 리턴하는 예이다.

auto returningLambda = [](int a int b) -> int { return a + b; };
int sum = returningLambda(11, 22);

람다 표현식은 자신이 속한 스코프에 있는 변수에 접근할 수 있다. 예컨대 다음 코드는 data라는 변수를 람다 표현식의 본문에서 사용한다.

double data = 1.23;
auto capturingLambda = [data] { cout << "Data = " << data << endl; };

대괄호 부분은 람다 캡쳐 블록(capture block)이라 부른다. 앞의 코드처럼 어떤 변수를 대괄호 안에 지정해서 람다 표현식의 본문 안에서 그 변수를 사용하게 만들 수 있다. 이를 캡쳐한다고 한다. 캡쳐 블록을 []와 같이 비워두면 람다 표현식이 속한 스코프에 있는 변수를 캡쳐하지 않는다. 앞의 코드처럼 캡쳐 블록에 변수 이름만 쓰면 그 변수를 값으로 캡쳐한다.

컴파일러는 람다 표현식을 이름 없는 펑터(함수 객체)로 변환한다. 캡쳐한 변수는 이 펑터의 데이터 멤버가 된다. 값으로 캡쳐한 변수는 펑터의 데이터 멤버로 복제된다. 이렇게 복제된 데이터 멤버는 캡쳐한 변수의 const 속성을 그대로 이어받는다. 

앞서 본 capturingLambda 예제에서 펑터는 non-const 데이터 멤버(data)를 갖게 된다. 캡처한 변수인 data가 non-const이기 때문이다. 하지만 다음 코드에서는 변수가 const이기 때문에 펑터는 const 데이터 멤버를 갖게 된다.

const double data = 1.23;
auto capturingLambda = [data] { cout << "Data = " << data << endl; };

펑터마다 함수 호출 연산자인 operator()가 구현돼있다. 람다 표현식의 경우 이 연산자는 기본적으로 const로 설정된다. 따라서 non-const 변수를 람다 표현식에 값으로 캡쳐해도 람도 표현식 안에서 이 값의 복제본을 수정할 수 없다. 다음과 같이 람다 표현식을 mutable로 지정하면 함수 호출 연산자를 non-const로 만들 수 있다.

double data = 1.23;
auto capturingLambda = [data] () mutable { data *=2 ; cout << "Data = " << data << endl; };

이 코드에서 non-const data 변수를 값으로 캡쳐했다. 따라서 data의 복제본으로 생성되는 펑터의 데이터 멤버는 non-const다. 여기서 mutable 키워드를 지정했기 때문에 함수 호출 연산자도 non-const다. 따라서 람다 표현식의 본문에서 data의 복제본을 수정할 수 있다. 참고로 mutable을 지정할 때는 매개변수가 없더라도 소괄호를 반드시 적어야 한다.

변수 이름 앞에 &를 붙이면 레퍼런스로 캡쳐한다.

double data = 1.23;
auto capturingLambda = [&data] { data *= 2; }; 

변수를 레퍼런스로 캡쳐하려면 람다 표현식을 실행하는 시점에 레퍼런스가 유효한지 반드시 확인해야 한다.

람다 표현식이 속한 스코프의 변수를 모두 캡쳐할 수도 있다. 방법은 다음과 같이 두 가지가 있다.

  • [=]: 스코프에 있는 변수를 모두 값으로 캡쳐한다.
  • [&]: 스코프에 있는 변수를 모두 레퍼런스로 캡쳐한다.

캡쳐 리스트를 지정하면 캡쳐할 변수를 골라서 지정할 수 있다. 이때 캡쳐 디폴트(capture default)도 함께 지정할 수 있다. 캡쳐 리스트에서 앞에 &가 붙은 변수를 레퍼런스로 캡쳐한다. &가 없는 변수는 값으로 캡쳐한다. 변수 이름 앞에 &나 =를 붙이려면 반드시 캡쳐 리스트의 첫 번째 원소가 캡쳐 디폴트(& 또는 =)여야 한다. 캡쳐 블록에 대한 예를 몇 가지 들면 다음과 같다.

  • [&x]: 변수 x만 레퍼런스로 캡쳐한다.
  • [x]: 변수 x만 값으로 캡쳐한다.
  • [=, &x, &y]: x와 y는 캡쳐하고, 나머지는 값으로 캡쳐한다.
  • [&, x]: x만 값으로 캡쳐하고 나머지는 레퍼런스로 캡쳐한다.
  • [this]: 현재 객체를 캡쳐한다. 람다 표현식의 본문 안에서 이 객체에 접근할 때 this->를 붙이지 않아도 된다.
  • [*this]: 현재 객체의 복제본을 캡쳐한다. 람다 표현식을 실행하는 시점에 객체가 살아 있지 않을 때 유용하다.

Note) 캡쳐 디폴트를 지정할 때는 람다 표현식의 본문에서 실제로 쓸 것만 값(=) 또는 레퍼런스(&)로 캡쳐해야 한다. 사용하지 않을 변수는 캡쳐하지 않는다..

Caution) 캡쳐 디폴트가 람다 표현식에서 실제로 사용된 변수만 캡쳐하더라도 캡쳐 디폴트를 사용하지 않는 것이 좋다. = 캡쳐 디폴트를 사용하면 자칫 복제 연산이 발생할 수 있는데 그러면 성능에 지장을 준다. & 캡쳐 디폴트를 사용할 때는 스코프에 있는 변수를 실수로 수정해 버릴 수 있다. 가능하면 캡쳐할 변수를 직접 지정하기 바란다.

람다 표현식의 문법을 정리하면 다음과 같다.

[캡쳐 블록] (매개변수) mutable constexpr noexcept 속성 -> 리턴타입 { 본문 }
  • 캡쳐블록: 스코프에 있는 변수를 캡쳐하는 방식을 지정하고, 람다 표현식의 본문에서 그 변수에 접근할 수 있게 만든다.
  • 매개변수(생략 가능): 람다 표현식에 대한 매개변수 목록. 매개변수를 받지 않고 mutable, const 표현식, noexcept 지정자, 속성, 리턴 타입을 지정하지 않는다면 생략해도 된다. 매개변수 목록을 지정하는 방식은 일반 함수와 같다.
  • mutable(생략 가능): 람다 표현식을 mutable로 지정한다.
  • constexpr(생략 가능): 람다 표현식을 const로 지정한다. 그러면 컴파일 시간에 평가된다. 명싲거으로 지정하지 않더라도 람다 표현식이 일정한 요건을 충족하면 내부적으로 const로 처리된다.
  • noexcept(생략 가능): noexcept 구문을 지정할 때 사용한다. 기능은 일반 함수에 대한 noexcept와 같다.
  • 속성(생략 가능): 람다 표현식에 대한 속성을 지정한다. 
  • 리턴타입(생략 가능): 리턴값의 타입을 지정한다. 생략하면 컴파일러가 추론한다.

제네릭 람다 표현식

람다 표현식에서 매개변수의 타입을 구체적으로 지정하지 않고 auto 타입 추론을 적용할 수 있다. 매개변수에 auto 타입 추론을 적용하려면 타입 자리에 auto라고만 쓰면 된다. 이때 적용되는 타입 추론 규칙은 템플릿 인수 추론과 같다.

다음 코드는 isGreaterThan100 이란 이름의 제네릭 람다 표현식을 정의하고 있다. 이 표현식을 find_if() 알고리즘에 적용하는데, 한 번은 정수 vector에 대해 또 한번은 double vector에 대해 적용한다.

// 값이 100보다 큰지 판단하는 제네릭 람다 표현식 정의
auto isGreaterThan100 = [] (auto i) { return i > 100; };

// 위에서 정의한 제네릭 람다 표현식을 정수 벡터에 적용한다.
vector<int> ints { 11, 55, 101, 200 };
auto it1 = find_if(cbegin(ints), cend(ints), isGreaterThan100);
if (it1 != cend(ints))
{
cout << "Found a value > 100: " << *it1 << endl;
}

// 위에서 정의한 제네릭 람다 표현식을 double vector에 적용한다.
vector<double> doubles { 11.1, 55.5, 200.2 };
auto it2 = find_if(cbegin(doubles), cend(doubles), isGreaterThan100);
if (it2 != cend(ints))
{
cout << "Found a value > 100: " << *it2 << endl;
}

람다 캡처 표현식

람다 캡쳐 표현식(lambda capture expression)은 표현식에 사용할 캡쳐 변수를 초기화한다. 스코프에 있는 변수 중에서 캡쳐하지 않았던 것을 람다 표현식에 가져오는데 사용할 수 있다.

예컨대 다음 코드를 보면 람다 캡쳐 표현식으로 myCapture란 변수를 ‘Pi:’로 초기화하고, 람다 표현식과 같은 스코프에 있던 pi 변수를 캡쳐한다. 참고로 myCapture처럼 비레퍼런스(non-reference) 캡쳐 변수를 캡쳐 이니셜라이저(capture initializer)로 초기화할 때는 복제 방식으로 생성된다. 그래서 const 지정자가 사라진다.

double pi = 3.1415;
auto myLambda = [myCpature = "Pi: ", pi] { cout << myCapture << pi; };

람다 캡쳐 변수는 std::move()를 비롯한 모든 종류의 표현식으로 초기화할 수 있다. unique_ptr처럼 복제할 수 없고 이동만 가능한 객체를 다룰 때 이 점을 반드시 명심해야 한다. 기본적으로 값으로 캡쳐하면 복제 방식이 적용된다. 그래서 unique_ptr를 람다 표현식에 값으로 캡쳐할 수 없다. 하지만 람다 캡쳐 표현식을 사용하면 다음과 같이 이동 방식으로 복제할 수 있다.

auto myPtr = std::make_unique<double>(3.1415);
auto myLambda = [p = std::move(myPtr)] { cout << *p; };

권장하는 방법은 아니지만 캡쳐 대상인 변수 이름과 캡쳐해서 람다 표현식 안에 사용할 변수 이름을 똑같이 정해도 된다. 위 코드를 이렇게 수정하면 다음과 같다.

auto myPtr = std::make_unique<double>(3.1415);
auto myLambda = [myPtr = std::move(myPtr)] { cout << *myPtr; };

람다 표현식을 리턴 타입으로 사용하기

std::function을 이용하면 함수가 람다 표현식을 리턴하게 만들 수 있다. 예컨대 다음 코드를 보자.

function<int(void)> multiplyBy2Lambda(int x)
{
return [x] { return 2 * x; };
}

이 함수의 본문을 보면 스코프에 있는 x라는 변수를 값으로 캡쳐하고 multiplyBy2Lambda의 인수에 2를 곱한 정숫값을 리턴하는 람다 표현식을 생성한다. 이 함수의 리턴 타입은 인수를 받지 않고 정수를 리턴하는 함수인 function<int(void)>다. 이 함수의 본문에서 정의한 람다 표현식은 함수 프로토타입과 정확히 일치한다. 변수 x는 값으로 캡쳐하기 때문에 이 함수가 람다 표현식을 리턴하기 전에 람다 표현식 안의 x는 x값의 복제본에 바인딩된다. 이 함수를 호출하는 방법은 다음과 같다.

function<int(void)> fn = multiplyBy2Lambda(5);
cout << fn() << endl;

auto 키워드를 사용하면 더 간결하게 표현할 수 있다.

auto fn = multiplyBy2Lambda(5);
cout << fn() << endl;

함수 리턴 타입 추론을 활용하면 multiplyBy2Lambda() 함수를 다음과 같이 좀 더 세련되게 표현할 수 있다.

auto multiplyBy2Lambda(int x)
{
return [x] { return 2 * x; };
}

multiplyBy2Lambda() 함수는 변수 x를 값으로 캐쳐한다([x]). 만약 다음과 같이 x를 레퍼런스로 캡쳐하면 ([&x]) 문제가 생긴다. 여기서 리턴한 람다 표현식은 대부분 이 함수가 끝난 뒤에 사용된다. 그래서 multiplyBy2Lambda() 함수의 스코프는 더는 존재하지 않기 때문에 x에 대한 레퍼런스는 이상한 값을 가리키게 된다.

auto multiplyBy2Lambda(int x)
{
return [&x] { return 2 * x; }; // 버그 발생
}

람다 표현식을 매개변수로 사용하기

std::function 타입의 함수 매개변수는 람다 표현식을 인수로 받을 수 있다. 그때 소개한 예제를 보면 process() 함수가 콜백을 람다 표현식으로 받았다. 또한 std::function 대신 함수 템플릿을 사용하는 방법도 소개했다. processTemplate() 함수 템플릿은 인수를 람다 표현식으로 받을 수 있다.

표준 라이브러리 알고리즘 활용 예제

(생략)

함수 객체

어떤 클래스의 함수 호출 연산자를 오버로딩해서 그 클래스의 객체를 함수 포인터처럼 사용하게 만들 수 있다. 이렇게 사용하는 객체를 함수 객체 또는 펑터라 부른다.

표준 라이브러리 알고리즘 중에서 find_if(), accumulate()를 비롯한 여러 알고리즘은 함수 포인터, 람다 표현식, 펑터 등을 비롯한 호출 가능 개체(callable)를 인수로 받아서 알고리즘의 동작을 변경할 수 있다.

펑터의 개념은 간단하지만 작성 과정은 상당히 번거롭다. 함수나 펑터 클래스를 생성해서 다른 것과 중복되지 않게 이름을 정한 다음 그 이름을 사용하도록 작성해야 한다. 이럴 때는 람다 표현식을 이용하여 익명 함수로 만들면 편하다.

Note) 가능하면 함수 객체보다 람다 표현식을 사용하기 바란다. 표현식이 코드를 작성하기 쉬울 뿐 아니라 읽거나 이해하기도 쉽기 때문이다.

산술 함수 객체

C++는 다섯 가지 이항 산술 연산자(plus, minus, multiplies, divides, modulus)에 대한 펑터 클래스 템플릿을 제공한다. 여기에 추가로 단항 negate도 제공한다.

이러한 클래스 템플릿을 피연산자의 타입으로 템플릿화해서 클래스를 만들면 실제 연산자에 대한 래퍼로 사용할 수 있다. 템플릿 타입의 매개 변수를 한 개 또는 두 개 받아서 연산을 수행한 뒤 결과를 리턴한다. plus 클래스 템플릿을 사용하는 방법은 다음과 같다.

plus<int> myPlus;
int res = myPlus(4, 5);
cout << res << endl;

이렇게 해서 더 나아지는 점이 없다. plus 클래스 템플릿을 사용할 필요 없이 곧바로 operator+를 사용해도 된다. 하지만 이렇게 산술 함수 객체를 사용하면 알고리즘으로 콜백을 전달하기 좋다. 산술 연산자를 곧바로 쓸 때는 이렇게 할 수 없기 때문이다.

예컨대 앞서 본 geometricMean() 함수는 accumulate()에 두 정수를 곱하는 product() 콜백을 함수 포인터로 전달하도록 구현했다. 이 부분을 다음과 같이 C++에서 제공하는 multiplies 함수 객체를 사용하도록 수정할 수 있다.

double geometricMean(const vector<int>& nums)
{
double mult = accumulate(cbegin(nums), cend(nums), 1, multiplies<int>());
return pow(mult, 1.0 / nums.size());
}

multiplies<int>()라는 표현식은 multiplies 펑터 클래스 템플릿으로부터 int 타입에 대한 인스턴스를 새로 만든다. 다른 산술 함수 객체의 기능과 사용법도 이와 비슷하다.

Caution) 산술 함수 객체는 산술 연산자에 대한 래퍼에 불과하다. 알고리즘에서 함수 객체를 콜백으로 사용하려면 반드시 컨테이너에 담긴 객체가 해당 연산(예: operator*나 operator+ 등)을 구현해야 한다.

투명 연산자 펑터

C++은 투명 연산자(transparent operator) 형태의 펑터도 제공한다. 이 펑터를 이용하면 템플릿 타입 인수를 생략해도 된다. 예컨대 multiplies<int>() 대신 multiplies<>()라고만 적어도 된다.

double geometricMean(const vector<int>& nums)
{
  double mult = accumulate(cbegin(nums), cend(nums), 1, multiplies<>());
  return pow(mult, 1.0 / nums.size());
}

투명 연산자에서 굉장히 중요한 특징은 이종 타입을 지원한다는 점이다. 다시 말해 비투명 펑터 보다 간결할 뿐만 아니라 실질적인 기능도 더 뛰어나다. 예컨대 다음 코드는 vector가 정수를 담고 있지만 투명 연산자 펑터를 사용해서 1.1이라는 double 값으로 초기화하도록 지정했다. 그러면 accumulate()는 결과를 double로 계산해서 6.6이란 값을 리턴한다.

vector<int> nums { 1, 2, 3 };
double result = accumulate(cbegin(nums), cend(nums), multiplies<>());

이 코드를 다음과 같이 비투명 연산자 펑터로 작성하면 accumulate()는 결과를 정수로 계산해서 6을 리턴한다. 이 코드를 컴파일하면 데이터 손실이 발생할 수 있다는 경고 메시지가 출력된다.

vector<int> nums { 1, 2, 3 };
double result = accumulate(cbegin(nums), cend(nums), multiplies<int>());

Note) 항상 투명 연산자 펑터를 사용하기 바란다.

비교 함수 객체

C++는 산술 함수 객체 클래스뿐만 아니라 표준 비교 연산(equal_to, not_equal_to, less, greater, less_equal, greater_equal)도 제공한다. 그중에서 less를 사용하는 방법은 앞 장에서 priority_queue와 연관 컨테이너의 원소를 비교할 때 디폴트 연산자로 지정하는 과정에서 살펴본 적 있다. 이번에는 priority_queue의 비교 기준을 변경하는 방법을 소개한다. 먼저 std::less를 디폴트 비교 연산자로 사용하는 priority_queue를 이용하는 방법은 다음과 같다.

priority_queue<int> myQueue;
myQueue.push(3);
myQueue.push(4);
myQueue.push(2);
myQueue.push(1);

while(!myQueue.empty())
{
cout << myQueue.top() << " ";
myQueue.pop();
}

// 실행 결과
// 4 3 2 1

여기서 볼 수 있듯이 less로 비교하기 때문에 큐에 담긴 원소가 내림차순으로 삭제된다. 템플릿 인수에 greater를 지정해서 비교 연산자를 변경할 수 있다. priority_queue 템플릿은 다음과 같이 정의돼 있다.

template <class T, class Container = vector<T>, class Compare = less<T>>

그런데 Compare 타입 매개변수가 마지막에 있다. 그래서 이 값을 지정하려면 컨테이너 타입 매개변수도 지정해야 한다. 앞에서 본 priority_queue가 greater를 기준으로 원소를 오름차순으로 정렬하게 만들려면 priority_queue를 다음과 같이 선언한다.

priority_queue<int, vector<int>, greater<>> myQueue;

여기서 myQueue를 투명 연산자인 greater<>로 정의했다. 비교자(비교 함수) 타입을 인수로 받는 표준 라이브러리 컨테이너를 사용할 때는 항상 투명 연산자를 사용하는 것이 좋다. 투명 연산자가 대체로 비투명 연산자보다 성능이 좋다.

예컨대 map<string>에서 스트링 리터럴로 주어진 키로 조회 연산을 수행할 때 비투명 연산자를 이용하면 불필요한 복제 연산이 발생할 수 있다. 스트링 리터럴로부터 string 인스턴스를 생성하기 때문이다. 투명 연산자로 된 비교자를 사용하면 이런 복제 연산을 피할 수 있다.

이 장에서 소개하는 알고리즘 중 몇 가지는 비교자 콜백을 지정해야 한다. 이럴 때 미리 정의된 비교자가 있으면 처리하기 편하다.

논리 함수 객체

C++은 logical_not(operator!), logical_and(operator&&), logical_or(operator||)라는 세 가지 논리 비교 연산자에 대한 함수 객체 클래스를 제공한다. 이러한 논리 연산자는 true나 false 값만 다룬다. 비트 함수 객체는 다음 절에서 설명한다.

논리 연산자에 대한 펑터는 컨테이너에 있는 부울 타입 플래그가 모두 true인지 검사하는 allTrue() 함수를 구현하는데 활용할 수 있다.

bool allTrue(const vector<bool>& flags)
{
return accumulate(begin(flags), end(flags), true, logical_and<>());
}

마찬가지로 컨테이너의 부울 플래그가 최소한 하나라도 true면 true를 리턴하는 anyTrue() 함수를 logical_or 펑터로 구현할 수 있다.

bool anyTrue(const vector<bool>& flags)
{
return accumulate(begin(flags), end(flags), false, logical_or<>());
}

Note) 여기서 소개한 allTrue()와 anyTrue() 함수는 단지 예를 보여주기 위해 만든 것이다. 표준 라이브러리는 std::all_of()와 any_of()란 알고리즘으로 이런 연산을 제공하고 있다. 게다가 단락 평가(short-circuiting, 축약 평가)도 지원하기 때문에 성능이 훨씬 뛰어나다.

비트 연산 함수 객체

C++는 bit_and(operator&), bit_or(operator|), bit_xor(operator^), bit_not(operator~)과 같은 모든 비트 연산자에 대한 함수 객체도 제공한다. 비트 연산자에 대한 펑터는 컨테이너에 담긴 모든 원소에 대해 비트 연산을 수행하는 transform() 같은 알고리즘에서 사용할 수 있다.

어댑터 함수 객체

표준에서 제공하는 함수 객체가 자신의 요구사항에 딱 맞지 않을 수 있다. 예컨대 find_if()로 어떤 값보다 작은 원소를 찾을 때 less 함수 객체를 사용할 수 없다. find_if()는 콜백을 호출할 때마다 인수를 단 하나만 지정할 수 있기 때문이다. 

이러한 단점을 보완하기 위해 어댑터 함수 객체(adaptor function object)를 제공한다. 어댑터 함수 객체는 함수 객체, 람다 표현식, 함수 포인터를 비롯한 모든 호출 가능 개체에 적용할 수 있다. 이러한 어댑터는 미약하게나마 함수 합성(functional composition)을 지원한다. 다시 말해 여러 함수를 하나로 합쳐서 원하는 기능을 구현할 수 있다.

바인더

바인더(binder)를 이용하면 호출 가능 개체의 매개변수를 일정한 값으로 묶어둘(바인딩) 수 있다. 이를 위해 <functional> 헤더 파일에 정의된 std::bind()를 이용하면 호출 가능 개체의 매개변수를 원하는 방식으로 바인딩할 수 있다. 이때 매개변수를 고정된 값에 바인딩할 수도 있고, 매개변수의 순서를 바꿀 수도 있다. 

다음과 같이 인수 두 개를 받는 func() 함수를 살펴보자.

void func(int num, string_view str)
{
cout << "func(" << num << ", " << str << ")" << endl;
}

다음 코드는 func()의 두 번째 인수를 myString이란 고정된 값으로 바인딩하도록 bind()를 사용하는 방법을 보여주고 있다. 결과는 f1()에 저장된다. 여기서는 auto 키워드를 사용했는데, C++ 표준에 bind()의 리턴 타입이 명확히 정의돼 있지 않기 때문이다.

특정한 값에 바인딩되지 않은 인수는 반드시 std::placeholders 네임스페이스에 정의된 _1, _2, _3 등으로 지정해야 한다. f1()의 정의에서 _1은 func()를 호출할 때 f1()의 첫 번째 인수가 들어갈 지점을 지정한다. 그러면 다음과 같이 f1()에 정수 타입 인수 하나만 지정해서 호출할 수 있다.

string myString = "abc";
auto f1 = bind(func, placeholders::_1, myString);
f1(16);

// 실행 결과
// func(16, abc)

bind()로 인수의 순서를 바꿀 수도 있다. 예컨대 다음 코드와 같다. 여기서 _2는 func()를 호출할 때 f2()의 두 번째 인수가 들어갈 지점을 지정한다. 다시 말해 f2()의 첫 번째 인수는 func()의 두 번째 인수가 되고, f2()의 두 번째 인수는 func()의 첫 번째 인수가 된다.

auto f2 = bind(func, placeholders::_2, placeholders::_1);
f2("Test", 32);

// 실행결과
// func(32, Test)

앞서 설명했듯이 <functional> 헤더 파일에 std::ref()와 cref() 헬퍼 템플릿 함수가 정의돼 있다. 이를 사용하면 레퍼런스나 const 레퍼런스를 바인딩할 수 있다. 예컨대 다음과 같은 함수를 살펴보자.

void increment(int& value) {++ value; }

이 함수를 다음과 같이 호출하면 index 값이 1이 된다.

int index = 0;
increment(index);

이 함수를 다음과 같이 bind()로 호출하면 index 값이 증가하지 않는다. index의 복제본에 대한 레퍼런스가 increment() 함수의 첫 번째 매개변수로 바인딩되기 때문이다.

int index = 0;
auto incr = bind(increment, index);
incr();

다음과 같이 std::ref()로 레퍼런스를 제대로 지정하면 index 값이 증가한다.

int index = 0;
auto incr = bind(increment, ref(index));
incr();

바인딩 매개변수를 오버로딩된 함수와 함께 사용할 때 사소한 문제가 발생하 ㄹ수 있다. 예컨대 다음과 같은 두 가지 overloaded() 함수가 있다고 하자. 하나는 정수를 받고 다른 하나는 부동소수점수를 받는다.

void overloaded(int num) {}
void overloaded(float f) {}

이렇게 오버로딩된 함수에 대해 bind()를 사용하려면 둘 중 어느 함수에 바인딩할지 명시적으로 지정해야 한다. 예컨대 다음과 같이 작성하면 컴파일 에러가 발생한다.

auto f3 = bind(overloaded, placeholders::_1);  // error

부동소수점 인수를 받는 오버로딩 함수의 매개변수를 바인딩하고 싶다면 다음과 같이 작성한다.

auto f4 = bind((void(*)(float))overloaded, placeholders::_1);

bind()의 또 다른 예로 find_if() 알고리즘으로 컨테이너에서 100보다 크거나 같은 원소 중 첫 번째를 찾는 경우를 들 수 있다. 앞에 나온 perfectScore() 함수 예제에서는 find_if()에 perfectScore()에 대한 포인터를 전달하는 방식으로 처리했다. 이번에는 greater_equal이란 비교 연산 펑터와 bind()로 구현해보자. 다음 코드는 bind()로 greater_equal의 두 번째 매개변수를 100이란 고정된 값으로 바인딩한다.

// 벡터에 점수를 입력하는 코드는 이전과 같으므로 생략한다.
auto endIter = end(myVector);
auto it = find_if(begin(myVector), endIter, bind(greater_equal<>(), placeholders::_1, 100));

if (it == endIter)
{
cout << "No perfect scores" << endl;
}
else
{
cout << "Found a \"perfect\" score of " << *it << endl;
}

다음과 같이 람다 표현식으로 작성하면 훨씬 좋다.

auto it = find_if(begin(myVector, endIter, [](int i) { return i> 100; });

Caution) C++ 11이전에는 bind2nd()와 bind1st()도 제공했다. 둘 다 C++ 11부터 폐기됐고 C++ 17부터는 표준에서 완전히 삭제됐다. 이 함수 대신 람다 표현식이나 bind()를 사용하기 바란다.

부정 연산자

not_fn

부정 연산자(negator)는 바인더와 비슷하지만 호출 가능 개체의 결과를 반전시킨다는 점이 다르다. 예컨대 컨테이너에서 100보다 작은 원소 중 첫 번째를 찾고 싶다면 다음과 같이 perfectScore()에 not_fn()이란 부정 연산자 어댑터를 적용하면 된다.

// 벡터에 점수를 추가하는 부분은 이전과 같으므로 생략한다.
auto endIter = end(myVector);
auto it = find_if(begin(myVector), endIter, not_fn(perfectScore));

if (it == endIter)
{
cout << "All perfect scores" << endl;
}
else
{
cout << "Found a \"less-than-perfect"\ score of " << *it << endl;
}

not_fn() 펑터는 매개변수로 받은 호출 가능 개체의 호출 결과를 모두 반전시킨다. 참고로 이 예제를 find_if_not() 알고리즘으로 구현해도 된다.

여기서 볼 수 있듯이 펑터와 어댑터를 사용하면 코드가 좀 복잡해진다. 그래서 필자는 가능하면 펑터보다는 람다 표현식을 사용하길 권장한다. 예컨대 앞의 not_fn() 펑터로 find_if()를 호출하는 부분을 람다 표현식으로 작성하면 다음과 같다.

auto it = find_if(begin(myVector), endIter, [](int i) { return i < 100; });
not1과 not2

std::not_fn() 어댑터는 C++ 17부터 지원한다. C++ 17 버전에서는 std::not1()과 std::not2() 어댑터를 사용해야 한다. not1()에서 ‘1’은 피연산자가 반드시 하나인 단항 함수여야 한다는 것을 의미한다. 이항 함수에 대해서는 반드시 not2()를 사용해야 한다.

// vector에 점수를 입력하는 부분은 이전과 같으므로 생략
auto endIter = end(myVector);
function<bool(int)> f = perfectScore;
auto it = find_if(begin(myVector), endIter, not1(f));

자신이 직접 정의한 펑터 클래스에 not1()을 사용하려면 펑터 클래스 정의에 argument_type과 result_type이란 두 가지 typedef를 반드시 정의해야 한다. 또한 not2()를 사용하려면 펑터 클래스 정의에 first_argument_type, second_argument_type, result_type이란 세 가지 typedef를 정의해야 한다.

이러한 typedef를 제공하기 위한 가장 쉬운 방법은 인수가 하나면 unary_function을, 두 개면 binary_function을 상속해서 함수 클래스를 정의하는 것이다. 두 클래스 모두 <functional> 헤더에 정의돼 있으며, 지정한 함수의 매개변수와 리턴 타입에 대해 템플릿화 할 수 있다. 예컨대 다음과 같다.

class PerfectScore : public std::unary_function<int, bool>
{
public:
result_type operator()(const argument_type& score) const
{
return score >= 100;
}
};

이 펑터의 사용법은 다음과 같다.

auto it = find_if(begin(myVector), endIter, not1(PerfectScore());

Note) not1()과 note2()는 C++ 17부터 폐기됐다. unary_function과 binary_function도 C++ 11부터 폐기됐고 C++17 부터는 공식적으로 삭제됐다.

멤버 함수 호출하기

컨테이너에 대해 알고리즘을 적용할 때 클래스 메서드에 대한 포인터를 알고리즘으로 콜백으로 전달할 때가 있다. 예컨대 vector에 담긴 string 원소마다 empty()를 호출해서 첫 번째 공백 string을 찾는다고 하자. 이때 find_if()에 string::empty()에 대한 포인터만 전달하면 알고리즘 입장에서는 그 포인터가 일반 함수 포인터나 펑터가 아닌 메서드에 대한 포인터라는 사실을 알아낼 방법이 없다. 메서드 포인터를 호출하는 코드는 일반 함수 포인터를 호출할 때와 다르다. 메서드 포인터는 반드시 객체의 문맥에서 호출해야 하기 때문이다.

C++은 이를 위해 mem_fn()이란 변환 함수를 제공한다. 알고리즘의 콜백으로 메서드 포인터를 전달할 때 이 함수로 변환한 결과를 알고리즘의 콜백으로 전달하면 된다. 예컨대 다음 코드에서 &string::empty라고 메서드 포인터를 지정한 부분과 같다. 여기서 &string:: 부분은 생략하면 안 된다.

void findEmptyString(const vector<string>& strings)
{
auto endIter = end(strings);
auto it = find_if(begin(strings), endIter, mem_fn(&string::empty));

if (it == endIter)
{
cout << "No emtpy strings!" << endl;
}
else
{
cout << "Empty string at position: " << static_cast<int>(it = begin(strings)) << endl;
}
}

mem_fn()은 find_if()에서 콜백으로 사용할 함수 객체를 생성한다. 그러면 콜백이 호출될 때마다 인수(여기서는 스트링)에 대해 empty() 메서드가 호출된다.

컨테이너에 객체를 직접 넣지 않고 객체에 대한 포인터를 지정해도 mem_fn()은 똑같이 작동한다. 예컨대 다음과 같다.

void findEmptyString(const vector<string*>& strings)
{
auto endIter = end(strings);
auto it = find_if(begin(strings), endIter, mem_fn(&string::empty));

// 나머지 코드는 이전과 같으므로 생략
}

mem_fn()으로 findEmptyString() 함수를 구현하면 코드를 이해하기 쉽지 않을 수 있다. 이때 람다 표현식을 사용하면 훨씬 세련되고 이해하기 쉽게 표현할 수 있다.

void findEmptyString(const vector<string>& strings)
{
auto endIter = end(strings);
auto it = find_if(begin(strings), endIter, [](const string& str) { return str.empty(); });

// 나머지 코드는 이전과 같으므로 생략
}

마찬가지로 객체의 포인터를 담은 컨테이너에 대한 작업도 다음과 같이 람다 표현식으로 작성할 수 있다.

void findEmptyString(const vector<string*>& strings)
{
auto endIter = end(strings);
auto it = find_if(begin(strings), endIter, [](const string* str){ return str->empty(); });

// 나머지 코드는 이전과 같으므로 생략
}

invoke()

C++ 17부터 std::invoke()가 추가됐다. 이를 사용하면 모든 종류의 호출 가능 개체에 매개변수를 지정해서 호출할 수 있다. 다음 코드는 invoke()를 세 번 사용한다. 한 번은 일반 함수를, 한 번은 람다 표현식을, 나머지 한 번은 string 인스턴스의 멤버 함수를 호출한다.

void printMessage(string_view message) { cout << message << endl; }

int main()
{
invoke(printMessage, "Hello invoke");
invoke([](const auto& msg) { cout << msg << endl;}, "Hello invoke");
string msg = "Hello invoke";
cout << invoke(&string::size, msg) << endl;
}

invoke()의 기능만 보면 그리 유용하지 않아 보일 수 있다. 그냥 함수나 람다 표현식을 직접 호출해도 되기 때문이다. 하지만 임의의 호출 가능 개체를 호출하는 템플릿 코드를 작성할 때는 굉장히 유용하다.

함수 객체 직접 만들기

미리 정의된 펑터에서 제공하는 함수 객체나 람다 표현식으로 처리하기 힘든 작업을 수행하려면 함수 객체를 직접 작성해야 한다. 다음과 같이 간단히 작성된 함수 객체를 보자.

class myIsDigit
{
public:
bool operator()(char c) const { return ::isdigit(c) != 0; }
};

bool isNumber(string_view str)
{
auto endIter = end(str);
auto it = find_if(begin(str), endIter, not_fn(myIsDigit()));
return (it == endIter);
}

참고로 myIsDigit 클래스에서 오버로딩한 함수 호출 연산자를 반드시 const로 지정해야 이 클래스의 객체를 find_if()에 전달할 수 있다.

Caution) 표준 라이브러리 알고리즘은 펑터나 람다 표현식으로 전달된 프리디케이트를 복제해서 각각의 복제본마다 별도로 원소를 지정해서 호출할 수 있다. 그러면 이러한 프리디케이트로 인해 부작용이 발생하는 것을 방지한다. 펑터로 지정할 때는 함수 호출 연산자를 반드시 const로 지정해야 한다. 그래야 여러 번 호출해도 객체의 내부 상태를 일관성 있게 유지할 수 있다. 람다 표현식도 마찬가지이므로 mutable을 지정하면 안 된다. 

C++ 11 이전에는 함수 스코프 안에서 로컬로 정의한 클래스를 템플릿 인수로 지정할 수 없었다. 현재는 이러한 제약이 삭제됐기 때문에 다음과 같이 작성해도 된다.

bool isNumber(string_view str)
{
class myIsDigit
{
public:
bool operator()(char c) const { return ::isdigit(c) != 0; }
};

auto endIter = end(str);
auto it = find_if(begin(str), endIter, not_fn(myIsDigit()));

return it == endIter;
}

Note) 람다 표현식을 사용하면 코드를 읽기 쉽고 세련되게 작성할 수 있으므로 함수 객체보다 람다 표현식을 사용하는 것이 바람직하다. 함수 객체는 람다 표현식으로 할 수 없는 복잡한 작업을 처리할 때만 사용하기 바란다.

표준 라이브러리 알고리즘 심층 분석

반복자

반복자는 입력, 출력, 정방향, 양방향, 랜덤 액세스 등 다섯 가지 종류가 있다. 이러한 반복자에 대해 정식으로 정의한 클래스 계층은 없다. 컨테이너에 대한 구현은 표준 계층 구조에 속하지 않기 때문이다.

하지만 제공할 기능에 따라 어떤 계층에 속할지 추론할 수는 있다. 좀 더 구체적으로 설명하면 랜덤 액세스 반복자는 모두 양방향이고, 양방향 반복자는 모두 정방향이고, 정방향 반복자는 모두 입력 반복자라고 알 수 있다.

출력 반복자의 요건을 충족하는 반복자를 가변 반복자라 부르고 그렇지 않은 반복자를 상수 반복자라 부른다. 이러한 계층구조를 표현하면 아래와 같다. 여기서 계층 관계는 진짜가 아니므로 주의할 것

랜덤 액세스 -> 양방향 -> 정방향 -> 입력

알고리즘에서 사용할 반복자의 종류를 표준 방식으로 지정하려면 반복자 템플릿 타입 인수에 InputIterator, OutputIterator, ForwardIterator, BidirectionalIterator, RandomAccessIterator와 같은 이름을 지정하면 된다. 말 그대로 이름일 뿐이기 때문에 바인딩 타입 검사를 하지 않는다. 따라서 RandomAccessIterator를 인수로 받는 알고리즘에 양방향 반복자를 지정해서 호출해도 타입 검사를 거치지 않기 때문에 템플릿을 인스턴스화 할 수 있다.

하지만 랜덤 액세스 반복자의 기능을 사용하는 코드를 실행할 때 양방향 반복자를 발견하면 컴파일 에러가 발생한다. 따라서 타입 검사 기능을 제공하지 않더라도 실질적으로 타입을 엄격히 지켜야 한다. 게다가 이런 에러로 발생하는 메시지는 정확하지 않다. 

예컨대 랜덤 액세스 반복자를 지정해야 하는 sort() 알고리즘을 양방향 반복자만 제공하는 list에 적용하는 코드를 MS의 비주얼 C++ 2017에서 컴파일하면 30줄에 달하는 이상한 에러 메시지가 발생한다.

불변형 순차 알고리즘

불변형 순차 알고리즘(non-modifying sequence algorithm)이란 주어진 범위에서 원소를 검색하거나 두 범위를 서로 비교하는 함수를 말한다. 또한 개수를 세는 집계 알고리즘도 여기 속한다.

탐색 알고리즘

앞서 find()와 find_if()라는 탐색 알고리즘(검색 알고리즘)의 예를 살펴봤다. 표준 라이브러리는 순차적으로 나열된 원소를 처리하는 기본 find() 알고리즘을 다양하게 변형한 버전을 제공한다.

표준 라이브러리 알고리즘은 모두 operator==나 operator<를 디폴트 연산자로 사용한다. 또한 비교 콜백 함수를 직접 지정할 수 있도록 오버로딩된 버전도 제공한다.

(생략)

특수 탐색 알고리즘

C++ 17부터 search() 알고리즘에 원하는 탐색 알고리즘을 지정할 수 있도록 매개변수가 추가됐다. 이러한 옵션은 크게 세 가지(default_searcher, boyer_moore_searcher, boyer_moore_horspool_searcher)가 있으며 모두 <functional>에 정의돼 있다.

두 번째와 세 번째 옵션은 각각 유명한 보이어-무어(Boyer-Moore) 탐색 알고리즘과 보이어-무어-호스풀(Boyer-Moore-Horsepool) 탐색 알고리즘을 구현한 것이다. 모두 성능이 뚜어나며 방대한 텍스트에서 서브스트링을 검색하는데 주로 사용된다. 

보이어-무어 탐색 알고리즘의 성능은 다음과 같다. 여기서 N은 탐색할 대상의 크기, M은 그 안에서 찾으려는 패턴의 크기를 의미한다.

  • 패턴을 찾지 못한 경우: 최악의 복잡도는 O(N+M)
  • 패턴을 찾은 경우: 최악의 복잡도는 O(NM)

각각에 대한 최악의 복잡도는 이론적으로 분명히 나와 있다. 실전에서 이런 특수 탐색 알고리즘을 사용해보면 O(N)보다 뛰어난 준선형(sublinear) 시간이 나온다. 다시 말해 기본 탐색 알고리즘보다 훨씬 빠르다. 이렇게 준선형 시간에 실행할 수 있는 이유는 탐색 범위에 있는 문자를 모두 검사하지 않고 중간에 건너뛸 수 있기 때문이다. 또한 탐색할 패턴이 길수록 성능이 좋아진다. 탐색 대상에서 건너뛸 문자가 많아지기 때문이다.

보이어-무어와 달리 보이어-무어-호스풀 알고리즘은 초기화와 루프를 한 바퀴 도는데 걸리는 오버헤드가 적다. 하지만 최악의 복잡도는 보이어-무어 알고리즘보다 훨씬 크다. 따라서 어떤 것을 적용할지는 주어진 작업의 성격에 맞게 판단한다.

보이어-무어 탐색 알고리즘의 사용 예는 다음과 같다.

string text = "This is the haystack to search a needle in";
string toSearchFor = "needle";

auto searcher = std::boyer_moore_searcher(cbegin(toSearchFor), cend(toSearchFor));
auto result = search(cbegin(text), cend(text), searcher);

if (result != cend(text))
{
cout << "Found the needle" << endl;
}
else
{
cout << "Needle not found" << endl;
}

비교 알고리즘

주어진 범위의 원소를 비교할 때 equal(), mismatch(), lexicographical_compare()라는 세 가지 알고리즘 중 하나를 적용할 수 있다. 이 알고리즘은 비교할 범위가 속한 컨테이너가 달라도 적용할 수 있다는 장점이 있다. 예컨대 vector에 있는 원소와 list의 원소를 비교할 수 있다.

일반적으로 비교 알고리즘은 순차 컨테이너에 적용할 때 성능이 가장 뛰어나며, 각 컬렉션에서 동일한 위치에 있는 값끼리 비교하는 방식으로 실행된다. 각 알고리즘의 작동 방식은 다음과 같다.

  • equal()
    • 주어진 원소가 모두 같으면 true를 리턴한다. 이전 버전에서 equal()은 세 가지 반복자(첫 번째 범위에 대한 시작과 끝 반복자, 두 번째 범위에 대한 시작 반복자)만 인수로 받을 수 있었다. 이 버전은 비교할 범위에 속한 원소의 개수와 일치해야 한다. 그런데 C++ 14부터 네 가지 반복자(첫 번째 범위에 대한 시작과 끝 반복자, 두 번째 범위에 대한 시작과 끝 반복자)를 받도록 오버로딩된 버전이 추가됐다. 이 버전은 서로 크기가 다른 범위를 비교할 수 있다. 네 가지 반복자를 받는 버전이 훨씬 안전하기 때문에 항상 이 버전을 사용하는 것이 좋다.
  • mismatch()
    • 주어진 범위에서 일치하지 않는 범위를 가리키는 반복자를 리턴한다. equal()과 마찬가지로 세 가지 반복자를 받는 버전과 네 가지 반복자를 받는 버전이 있다. mismatch()도 마찬가지로 네 가지 반복자를 받는 버전이 훨씬 안전하므로 이 버전을 사용하기 바란다.
  • lexicographical_compare()
    • 첫 번째로 일치하지 않는 양쪽 범위의 원소 중에서 첫 번째 범위의 원소가 두 번째 범위의 원소보다 작거나, 첫 번째 범위의 원소 수가 두 번째 범위의 원소 수보다 적으면서 첫 번째 범위의 원소가 모두 두 번째 범위의 앞부분과 일치하면 true를 리턴한다. lexicographical_compare란 이름이 붙은 이유는 스트링을 나열하는 방식이 사전과 비슷하기 때문이다. 이 알고리즘은 모든 타입의 객체를 다룰 수 있도록 규칙을 확장했다.

Note) 타입이 서로 같은 컨테이너 원소 두 개를 비교할 때는 equal()이나 lexicographical_compare() 보다는 operator==나 operator<를 사용하는게 좋다. 표준 라이브러리 알고리즘은 서로 타입이 다른 컨테이너의 부분 범위나 C 스타일 배경르 비교하는데 적합하다.

(사용 예시 생략)

집계 알고리즘

불변형 집계 알고리즘에는 all_of(), any_of(), none_of(), count(), count_if()가 있다.

(사용 예시 생략)

가변형 순차 알고리즘

표준 라이브러리는 가변형 순차 알고리즘(modifying sequence algorithm)도 다양하게 제공한다. 예컨대 한 범위에 있는 원소를 다른 범위로 복제하거나 원소를 삭제하거나 주어진 범위의 원소 순서를 반대로 바꾸는 것이 있다.

가변형 알고리즘 중 일부는 원본(source)과 대상(destination) 범위를 모두 지정한다. 그래서 원본 범위에 있는 원소를 읽어서 변경한 내용을 대상 범위에 저장한다. 나머지 알고리즘은 주어진 범위에서 곧바로 수정한다. 그래서 범위를 하나만 지정해도 된다.

Caution) 가변형 알고리즘은 대상 범위에 원소를 추가하는 작업은 할 수 없다. 대상 범위에 있는 원소를 수정하거나 덮어쓸 수만 있다. 반복자 어댑터를 이용하면 대상 범위에 원소를 추가하게 만들 수는 있다.

Note) 가변형 알고리즘의 대상 범위를 map과 multimap의 범위로 지정할 수 없다. 가변형 알고리즘에 map을 적용하면 키/값 쌍으로 구성된 원소를 모두 덮어쓰기 때문이다. 그런데 map과 multimap은 키를 const로 지정하기 때문에 다른 값을 대입할 수 없다. set과 multiset도 마찬가지다. 따라서 map이나 set류의 컨테이너에 가변형 알고리즘을 적용하려면 21장에서 소개하는 추가 반복자(insert iterator)로 처리해야 한다.

transform

transform() 알고리즘의 첫 번째 버번은 주어진 범위에 있는 모든 원소마다 콜백을 적용해서 새 원소를 생성한다. 이렇게 생성된 원소는 인수로 지정한 대상 범위에 저장된다. 원본과 대상 범위를 똑같이 지정하면 그 범위 안에 담긴 원소를 그 원소에 대해 콜백을 호출할 결과로 대체할 수 있다. 이때 원본 범위의 시작과 끝 반복자, 대상 범위의 시작 반복자, 콜백을 매개변수로 지정한다. 예컨대 vector의 모든 원소에 100씩 더하려면 다음과 같이 작성한다.

vector<int> myVector;
populateContainer(myVector);

cout << "The vector contains:" << endl;
for (const auto& i : myVector) { cout << i << " "; }
cout << endl;

transform(begin(myVector), end(myVector), begin(myVector), [](int i) { return i + 100; });

cout << "The vector contains:" << endl;
for (const auto& i : myVector) { cout << i << " "; }

transform()의 또 다른 버전은 주어진 범위의 원소 쌍에 대해 이항 함수를 호출한다. 그래서 첫 번째 범위에 대한 시작과 끝 반복자, 두 번째 범위에 대한 시작 반복자, 대상 범위에 대한 시작 반복자를 인수로 지정해야 한다.

다음 예는 vector를 두 개 생성해서 transform()으로 두 vector에서 같은 위치의 원소끼리 더한 결과를 첫 번째 vector에 저장하는 방법을 보여준다.

vector<int> vec1, vec2;

cout << "Vector1:" << endl;
populateContainer(vec1);

cout << "Vector2:" << endl;
populateContainer(vec2);

if (vec2.size() < vec1.size())
{
cout << "Vector2 should be at least the same size as vector1" endl;
return 1;
}

// 컨테이너의 내용을 출력하는 람다 표현식을 만든다.
auto printContainer = [](const auto& container) {
for (auto& i : container) { cout << i << " "; }
cout << endl;
};

cout << "Vector1: ";
printContainer(vec1);

cout << "Vector2: ";
printContainer(vec2);

transform(begin(vec1), end(vec1), begin(vec2), begin(vec1), [](int a, int b){ return a + b; });

cout << "Vector1: ";
printContainer(vec1);

cout << "Vector2: ";
printContainer(vec2);

Note) transform()을 비롯한 가변형 알고리즘 중에는 대상 범위의 마지막 바로 다음 번째 항목을 가리키는 반복자를 리턴하는 것이 많다. 이 책에 나온 예제는 대부분 리턴값을 무시한다.

copy

copy() 알고리즘은 한 범위의 원소를 다른 범위로 복제한다. 이때 주어진 범위의 첫 버째 원소부터 시작해서 마지막 원소까지 순차적으로 처리한다. 원본과 대상 범위는 반드시 달라야 하지만 일정한 제약사항(예컨대 copy(b, e, d)에서 d가 b보다 앞에 나온다면) 중첩돼도 된다. 하지만 d가 [b, e) 범위 안에 있으면 어떤 결과가 나올지 알 수 없다.

다른 가변형 알고리즘과 마찬가지로 copy()도 대상 범위에 원소를 추가할 수 없다. 기존에 있던 원소를 그냥 덮어쓰기만 한다. 

여기서는 copy()를 사요하는 간단한 예제를 소개한다. 여기서 copy()는 대상 컨테이너에 공간이 충분한지 확인하는 작업을 vector의 resize() 메서드로 처리한다. 그런 다음 vec1의 원소를 모두 vec2로 복제한다.

vector<int> vec1, vec2;
populateContainer(vec1);

vec2.resize(size(vec1));
copy(cbegin(vec1), cend(vec1), begin(vec2));
for (const auto& i : vec2) { cout << i << " "; }

copy_backward()란 알고리즘도 제공한다. 이 알고리즘은 원본의 마지막 원소부터 시작 원소 순으로 복제한다. 다시 말해 원본의 마지막 원소를 복제해서 대상의 마지막 우너소에 저장하고 그다음에는 바로 전 원소를 처리하는 식으로 거슬러 올라가며 복제한다. 

copy_backward()도 원본과 대상 범위가 서로 달라야 하며, 일정한 제약 조건을 만족하면 중첩해도 된다. 앞의 예제를 copy()가 아닌 copy_backward()로 복제하도록 수정하면 다음과 같다. 여기서 세 번째 인수를 begin(vec2)가 아닌 end(vec2)로 지정한 점에 주목한다.

copy_backward(cbegin(vec1), cend(vec1), end(vec2));

copy_if() 알고리즘은 반복자 두 개로 지정한 입력 범위, 반복자 하나로 지정한 출력 대상 범위 그리고 프리디케이트를 인수로 받아 처리한다. 이 알고리즘은 지정한 프리디케이트를 만족하는 원소를 모두 대상 범위로 복제한다. 단 복제 과정에서 컨테이너를 생성하거나 확장하지는 않는다. 기존 원소를 바꾸기만 한다. 따라서 대상 범위는 복제할 원소를 모두 담을 수 있도록 충분히 커야 한다.

물론 원소를 복제한 뒤에는 마지막 원소를 복제한 지점 이후의 공간은 삭제하는 것이 좋다. 이를 위해 copy_if()는 대상 범위에 마지막으로 복제한 원소의 바로 다음 지점을 가리키는 반복자를 리턴한다. 다음 코드는 짝수만 vec2에 복제하는 예를 보여준다.

vector<int> vec1, vec2;
populateContainer(vec1);
vec2.resize(size(vex1));

auto endIterator = copy_if(cbegin(vec1), cend(vec1), begin(vec2), [](int i) { return i % 2 == 0; });
vec2.erase(endIterator, end(vec2));
for (const auto& i : vec2) { cout << i << " "; }

copy_n() 알고리즘은 원본에서 n개의 원소를 대상으로 복제한다. copy_n()의 첫 번째 매개변수는 시작 반복자, 두 번째 매개변수는 복제할 원소 수를 지정하는 정수, 세 번째 매개변수는 대상 반복자다.

copy_n() 알고리즘은 경곗값 검사를 하지 않는다. 따라서 시작 반복자부터 원소 수만큼 위칫값을 하나씩 증가하면서 복제해도 end()를 초과하지 않도록 검사하는 코드를 직접 작성해야 한다. 그렇지 않으면 예상치 못한 결과가 나올 수 있다. 예컨대 다음과 같다.

vector<int> vec1, vec2;
populateContainer(vec1);
size_t cnt = 0;
cout << "Enter number of elements you want to copy: ";
cin >> cnt;
cnt = min(cnt, size(vec1));
vec2.resize(cnt);
copy_n(cbegin(vec1), cnt, begin(vec2));
for (const auto& i : vec2) { cout << i << " "; }

move

표준 라이브러리는 move()와 move_backward()라는 두 가지 이동 알고리즘을 제공한다. 둘 다 9장에서 설명한 이동 의미론을 적용한다. 그러므로 이 알고리즘을 적용할 컨테이너의 원소 타입을 직접 정의하려면 원소 타입에 대한 클래스에 반드시 이동 대입 연산자를 구현해야 한다.

구체적인 방법은 다음 코드와 같다. 여기서 main() 함수는 먼저 MyClass 객체를 세 개 담은 vector를 생성한다. 그리고 인수를 하나만 받는 버전의 move() 함수로 좌측값(lvalue)을 우측값(rvalue)으로 변환한다. 이 함수는 <utility> 헤더 파일에 정의돼 있다. 반면 인수를 세 개 받는 표준 라이브러리의 move() 알고리즘은 컨테이너 사이에 원소를 이동시킨다. 이동 대입 연산자를 구현하고 인수를 하나만 받는 버전의 std::move() 사용법은 9장에서 설명했다.

class MyClass
{
public:
MyClass() = default;
MyClass(const MyClass& src) = default;
MyClass(string_view str) : mStr(str) {}
virtual ~MyClass() = default;

// 이동 대입 연산자
MyClass& operator=(MyClass&& rhs) noexcept
{
if (this == &rhs)
return;

mStr = std::move(rhs.mStr);
cout << "Move operator= (mStr=" << mStr << ")" << endl;
return *this;
}

void setString(string_view str) { mStr = str; }
string_view getString() const { return mStr; }

private:
string mStr;
};

int main()
{
vector<MyClass> vecSrc { MyClass("a"), MyClass("b"), MyClass("c") };
vector<MyClass> vecDst(vecSrc.size());
move(begin(vecSrc), end(vecSrc), begin(vecDst));
for (const auto& c : vecDst) { cout << c.getString() << " "; }
return 0;
}

Note) 9장에서 설명했듯이 move 연산을 수행하는 동안에는 원본 객체 중 일부는 유효한 상태에 있고 나머지는 불확실한 상태에 있게 된다. 앞의 예제에서 vecSrc에 대해 move 연산을 수행한 뒤 여기 담긴 모든 원소를 확실한 상태로 만들지 않았다면 그 원소를 사용하면 안 된다. 예컨대 아무런 사전 조건 없이 그 객체에 대해 setString()과 같은 메서드를 호출하면 안 된다.

move_backward() 알고리즘의 작동 방식도 move()와 비슷하다. 단 마지막 원소부터 첫 번째 원소 순으로 원소를 이동시킨다는 점이 다르다. move()와 move_backward()는 모두 일정 요건을 만족한다면 원본과 대상 범위가 겹쳐도 된다. 이 요건은 copy()와 copy_backward()의 조건과 같다.

replace

replace()와 replace_if() 알고리즘은 주어진 범위에서 값이나 프리디케이트로 지정한 조건에 일치하는 원소를 새 값으로 교체한다. 예컨대 replace_if()는 첫 번째와 두 번째 매개변수로 컨테이너의 원소 범위를 지정한다. 세 번째 매개변수는 true나 false를 리턴하는 함수나 람다 표현식이다. 여기서 true가 리턴되면 컨테이너의 값을 네 번째 매개변수로 지정한 값으로 교체하고, false로 리턴되면 원래대로 놔둔다.

다음과 같이 컨테이너에서 홀수 값을 가진 원소를 모두 0으로 교체하는 코드를 살펴보자.

vector<int> vec;
populateContainer(vec);
replace_if(begin(vec), end(vec), [](int i){ return i % 2 != 0; }, 0);
for (const auto& i : vec) { cout << i << " "; }

replace()와 replace_if()를 각각 변형한 replace_copy()와 replace_copy_if()도 있다. 이 알고리즘은 다른 대상 범위에 교체 결과를 복제한다. 새 원소를 충분히 담을 정도로 대상 범위가 커야 한다는 정메서 copy()와 비슷하다.

remove

 주어진 범위에서 특정한 조건을 만족하는 원소를 삭제하는 경우를 생각해 보자. 한 가지 방법은 표준 라이브러리 문서에 현재 컨테이너가 erase() 메서드를 제공한다고 나와 있다면 모든 원소에 대해 반복문을 돌면서 조건에 일치하는 원소에 대해 erase()를 호출하는 것이다. erase() 메서드를 제공하는 컨테이너의 예로 vector가 있다.

하지만 vector 컨테이너를 이렇게 처리하면 굉장히 비효율적이다. vector에서 메모리를 연속적으로 사용하기 위해 메모리 연산이 상당히 ㅁ낳이 발생하기 때문에 제곱 복잡도의 성능이 나온다. 게다가 에러가 발생하기도 쉽다.

(예시 생략)

이렇게 구현하면 성능이 상당히 떨어지기 때문에 바람직하지 않다. 이 문제는 소위 remove-erase 패턴으로 구현하는 것이 좋다. 그러면 선형 시간에 처리할 수 있다.

표준 라이브러리 알고리즘은 컨테이너를 다룰 때 반복자 인터페이스로만 접근한다. 그래서 remove() 알고리즘은 컨테이너에서 원소를 직접 삭제하지 않고, 주어진 값이나 프레디케이트를 만족하는 원소를 그렇지 않은 원소와 교체한다. 이 작업은 이동 대입 연산으로 처리한다.

그러면 주어진 범위가 두 집합으로 분할된다. 하나는 그대로 유지할 원소 집합이고, 다른 하나는 삭제할 원소 집합이다. remove()는 삭제할 범위의 첫 번째 원소를 가리키는 반복자를 리턴한다. 이 원소를 컨테이너에서 실제로 삭제하려면 먼저 remove() 알고리즘부터 적용한 뒤 리턴된 반복자를 이용하여 컨테이너에 대해 erase()를 호출해서 반복자가 가리키는 원소들을 모두 지운다. 

이것이 remove-erase 패턴이다. 예컨대 string 원소에 대한 vector에서 공백 string을 제거하는 함수를 살펴보자.

void removeEmptyStrings(vector<string>& strings)
{
auto it = remove_if(begin(strings), end(strings), [](const string& str){ return str.empty(); });

// 제거된 원소를 모두 지운다.
strings.erase(it, end(strings));
}

int main()
{
vector<string> myVector = { "", "one", "", "two", "three", "four" };

for (auto& str : myVector) { cout << "\"" << str << "\"";
cout << endl;

removeEmptyStrings(myVector);

 for (auto& str : myVector) { cout << "\"" << str << "\"";
cout << endl;

return 0;
}

Caution) remove-erase 패턴으로 구현할 때 반드시 erase()의 두 번째 인수를 지정해야 한다. 깜박잊고 erase()에 두 번째 인수를 지정하지 않으면 컨테이너에서 원소 하나만 삭젷나다. 즉, 첫 번째 인수로 전달한 반복자가 가리키는 원소만 삭제된다.

remove()와 remove_if()를 변형한 remove_copy()와 remove_copy_if()란 알고리즘도 있다. 이 알고리즘은 원본 범위를 변경하지 않는다. 대산 원소를 모두 다른 대상 범위에 복제한다. 대상 범위에 새 원소를 추가할 만큼 공간이 충분해야 한다는 점에서 copy()와 비슷하다.

Note) remove() 계열의 함수는 원소를 제거하는 과정에서 컨테이너에 남아 있는 원소의 순서를 그대로 유지한다는 점에서 안정적인 알고리즘이라 부른다.

unique

unique() 알고리즘은 remove()의 특수한 버전으로서, 같은 원소가 연달아 나오는 부분을 모두 삭제한다. list 컨테이너는 unique()라는 메서드를 통해 이 알고리즘과 똑같은 기능을 제공한다. unique()는 주로 정렬 컨테이너에 대해 사용하지만, 비정렬 컨테이너에도 적용할 수 있다.

unique()의 기본 버전은 그 자리에서 직접 원소를 제거하지만, 대상 범위에 결과를 복제하는 unique_copy()라는 버전도 있다.

sample

sample() 알고리즘은 인수로 지정한 원본 범위에서 무작위로 n개의 원소를 골라서 리턴하고 이를 대상 범위에 저장한다. 이 알고리즘은 다섯 가지 매개변수를 받는다.

  • 샘플링할 범위를 나타내는 시작과 끝 반복자
  • 무작위로 고른 원소를 저장할 대상 범위의 시작 반복자
  • 고를 원소의 수
  • 무작위수 생성 엔진
vector<int> vec { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
const size_t numberOfSamples = 5;
vector<int> samples(numberOfSamples);

random_device seeder;
const auto seed = seeder.entropy() ? seeder() : time(nullptr);
default_random_engine engine(static_cast<default_random_engine::result_type>(seed));

for (int i = 0; i < 6; i++)
{
sample(cbegin(vec), cend(vec), begin(samples), numberOfSamples, engine);

for (const auto& sample : samples) { cout << sample << " "; }
cout << endl;
}

reverse

reverse() 알고리즘은 주어진 범위에 있는 원소의 순서를 반대로 바꾼다. 범위의 첫 번째 원소를 마지막 원소와 바꾸고, 두 번째 원소를 끝에서 두 번째 원소와 바꾸는 식으로 진행한다.

reverse()의 기본 버전은 원본을 바로 수정하며 범위를 표현하는 시작 반복자와 끝 반복자를 인수로 받는다. 이와 달리 reverse_copy()는 결과를 대상 범위에 복제하며 원본 범위에 댛나 시작과 끝 반복자와 대상 범위에 대한 시작 반복자를 인수로 받는다. 여기서 대상 범위는 반드시 새 원소를 담을 만큼 공간이 넉넉해야 한다.

shuffle

shuffle() 알고리즘은 주어진 범위의 원소를 무작위 순으로 재정렬하며 성능은 선형 시간이다. 이 알고리즘은 카드를 섞는 것과 같은 작업에 유용하다. shuffle()은 재정렬할 범위를 나타내느 시작과 끝 반복자, 무작위수를 생성하는 방법을 지정하는 균등 분포 무작위수 생성기(uniform random number generator)를 인수로 받는다.

연산 알고리즘

표준 라이브러리에서 제공하는 연산 알고리즘은 for_each()와 for_each_n() 뿐이다. for_each_n()은 C++ 17부터 새로 추가됐다.

for_each() 알고리즘은 주어진 범위에 있는 원소마다 콜백을 실행시키고, for_each_n() 알고리즘은 주어진 범위에 있는 원소 중에서 첫 번째부터 n 번째 원소까지 콜백을 실행한다. 이때 컨테이너의 원소에 대해 처리할 작업은 간단한 함수 콜백이나 람다 표현식으로 정의한다. 

여기서는 단지 이런 알고리즘이 있다는 사실을 알려주기 위해 소개했다. 대부분 범위 기반 for 문을 사용하는 것이 코드를 작성하기도 쉽고 읽기도 쉽다.

for_each

다음 예제는 제네릭 람다 표현식으로 map에 있는 원소를 화면에 출력한다.

map<int, int> myMap = { { 4, 40 }, { 5, 50 }, { 6, 60 } };
for_each(cbegin(myMap), cend(myMap), [](const auto& p) { cout << p.first << "->" << p.second << endl; });

(이하 예시 생략)

for_each_n

for_each_n() 알고리즘은 주어진 범위의 시작 반복자, 반복할 원소 수, 함수 콜백을 인수로 받아서 ‘시작 지점 + n’ 위치를 가리키는 반복자를 리턴한다. 이 알고리즘도 경곗값 검사를 하지 않는다.

예컨대 map의 첫 번째와 두 번째 원소에 대해서만 반복하려면 다음과 같이 작성한다.

map<int, int> myMap = { { 4, 40 }, { 5, 50 }, { 6, 60 } };
for_each_n(cbegin(myMap), 2, [](const auto& p) { cout << p.first << "->" << p.second << endl; })

swap()과 exchange() 알고리즘

swap()

std::swap()은 두 값을 효율적으로 맞바꾼다. 이때 가능하다면 이동 의미론을 적용한다.

int a = 11;
int b = 22;
swap(a, b);

exchange()

std::exchage()는 기존 값을 새 값으로 교체한 후 기존 값을 리턴한다.

int a = 11;
int b = 22;
int returnedValue = exchange(a, b);

exchange()는 이동 대입 연산자를 구현할 때 유용하다. 이동 대입 연산자는 원본 객체에서 대상 객체로 데이터를 이동해야 한다. 이때 원본 객체에 있던 데이터를 널로 만들어야 할 때가 많은데 대부분 다음 코드에 나온 방식으로 처리한다. 여기서 Foo에 어떤 메모리에 대한 포인터를 갖는 mPtr란 데이터 멤버가 있다고 하자.

Foo& operator=(Foo&& rhs) noexcept
{
// 자신을 대입하는 것이 아닌지 검사한다.
if (this == &rhs) { return *this; }

// 예전 메모리를 해제한다.
delete mPtr;
mPtr = nullptr;

// 데이터를 이동시킨다.
mPtr = rhs.mPtr; // 데이터를 원본에서 대상으로 옮긴다.
rhs.mPtr = nullptr; // 원본에 있던 데이터를 널로 만든다.

return *this;
}

위 메서드의 마지막에서 mPtr과 rhs.mPtr에 대입하는 연산을 다음과 같이 exchange()로 구현할 수 있다.

Foo& operator=(Foo&& rhs) noexcept
{
// 자신을 대입하는 것이 아닌지 검사한다.
if (this == &rhs) { return *this; }

// 예전 메모리를 해제한다.
delete mPtr;
mPtr = nullptr;

// 데이터를 이동시킨다.
mPtr = exchange(rhs.mPtr, nullptr); // 이동 후 널로 만든다.

return *this;
}

분할 알고리즘

partition_copy()는 원본에 있는 원소를 복제해서 서로 다른 두 대상으로 분할한다. 이때 둘 중 어느 대상에 원소를 보낼지는 프레디케이트의 실행 결과가 true냐 false냐에 따라 달라진다.

partition_copy()는 반복자 쌍을 리턴한다. 그중 하나는 첫 번째 대상 범위에서 마지막으로 복제한 원소의 바로 다음 지점을 가리키는 반복자고 다른 하나는 두 번째 대상에서 마지막으로 복제한 원소의 바로 다음 지점을 가리키는 반복자다. 이렇게 리턴된 반복자를 erase()와 함께 사용해서 두 대상 범위에서 초과된 원소를 삭제하게 만들 수 있다.

구체적인 방법은 앞서 본 copy_if() 예제와 같다. 다음 코드는 사용자로부터 정수의 개수를 입력받아서 대상 범위를 짝수에 대한 vector와 호룻에 대한 vector로 분할한다.

vector<int> vec1, vecOdd, vecEven;
populateContainer(vec1);
vecOdd.resize(size(vec1));
vecEven.resize(size(vec1));

auto pairIters = partition_copy(cbegin(vec1), cend(vec1), begin(vecEven), vegin(vecOdd), [](int i){ return i % 2 == 0; });

vecEven.erase(pairIters.first, end(vecEven));
vecOdd.erase(pairIters.second, end(vecOdd));

cout << "Even numbers: ";
for (const auto& i : vecEven) { cout << i << " "; }
cout << "Odd numbers: ";
for (const auto& i : vecOdd) { cout << i << " "; }

partition() 알고리즘은 프레디케이트에서 true를 리턴하는 원소가 false를 리턴하는 원소보다 앞에 나오도록 정렬한다. 이때 두 개로 분할된 범위 내에서는 원래 순서가 유지되지 않는다. 다음 코드는 vector에 있는 원소 중 짝수를 모두 홀수 앞에 나오도록 분할하는 예를 보여준다.

vector<int> vec;
populateContainer(vec);
paritition(begin(vec), end(vec), [](int i){ return i % 2 == 0; });
cout << "Partitioned result: ";
for (const auto& i : vec) { cout << i << " "; }

정렬 알고리즘

정렬 알고리즘은 컨테이너에 담긴 원소가 특정한 조건을 기준으로 순서에 맞게 유지하도록 재배치 한다. 그래서 순차 컨테이너에만 정렬 알고리즘을 적용할 수 있다. 항상 원소를 정렬된 상태로 유지하는 정렬 연관 컨테이너에는 적용할 수 없다. 비정렬 연관 컨테이너에도 적용할 수 없다. 원래부터 정렬을 하지 않기 때문이다.

list나 forward_list와 같은 일부 컨테이너는 자체적으로 정렬 메서드를 제공한다. 그래서 제네릭 정렬 알고리즘보다 자체 메서드로 구현하는 것이 훨씬 효율적이다. 제네릭 정렬 알고리즘은 주로 vector, deque, array, C 스타일 배열에 적합하다.

sort() 알고리즘은 주어진 범위에 있는 원소를 O(N log N) 시간에 정렬한다. sort()는 기본적으로 operator<를 이용해서 주어진 범위를 오름차순으로 정렬한다. 순서를 바꾸고 싶다면 greater와 같은 다른 비교자를 지정하면 된다.

sort()를 변형한 stable_sort()란 알고리즘도 있다. 이 알고리즘은 주어진 범위에서 같은 원소에 대해서는 원본에 나온 순서를 그대로 유지한다. 그러므로 sort() 알고리즘보다 성능이 좀 떨어진다.

vector<int> vec;
populateContainer(vec);
sort(begin(vec), end(vec), greater<>());

또한 표준 라이브러리는 is_sorted(), is_sorted_until()도 제공한다. is_sorted()는 주어진 범위가 정렬된 상태면 true를 리턴하고, is_sorted_until()은 반복자를 리턴하는데, 이 반복자 앞에 나온 원소까지는 모두 정렬된 상태다.

이진 탐색 알고리즘

탐색 알고리즘 중에는 정렬된 시퀀스에 대해서만 적용하거나 검색 대상을 기준으로 분할된 시퀀스에 대해서만 적용하는 이진 탐색 알고리즘이 있다. 이러한 알고리즘에는 binary_search(), lower_bound(), upper_bound(), equal_range() 등이 있다. 

lower_bound() 알고리즘은 정렬된 범위에서 주어진 값보다 작지 않은 원소 중에서 첫 번째 원소를 찾는다. 이 알고리즘은 주로 정렬된 vector에 새 값을 추가해도 계속 정렬된 상태를 유지할 수 있는 적절한 추가 지점을 찾을 때 사용한다.

(예시 코드 생략)

binary_search() 알고리즘은 선형 시갑노다 빠른 로그 시간에 원소를 검색한다. 이 알고리즘은 탐색을 범위를 지정하는 시작과 끝 반복자, 탐색할 값 그리고 옵션으로 비교 콜백을 인수로 받는다. 주어진 범위에서 값을 찾으면 true를 리턴하고 그렇지 않으면 false를 리턴한다.

(예시 코드 생략)

집합 알고리즘

집합 알고리즘은 정렬된 범위라면 어떤 것에도 적용할 수 있다. includes() 알고리즘은 부분집합을 판별하는 표준 함수로서 인수로 주어진 두 (정렬된) 범위 중에서 한쪽의 원소가 다른 쪽 범위에 모두 포함되는지 검사한다. 포함 관계를 판단할 때는 순서를 고려하지 않는다.

set_union(), set_intersection(), set_difference(), set_symmetric_difference() 알고리즘은 각각 수학의 집합에서 합집합(union), 교집합(intersection), 차집합(difference), 대칭 차집합(symmetric difference)을 구현한 것이다.

Caution) 집합 연산을 수행할 때 연산의 결과를 담을 범위는 연산 결과로 나온 원소를 모두 담을만큼 충분히 크게 지정해야 한다. set_union()과 set_symmetric_difference()의 결과를 담으려면 적어도 입력한 두 범위의 크기를 더한 만큼의 공간을 확보해야 한다. set_intersection()의 결과를 담으려면 두 입력 범위의 크기 중 작은 것만큼 set_difference()의 결과를 담으려면 첫 번째 입력 범위의 크기만큼 확보해야 한다.

Caution) set과 같은 연관 컨테이너에서 구한 반복자 범위에는 결과를 저장할 수 없다. 연관 컨테이너는 키를 변경할 수 없기 때문이다.

(예시 코드 생략)

merge() 알고리즘을 사용하면 정렬된 두 범위를 하나로 합칠 수 있다. 이때 정렬 순서는 그대로 유지된다. 그 결과 두 범위에 있던 원소를 모두 담은 정렬된 범위가 리턴되며, 선형 시간에 처리된다. 이 알고리즘은 다음과 같은 매개변수를 받는다.

  • 첫 번째 원본 범위에 대한 시작과 끝 반복자
  • 두 번째 원본 범위에 대한 시작과 끝 반복자
  • 대상 범위에 대한 시작 반복자
  • 비교 연산을 수행하는 콜백(옵션)

두 범위를 하나로 합친 결과를 sort()로 정렬해도 merge()와 똑같은 효과를 낼 수 있지만 성능이 O(N log N)이므로 선형 복잡도를 갖는 merge()에 비해 성능이 떨어진다.

Caution) merge()의 결과를 담을 수 있도록 대상 범위의 공간을 넉넉히 확보해야 한다.

(예시 코드 생략)

최대/최소 알고리즘

min()과 max() 알고리즘은 모든 타입의 원소를 operator< 또는 사용자가 정의한 이항 프리디케이트로 비교해서 각각 최소 원소와 최대 원소에 대한 const 레퍼런스를 리턴한다. minmax() 알고리즘은 두 개 이상의 원소 중에서 최솟값과 최댓값을 쌍으로 묶어서 리턴한다. 이러면 최대/최소 알고리즘은 반복자를 매개변수로 리턴받지 않는다.

반면 min_element(), max_element(), minmax_element()는 반복자로 지정한 범위에 대해 주어진 작업을 처리한다.

Note) 떄로는 비표준 매크로로 최솟값이나 최댓값을 구하는 코드를 볼 떄가 있다. 예컨대 GNU C 라이브러리(glibc)는 MIN()과 MAX()라는 매크로를 제공하고, 윈도우에서도 Windows.h 헤더 파일에 min()과 max()란 이름의 매크로를 제공한다. 이들은 매크로이기 때문에 주어진 인수를 두 번 평가할 가능성이 있는 반면 std::min()과 std::max()는 주어진 인수를 단 한 번만 평가한다. 항상 C++ 버전의 std::min()과 std::max()를 사용하기 바란다.

std::clamp()는 <algorithm>에 정의된 간단한 헬퍼 함수로서, 주어진 값(v)이 최솟값(lo)과 최댓값(hi) 사이에 있는지 검사한다. v < lo면 lo에 대한 레퍼런스를 리턴하고, v > hi면 hi에 대한 레퍼런스를 리턴한다. 나머지 경우에는 v에 대한 레퍼런스를 리턴한다.

(예시 코드 생략)

병렬 알고리즘

C++ 17부터 성능 향상을 위한 병렬 처리 알고리즘이 표준 라이브러리에 60개 이상 추가됐다. 대표적인 예로 for_each(), all_of(), copy(), count_if(), find(), replace(), search(), sort(), transform() 등이 있다. 병렬 실행을 지원하는 알고리즘은 소위 실행 정책(execution policy)이라 부르는 옵션을 첫 번째 매개변수로 받는다.

이러한 실행 정책을 이용해서 주어진 알고리즘을 병렬로 실행할지 아니면 벡터 방식으로 순차적으로 처리할지 결정한다. 실행 정책은 크게 세 가지가 있으며, 각 타입마다 전역 인스턴스도 있다. 모두 <execution> 헤더 파일의 std::execution 네임스페이스 아래에 정의돼 있다.

실행 정책 타입 전역 인스턴스 설명
sequenced_policy seq 병렬로 실행하지 않는다.
parallel_policy par 병렬로 실행한다.
parallel_unsequenced_policy par_unseq 병렬 실행과 벡터 실행 모두 가능하다. 또한 슬드 사이에 실행을 이어서 할 수 있다.

표준 라이브러리의 구현만다 실행 정책을 얼마든지 추가할 수 있다.

여기서 parallel_unsequenced_policy로 알고리즘을 실행하는 경우에 대해 좀 더 설명할 필요가 있다. 콜백을 호출하는 함수 호출은 순차적이지 않고 서로 교차해서(interleaved) 실행될 수 있다. 다시 말해 함수 콜백에서 할 수 있는 일에 제약사항이 많다.

예컨대 메모리를 할당하거나 해제할 수 없고, 뮤텍스를 획득할 수 없고, 잠금에 재약 없는 std::atomic을 사용할 수 없다. 나머지 정책은 함수 호출을 순차적으로 실행할 수 있지만 정확한 순서는 보장할 수 없다. 이런 정책은 함수 콜백에서 할 수 있는 일에 대해 제약사항을 두지 않는다.

병렬 ㅇ라고리즘은 데이터 경쟁(data race)이나 데드락(deadlock)에 대한 대비책을 따로 제공하지 않기 때문에 알고리즘을 병렬로 실행할 때 이러한 상황에 직접 대처해야 한다. 데이터 경쟁이나 데드락을 방지한느 방법은 23장에서 자세히 설명한다.

예컨대 vector에 담긴 원소들을 병렬 정책을 지정해서 정렬하려면 다음과 같이 작성한다.

sort(std::execution::par, begin(myVector), end(myVector));

수치 처리 알고리즘

inner_product

inner_product()는 <numeric> 헤더 파일에 정의돼 있으며, 두 시퀀스(벡터)의 내적을 구한다.

(예시 코드 생략)

iota

iota() 알고리즘도 <numeric> 헤더 파일에 정의돼 있으며, 주어진 범위에서 주어진 값으로 시작해서 operator++로 값을 순차적으로 생성하는 방식으로 값에 대한 시퀀스를 만든다. 다음 코드는 이 알고리즘을 정수 타입 원소에 대한 vector에 적용하는 예를 보여준다. 참고로 정수가 아닌 다른 타입에 대해서도 operator++만 구현돼 있으면 얼마든지 적용할 수 있다.

vector<int> vec(10);
iota(begin(vec), end(vec), 5);
for (auto& i : vec) { cout << i << " "; }

// 실행 결과
// 5 6 7 8 9 10 11 12 13 14

gcd()와 lcm()

인수로 지정한 두 정수에 대해 gcd()는 최대공약수(greatest common divisor), lcm은 최소공배수(least common multiple)을 리턴한다. 둘다 <numeric>에 정의돼 있다.

(예시 코드 생략)

reduce()

std::accumulate() 알고리즘은 표준 라이브러리 알고리즘 중에서 병렬 실행을 지원하지 않는 몇 안되는 것 중 하나다. 하지만 새로 추가된 std::reduce() 알고리즘에 병렬 실행 옵션을 지정해서 주어진 값의 합으로 구할 수 있다. 예컨대 다음에 나온 두 줄 모두 벡터의 합을 구하지만, reduce()는 병렬 및 벡터 버전으로 구동하여 앞줄보다 실행 속도가 훨씬 빠르다. 성능 향상의 폭은 입력 범위가 클수록 두드러진다.

double result1 = std::accumulate(cbegin(vec), cend(vec), 0.0);
double result2 = std::reduce(std::execution::par_unseq, cbegin(vec), cend(vec));

accumulate()와 reduce()는 초깃값이 Init이고, 이항 연산자 \Theta 가 주어졌을 때 다음 수식에 따라 [x_{0}, x_{n}) 범위에 있는 원소의 합을 구한다.

Init \Theta x_{0} \Theta x_{1} ... \Theta x_{n-1}

transform_reduce()

std::inner_product() 역시 병렬 실행을 지원하지 않는다. 이 알고리즘 대신 transform_reduce()를 사용하면 옵션으로 병렬 실행을 지정해서 내적을 계산할 수 있다. 사용법은 reduce()와 거의 같다.

transform_reduce()는 초깃값이 Init이고 단항 함수 f 와 이항 연산자 \Theta 가 주어졌을 때 다음 수식에 따라 [x_{0}, x_{n}) 범위에 있는 원소의 합을 구한다.

Init \Theta f(x_{0}) \Theta f(x_{1}) ... \Theta f(x_{n-1})

스캔 알고리즘

C++ 17부터 네 가지 스캔 알고리즘(exclusive_scan(), inclusive_scan(), transform_exclusive_scan(), transform_inclusive_scan())을 제공한다.

다음 표는 [x_{0}, x_{n}) 범위에 있는 원소에 대해 초깃값으로 Init(partial_sum()에 대해서는 0)을 지정하고, 연산자로 \Theta 를 지정했을 때 [y_{0}, y_{n}) 범위에 있는 원소의 합을 exclusive_scan()으로 구하는 과정과 inclusive_scan()/partial_sum()으로 구하는 과정을 보여주고 있다.

exclusive_scan() inclusive_scan()/partial_sum()
y_{0} = Init y_{0} = Init \Theta x_{0}
y_{1} = Init \Theta x_{0} y_{1} = Init \Theta x_{0} \Theta x_{1}
y_{2} = Init \Theta x_{0} \Theta x_{1}
y_{n-1} = Init \Theta x_{0} \Theta x_{1} ... \Theta x_{n-1}
y_{n-1} = Init \Theta x_{0} \Theta x_{1} ... \Theta x_{n-2}  

transform_exclusive_scan()과 transform_inclusive_scan()은 모두 합을 구하기 전에 원소에 단항 함수를 적용한다. 이는 transform_reduce()가 리듀스 연산을 적용하기 전에 원소마다 단항 함수를 적용하는 것과 비슷하다.

여기서 스캔 함수는 모두 병렬 실행 옵션이 있다는 점에 주목한다. 각 알고리즘의 평가 순서는 정확히 알 수 없다(비결정적, non-deterministic이다). 반면 partial_sum()과 accumulate()는 왼쪽부터 오른쪽 순으로 평가된다. 바로 이런 이유 때문에 partial_sum()과 accumulate()를 병렬로 실행할 수 없다.

알고리즘 예제: 선거인 명부 검사

(생략)

전문가를 위한 C++/ 컨테이너와 반복자 살펴보기

(전체가 아니라 C#과 차이가 있는 부분을 중심으로 요약 정리)

컨테이너 개요

표준 라이브러리를 이용하면 표준 C 스타일 배열ㅇㄹ 사용하거나 연결 리스트나 스택 등을 직접 구현할 필요가 없다. 

표준 라이브러리는 16가지 컨테이너를 제공하며 크게 네 가지 범주로 나눌 수 있다.

  • 순차 컨테이너
    • vector
    • deque
    • list
    • forward_list
    • array
  • 연관 컨테이너
    • map
    • multimap
    • set
    • multiset
  • 비정렬 연관 컨테이너(해시 테이블)
    • unordered_map
    • unordered_multimap
    • unordered_set
    • unordered_multiset
  • 컨테이너 어댑터
    • queue
    • priority_queue
    • stack

원소에 대한 요구사항

표준 라이브러리 컨테이너는 원소를 값으로 처리한다. (값 전달 방식, value semantics) 다시 말해 원소의 복제본을 저장하고, 대입 연산자로 대입하고 소멸자로 원소를 샂게한다. 그래서 표준 라이브러리를 사용하는 클래스를 작성할 때는 반드시 복제할 수 있게 (copyable) 만들어야 한다. 표준 라이브러리 컨테이너에서 원소를 요청하면 저장된 복제본에 대한 레퍼런스를 리턴한다.

원소를 레퍼런스로 처리하고 싶다면(레퍼런스 전달 방식, reference semantics) 원소를 그대로 넣지 않고 원소에 대한 포인터를 저장한다. 그러면 컨테이너에서 복제하는 대상이 포인터지만 복제된 값도 결국 똑같은 원소를 가리킨다. 아니면 컨테이너에 std::reference_wrapper를 저장해도 된다. reference_wrapper는 std::ref()나 std::cref()로 생성하며 결과로 나온 레퍼런스를 복제할 수 있게 만든다.

이동 전용(move-only) 타입, 즉 복제할 수 없는 (non-copyable) 타입도 컨테이너에 저장할 수 있지만 이때 컨테이너의 연산 중 일부는 컴파일 에러를 발생시킬 수 있다. 이동 전용 타입의 대표적인 예로 std::unique_ptr이 있다.

Caution) 컨테이너에 포인터를 저장하려면 unique_ptr나 shared_ptr을 사용한다. 포인터가 가리키는 객체의 소유자가 컨테이너라면 unique_ptr을 사용하고 컨테이너가 객체의 소유권을 다른 컨테이너와 공유한다면 shared_ptr을 사용한다.

표준 라이브러리 컨테이너에 대한 템플릿 타입 매개변수 중에는 할당자(allocator)라는 것이 있다. 컨테이너는 이 할당자를 이용하여 원소에 대한 메모리를 할당하거나 해제할 수 있다. 할당자 타입 매개변수는 디폴트 값이 정해져 있어서 거의 대부분 그냥 써도 된다.

map과 같은 일부 컨테이너는 템플릿 타입 매개변수로 비교자(comparator)도 받을 수 있다. 비교자는 원소를 정렬하는데 사용된다. 비교자도 디폴트 값이 정해져 있어서 매번 값을 지정할 필요가 없다.

디폴트 할당자와 비교자를 사용하는 컨테이너의 원소가 만족해야 할 요구사항은 다음과 같다.

메서드 설명 노트
복제 생성자 기존 원소와 ‘똑같은’ 원소를 새로 생성하며, 기존 원소에 아무런 영향을 미치지 않고 안전하게 제거할 수 있다. 원소를 추가할 때마다 호출된다. 단 뒤에서 설명할 emplace 메서드를 사용할 때는 호출되지 않는다.
이동 생성자 원본 원소에 있는 내용을 모두 새 원소로 이동하는 방식으로 원소를 새로 만든다. 원본 원소가 rvalue일 때 호출되며 새 원소가 생성된 후에는 제거된다. 또한 vector의 크기가 늘어날 때도 호출된다. 이동 생성자는 반드시 noexcept로 지정해야 한다. 그렇지 않으면 호출되지 않는다.
대입 연산자 원소의 내용을 원본의 복제본으로 교체한다. 원소를 수정할 때마다 호출된다.
이동 대입 연산자 원소의 내용을 원본 원소의 모든 내용으로 교체한다. 원본 원소가 rvalue일 때 호출되며, 대입 연산자의 실행이 끝나면 제거된다. 이동 대입 연산자는 반드시 noexcept로 지정해야 한다. 그렇지 않으면 호출되지 않는다.
소멸자 원소를 삭제한다. 원소를 제거할 때마다 호출된다. 또는 vector의 크기가 증가할 때 그 안에 담긴 원소가 noexcept로 지정되지 않고 이동시킬 수 없을 때 호출된다.
디폴트 생성자 아무런 인수 없이 원소를 생성한다. 인수가 하나 뿐인 vector::resize() 나 map::operator[]와 같은 특정한 연산에서만 필요하다.
operator== 두 원소가 같은지 비교한다. 비순차(비정렬) 연관 컨테이너의 키를 비교하거나 두 컨테이너를 operator==과 같은 특정한 연ㅅ나으로 비교할 때 필요하다.
operator< 두 원소의 크기를 비교한다 순차(정렬) 연관 컨테이너의 키를 비교하거나 두 컨테이너를 operator<와 같은 특정한 연산으로 비교할 때 필요하다.

Caution) 표준 라이브러리 컨테이너는 원소의 복제 생성자와 복제 대입 연산자를 호출하는 일이 많다. 따라서 두 연산자를 최대한 효율적으로 구현해야 한다. 원소에 대해 이동 의미론을 구현하는 것도 성능을 높이는데 도움 된다.

익셉션과 에러 검사

표준 라이브러리 컨테이너는 에러 검사 기능을 제공한다. 클라이언트는 이 기능으로 컨테이너 동작의 정확성을 보장할 수 있다고 기대하겠지만, 컨테이너 메서드나 함수 중 일부는 인덱스 범위를 벗어날 떄와 같은 특정한 조건에서 익셉션을 던지기도 한다.

반복자

표준 라이브러리는 컨테이너의 원손에 접근하는 기능을 범용적으로 제공하기 위해 반복자(iterator) 패턴을 사용한다. 컨테이너마다 원소에 대해 반복문을 수행할 방법이 담긴 특수한 스마트 포인터인 반복자가 정의돼 있다. 컨테이너의 종류가 달라도 반복자의 인터페이스는 모두 C++ 표준을 따르므로 모두 같다. 그래서 구체적인 동작은 달라도 컨테이너의 원소에 대해 반복문을 비슷한 방식으로 작성할 수 있도록 인터페이스는 통일돼 있다.

반복자는 컨테이너의 특정 원소에 대한 포인터로 생각할 수 있다. 배열에서 원소를 가리키는 포인터처럼 반복자도 operator++ 연산자를 이용하여 다음 원소로 이동할 수 있다. 또한 반복자로 원소의 필드나 원소 자체에 접근할 때 operator*나 operator->를 사용할 수도 있다. 어떤 반복자는 operator=나 operator!=로 비교하거나 operator–로 이전 원소로 이동하는 기능도 제공한다.

반복자는 반드시 복제 생성자, 복제 대입 연산자, 소멸자를 제공해야 한다. 반복자의 좌측값(lvalue)는 반드시 맞교환할 수 있어야 한다. 반복자의 기능은 컨테이너마다 약간씩 다르다. 표준에서는 다음표와 같이 반복자를 다섯 가지로 분류해서 정의하고 있다.

반복자 종류 필수 연산자 설명
입력(또는 읽기) operator++
operator*
operator->
복제 생성자
operator=
operator==
operator!=
읽기 전용이며 정방향(forward)으로만 사용할 수 있다. (역방향으로 이동하는 operator–는 없다)
이 반복자를 대입하거나 복제하거나 서로 같은지 비교할 수 있다.
출력(또는 쓰기) operator++
operator*
복제생성자
operator=
쓰기 전용이며, 정방향으로만 접근할 수 있다.
이 반복자를 대입할 수는 있지만, 서로 같은지 비교할 수는 없다.
출력 반복자는 *iter = value도 할 수 있다.
operator->는 없다는 점에 주의한다.
operator++에 대한 선행(prefix, 전위) 연산자 버전과 후행(postfix, 후위) 연산자 버전을 모두 제공한다.
정방향 입력/읽기 반복자의 연산자에 디폴트 생성자가 추가됨 읽기 및 정방향 전용이다.
반복자를 대입하거나 복제하거나 서로 같은지 비교할 수 있다.
양방향 정방향 반복자의 연산자에 다음 연산자 추가 
operator–
정방향 반복자에서 제공하는 기능을 모두 제공한다.
역방향으로 이동해서 이전 원소에 접근할 수 있다.
operator–에 대한 선행 연산자 버전과 후행 연산자 버전을 모두 제공한다.
랜덤 액세스 양방향 반복자의 연산자에 다음 연산자 추가
operator+
operator-
operator+=
operator-=
operator<
operator>
operator<=
operator>=
operator[]
일반 포인터와 같다. 포인터 산술 연산, 배열 인덱스 구문 그리고 모든 종류의 비교 연산을 지원한다.

여기서 출력 반복자의 요구사항을 만족하는 반복자를 가변 반복자(mutable iterator)라 부르고, 그렇지 않은 반복자를 상수(불변) 반복자 (constant iterator)라 부른다.

std::distance()를 이용하면 컨테이너의 두 반복자 사이의 거리(항목 수)를 구할 수 있다.

반복자는 특정한 연산자만 오버로딩한다는 점에서 스마트 포인터 클래스와 구현 방식이 비슷하다.

반복자의 기본 연산은 일반 포인터와 비슷하다. 그래서 일반 포인터를 얼마든지 특정한 컨테이너에 대한 반복자로 쓸 수 있다. 실제로 vector의 반복자를 일반 포인터로 구현할 수 있다. 

Note) 반복자는 내부적으로 포인터로 구현됐을 수도 있고 그렇지 않을 수도 있다.

Note) 순차 컨테이너(sequential container), 정렬 연관 컨테이너(ordered associative container), 비정렬 연관 컨테이너(unordered associative container)만 반복자를 제공한다. 컨테이너 어댑터와 bitset 클래스는 우너소에 대한 반복자를 제공하지 않는다.

반복자를 지원하는 표준 라이브러리의 컨테이너 클래스는 모두 반복자 타입에 대해 public 타입 앨리어스인 iterator와 const_iterator를 제공한다. 예컨대 int 타입 원소에 대한 vector의 const 반복자 타입은 std::vector<int>::const_iterator다. 역방향 반복을 지원하는 컨테이너는 reverse_iterator와 const_reverse_iterator란 이름의 public 타입 앨리어스도 제공한다. 그래서 이렇나 컨테이너 반복자를 사용하는 코드는 구체적인 타입에 신경쓰지 않고 반복자를 작성할 수 있다.

Note) const_iterator와 const_reverse_iterator는 컨테이너의 원소를 읽기 전용으로 접근한다.

컨테이너는 반복자를 리턴하는 begin()과 end() 메서드를 제공한다. begin()은 첫 번째 항목을 참조한느 반복자를 리턴하고, end()는 마지막 항목의 바로 다음 원소에 해당하는 지점을 가리키는 반복자를 리턴한다. 다시 말해 end()는 마지막 우너소를 가리키는 반복자에 operator++를 적용한 결과를 리턴한다. begin()과 end()는 모두 첫 번째 원소는 포함하지만 마지막 원소는 포함하지 않은 반개방 범위(half-open range)를 지원한다. 이렇게 복잡하게 구성된 이유는 빈 구간을 지원하기 위해서다. 다시 말해 구간이 비어있을 때는 begin()과 end()의 결과가 같다. begin()과 end() 반복자로 묶인 반개방 범위를 수학 기호로 [begin, end]라고 표현하기도 한다.

Note) 반개방 범위란 개념은 insert()나 erase()와 같은 컨테이너 메서드에 전달할 반복자 구간에도 적용된다.

다음과 같은 메서드도 제공된다.

  • const 반복자를 리턴하는 cbegin()과 cend() 메서드
  • 역방향 반복자를 리턴하는 rbegin()과 rend() 메서드
  • const 역방향 반복자를 리턴하는 crbegin()과 crend() 메서드

Note) 표준 라이브러리는 비멤버 전역 함수인 std::begin(), end(), cbegin(), cend(), rbegin(), rend(), crbegin(), crend()도 제공한다. 클래스의 멤버 메서드보다는 이처럼 비멤버 전역함수를 이용하는 것이 좋다.

순차 컨테이너

vector

vector의 개요

(내용 생략)

vector의 세부 기능

(내용 생략)

vector 반복자

앞서 소개한 프로그램에서 for 문을 범위 기반 for 문이 아닌 반복자를 사용하는 버전으로 수정하면 다음과 같다.

for (vector<double>::iterator iter = begin(doubleVector); iter != end(doubleVector); ++iter)
{
*iter /= max;
cout << *iter << " ";
}

먼저 for 문의 초기화 부분을 살펴보자.

vector<double>::iterator iter = begin(doubleVector);

앞서 설명했듯이 컨테이너 타입에 대한 반복자가 iterator란 타입 이름으로 정의돼 있다. begin()은 컨테이너의 첫 번째 원소를 참조하는 반복자를 리턴한다. 그래서 앞에 나온 초기화 문장을 실행하면 doubleVector의 첫 번째 원소를 참조하는 반복자가 iter 변수에 대입된다. 다음으로 for 문의 종료 조건을 표현하는 비교문을 살펴보자.

iter != end(doubleVector);

이 문장은 반복자가 참조하는 지점이 doubleVector의 마지막 원소를 지났는지 검사한다. 그래서 마지막 원소를 지났다면 루프를 종료한다. 증가연산을 수행하는 부분(++iter)은 반복자가 vector의 다음번 원소를 참조하도록 위치를 증가시킨다.

Note) 가능하면 후행 증가(post-increment)보다는 선행 증가(pre-increment)를 지정하는 것이 좋다. 선행 증가가 대체로 성능이 좋기 때문이다. iter++는 반드시 새로운 반복자 객체를 리턴하는 반면, ++iter는 iter에 대한 레퍼런스만 리턴한다.

반복자를 이용하는 for 문에서 auto 키워드를 사용하면 한결 간결하게 표현할 수 있다.

for (auto iter = begin(doubleVector); iter != end(doubleVector); ++iter)
{
*iter /= max;
cout << *iter << " ";
}
const_iterator

일반적으로 반복자는 읽기와 쓰기를 모두 할 수 있지만 const 객체에 대해 begin()이나 end()를 호출하거나 non-const 객체에 대해 cbegin()이나 cend()를 호출하면 const_iterator가 리턴된다. const_iterator는 읽기 전용이다. 따라서 반복자가 참조하는 원소를 수정할 수 없다. iterator는 언제든 const_iterator로 변환할 수 있다. 그래서 다음과 같이 작성하는 것이 안전성 측면에서 바람직하다.

vector<type>::const_iterator it = begin(myVector);

하지만 반대로 const_iterator를 iterator로 변환할 수는 없다.

Note) vector의 원소를 수정할 필요가 없다면 const_iterator를 사용하기 바란다. 그러면 코드의 정확성을 보장하기 쉽고 컴파일러가 코드의 성능을 최적화하는데도 도움 된다.

auto 키워드를 사용할 때는 const_iterator를 다르게 작성해야 한다. 예컨대 다음 코드를 보자.

vector<string> stringVector(10, "hello");

for (auto it = begin(stringVector); it != end(stringVector); ++it)
{
cout << *it << endl;
}

여기서 auto 키워드를 사용했기 때문에 컴파일러는 it 변수의 타입을 자동으로 추론하는데, 이 과정에서 stringVector는 const가 아니기 때문에 iterator로 바뀐다. 따라서 읽기 전용 const_iterator를 auto와 함께 사용할 때는 begin(), end()대신 cbegin(), cend()를 호출해야 한다.

vector<string> stringVector(10, "hello");

for (auto it = cbegin(stringVector); it != cend(stringVector); ++it)
{
  cout << *it << endl;
}

범위 기반 for 문에서도 다음과 같이 작성하면 항상 const 반복자를 사용하게 만들 수 있다.

vector<string> stringVector(10, "hello");

for (const auto element : stringVector)
{
cout << element << endl;
}
반복자의 안전성

일반적으로 반복자의 안전성(safety)은 포인터와 같다. 한마디로 안전성이 상당히 떨어진다. 예컨대 다음 코드를 보자.

vector<int> intVector;
auto iter = end(intVector);
*iter = 10; // 버그! iter가 벡터 원소를 가리키지 않고 있다.

앞서 설명했듯이 end()가 리턴하는 반복자는 vector의 마지막 원소가 아닌 그 다음 원소를 가리킨다. 따라서 end()의 리턴값을 역참조 하면 예상과 다른 결과가 나온다. 반복자는 어떠한 검증 작업도 거치지 않는다.

반복자가 서로 일치하지 않을 때도 문제가 발생한다. 예컨대 다음 코드에 나온 for 문을 보면 vectorTwo에 대한 begin()에서 리턴한 반복자로 iter를 초기화한 다음 vectorOne에 대한 end()에서 리턴한 반복자와 비교하고 있다. 이렇게 작성하면 당연히 무한 루프에 빠지게 된다. 이렇게 엉뚱한 반복자를 역참조하면 예상할 수 없는 결과가 발생한다.

vector<int> vectorOne(10);
vector<int> vectorTwo(10);

// 벡터에 원소를 채운다.

// 버그! 무한루프에 빠질 수 있다.
for (auto iter = begin(vectorTwo); iter != end(vectorOne); ++iter)
{
// 본문
}

Note) MS의 비주얼 C++은 기본적으로 디버그 모드로 빌드할 때 위와 같은 문제를 발견하면 어서션 에러(assertion error)를 발생시킨다. 릴리스 모드로 빌드하면 아무런 검증 작업도 수행하지 않는다. 이러한 디폴트 설정은 얼마든지 변경할 수 있지만 검증 작업을 추가하면 성능이 좀 떨어진다.

반복자의 다른 연산

vector 반복자는 랜덤 액세스를 지원한다. 다시 말해 앞뒤로 이동하거나 곧바로 원하는 원소로 건너뛸 수 있다. 다음 코드는 다섯 번째(4번 인덱스) 원소의 값을 4로 바꾸는 예를 보여준다.

vector<int> intVector(10);
auto it = begin(intVector);
it += 5;
--it;
*it = 4;
반복자와 인덱싱

vector의 원소에 대해 반복하는 for문을 인덱스 변수와 size() 메서드만으로 간단히 작성할 수 있다면 굳이 반복자가 필요 없을 것이다. 하지만 다음 세 가지 경우는 반복자를 사용하는 것이 유리하다.

  • 반복자를 사용하면 원소 또는 여러 우너소를 담은 시퀀스를 컨테이너의 원하는 지점에 추가하거나 삭제하기 쉽다.
  • 반복자를 사용하면 표준 라이브러리의 알고리즘을 사용하기 좋다.
  • 컨테이너를 순차적으로 접근할 때는 인덱스로 원소를 하나씩 조회하는 것보다 반복자를 사용하는 것이 더 효율적이다. vector에 대해서는 그렇지 않을 때도 있지만 list, map, set에 대해서는 반복자를 사용하는 것이 항상 빠르다.

(이하 내용 생략)

임플레이스 연산

C++은 vector를 비롯한 대부분의 표준 라이브러리 컨테이너에 대해 임플레이스(emplace) 연산을 지원한다. 임플레이스란 ‘특정한 지점에 설치’한다는 뜻이다. 예컨대 vector에서 제공하는 emplace_back() 메서드가 있다. 이 메서는 복제나 이동 작업을 수행하지 않고, 컨테이너에 공간을 마련해서 객체를 그 자리에 바로 생성한다. 예컨대 다음과 같다.

vec.emplace_back(5, 'a');

emplace 메서드는 인수를 가변 인수 템플릿으로 받기 때문에 인수의 개수를 다양하게 지정할 수 있다. 가변 인수 템플릿은 22장에서 설명하겠지만 자세히 몰라도 emplace_back()을 사용하는데 문제없다. 이동 의미론 버전의 push_back과 emplace_back()의 성능 차이는 컴파일러의 구현 방식에 따라 다르다. 대부분 두 가지 중에서 마음에 드는 방식으로 작성하면 된다.

vec.push_back(string(5, 'a'));

// 또는

vec.emplace_back(5, 'a');

C++ 17부터 emplace_back() 메서드는 추가된 원소에 대한 레퍼런스를 리턴한다. 그 전에는 void 타입을 리턴했다.

vector는 emplace() 메서드도 제공한다. 이 메서드는 vector의 특정 지점에 객체를 바로 생성한 뒤 그 원소를 가리키는 반복자를 리턴한다.

알고리즘 복잡도와 반복자 무효화

vector에 원소를 추가하거나 삭제하면 공간을 확보하거나 채우기 위해 남은 원소를 적절히 이동시킨다. 따라서 원소의 추가와 삭제 연산의 복잡도는 선형 시간이다. 또한 추가나 삭제 지점을 참조하는 반복자는 그 연산이 끝난 뒤에는 사용할 수 없다. vector의 원소가 변경된 결과에 따라 반복자를 자동으로 조정해주지 않기 때문이다. 따라서 프로그래머가 적절히 대응해야 한다.

또한 vector의 내부에서 공간 재할당이 발생하면 추가나 삭제 대상이 되는 원소를 가리키는 반복자 뿐만 아니라 다른 지점에 대한 기존 반복자들도 모두 무효가 된다. 다음 절에서 살펴보자.

vector의 메모리 할당 방식

vector에 원소를 추가할 때는 메모리가 자동으로 할당된다. vector는 표준 C 스타일 배열처럼 원소를 연속된 메모리 공간에 저장한다. vector에 현재 할당된 메모리 공간 바로 뒤에 새 메모리를 요청할 수 없기 때문에 vector에 원소를 추가하다 공간이 모자라면 더 큰 공간을 새로 할당 받아서 기존 원소를 모두 새 공간으로 이동하거나 복제해야 한다. 이런 일이 발생하면 시간이 오래 걸리기 때문에 vector를 구현할 때 재할당 발생 가능성을 최소화하도록 실제로 필요한 양보다 더 많이 할당 받는다.

vector의 내부 메모리 할당 방식을 알아야 하는 이유는 다음 두 가지다.

  1. 효율성
    • vector의 메모리 할당 방식은 원소의 추가 연산에 대해 분할 상환 상수 시간의 복잡도를 보장한다. 다시 말해 대다수의 추가 연산은 상수 시간에 처리되지만 간혹 재할당이 발생할 때처럼 선형 시간의 복잡도를 가질 수 있다. 효율성이 중요하다면 vector의 재할당 방식을 직접 관리하면 된다.
  2. 반복자 무효화
    • 메모리 재할당이 발생하면 vector의 원소를 참조하던 기본의 반복자들이 모두 무효가 된다.
크기와 용량

vector는 size()와 capacity() 메서드를 통해 크기에 대한 정보를 두 종류로 제공한다. size() 메서드는 vector에 담긴 원소 개수(벡터 크기)를 리턴하는 반면 capacity()는 재할당 없이 담을 수 있는 원소 개수(벡터 용량)를 리턴한다. 따라서 현재 상태에서 재할당 없이 추가할 수 있는 원소 개수는 capacity() – size()다.

Note) empty() 메서드를 이용하면 현재 vector가 비어 있는지 확인할 수 있다. vector가 비어 있어도 capacity()의 리턴값은 0이 아니다.

C++ 17부터 전역 함수인 std::size()와 std::empty()가 추가됐다. 이 함수는 std::begin()과 std::end()가 반복자에 대한 전역 함수 버전인 것과 비슷하다. 전역 함수 버전의 size()와 empty()는 모든 컨테이너에 대해 호출할 수 있다. 또한 포인터로 접근하지 않는 C 스타일의 정적 할당 배열에도 적용할 수 있고, initializer_list에도 적용할 수 있다.

예비 용량

프로그램을 최대한 효율적으로 만들거나 반복자가 무효화되지 않게 만들고 싶다면 vector에서 모든 원소를 확실히 담을 수 있도록 미리 공간을 충분히 할당해야 한다. 물론 이렇게 하려면 vector에 담길 원소 수를 미리 알아야 하는데, 경우에 따라 예측하기 힘들 수 있다.

공간을 미리 확보하기 위한 한 가지 방법은 reverse()를 호출하는 것이다. 이 메서드는 지정한 수의 원소를 충분히 담을 만큼의 공간을 할당한다. 

Caution) 원소에 대한 예비 공간을 확보하면 컨테이너의 크기(size())가 아닌 용량(capacity())이 커진다. 다시 말해 실제로 원소가 추가되는 것이 아니다. vector의 크기(size())를 넘은 지점의 원소를 접근하면 안 된다.

공간을 미리 확보하는 또 다른 방법은 vector에 담길 원소 수를 vector의 생성자나 resize() 또는 assign() 메서드로 지정하는 것이다. 이렇게 하면 vector를 원하는 크기로 생성할 수 있으며 용량도 그에 맞게 적절히 설정된다.

데이터에 직접 접근하기

vector는 데이터를 연속된 메모리 공간에 저장한다. data() 메서드를 호출하면 vector에서 데이터가 있는 메모리 블록에 대한 포인터를 구할 수 있다.

C++ 17부터 std::data()라는 전역 함수가 추가됐다. 이 함수는 array나 vector 컨테이너, string, 포인터로 접근하지 않는 정적 할당 C 스타일 배열, 그리고 initialize_list에 대해 호출할 수 있다.

vector 사용 예: 라운드-로빈 클래스

전산학에서 리소스 수가 유한할 때 이에 대한 요청을 잘 분배하는 문제를 흔히 볼 수 있다. 대표적인 예로 OS의 프로세스 스케줄러가 있다. OS는 프로세스마다 작업을 수행할 수 있는 시간(타임 슬라이스(time slice, 예: 100ms))를 할당하는 방식으로 프로세스를 관리한다.

프로세스에 할당된 시간이 만료되면 OS는 그 프로세스를 잠시 중단시키고, 다음 차례의 프로세스에 타임 슬라이스를 할당해서 작업을 수행할 기회를 준다. 이러한 문제를 해결하는 알고리즘 중에 가장 간단한 것으로 라운드 로빈 스케줄링(round-robin scheduling) 알고리즘이 있다.

(이하 예시 코드 생략)

vector<bool> 특수화

C++ 표준에 따르면 bool 타입 vector를 구현할 때 공간 효율을 최적화하도록 여러 부울 값을 묶어서(패킹, packing) 처리하도록 명시하고 있다. 앞서 설명했듯이 bool 값은 true나 false 중 하나를 가질 수 있는데, 이러한 두 가지 값은 한 비트로 표현할 수 있다.

그런데 C++의 기본 타입에는 비트 하나만 표현하는 것이 없다. 그래서 어떤 컴파일러는 부울값을 char로 처리하고 어떤 컴파일러는 int로 활용한다. 따라서 vector<bool>은 한 비트짜리 bool 값들을 배열로 저장하는 방식으로 공간을 절약한다.

Note) vector<bool> 은 vector 보다는 비트 필드(bit-field)에 가깝다. 비트 필드의 구현 관점에서 볼 때 vector<bool> 보다는 bitset 컨테이너가 더 뛰어나다. 하지만 vector<bool>은 크기를 동적으로 조절할 수 있다는 장점이 있다.

완벽한 방법은 아니지만 vector<bool>을 위한 비트 필드 루틴을 구현하기 위해 flip() 메서드를 제공하고 있다. 이 메서드를 컨테이너에 대해 호출하면 그 컨테이너에 담긴 원소를 모두 반전시키고, operator[]와 같은 메서드가 리턴한 레퍼런스에 대해 호출하면 해당 원소만 반전시킨다.

그런데 bool 타입 레퍼런스에 대해 메서드를 호출할 방법이 없다. vector<bool>은 내부적으로 bool 타입(비트)을 표현하는 (bool에 대한 프록시(proxy)인) reference란 클래스를 정의하는 방식으로 구현한다. operator[], at()과 같은 메서드를 호출하면 vector<bool>은 reference 타입의 객체를 리턴하며, 실제 bool 값 대신 이 객체를 사용한다.

Caution) vector<bool>에서 리턴한 reference 객체는 사실 일종의 프록시라서 컨테이너에 담긴 원소에 대한 포인터(주소)를 구하는데 사용할 수 없다.

프로그래머 입장에서 볼 때 bool 값을 패킹함으로써 얻을 수 있는 공간 절약 효과보다 이렇게 구현하는데 드는 노력이 훨씬 크다. 게다가 vector<bool>의 원소를 조회하거나 수정하는 속도는 vector<int> 처럼 특정 타입에 대한 vector보다 훨씬 느리다.

그래서 C++ 전문가는 대부분 vector<bool> 보다는 bitset을 쓰도록 권장한다. 크기를 동적으로 조절할 수 있는 비트필드가 필요하다면 vector<std::int_fast8_t>나 vector<unsigned char>를 쓰는게 낫다. std::int_fast8_t 타입은 <cstdint> 헤더에 정의돼 있다. 부호 있는 정수 타입이므로 컴파일러에서 이를 처리할 때 최소 8비트 이상인 정수 타입 중에서 가장 빠른 것을 적용해야 한다.

deque

deque(double-ended queue, 덱)은 vector와 거의 같지만 vector보다 활용도는 낮다.

deque는 vector에 비해 거의 사용되지 않기 때문에 자세히 설명하지 않겠다.

list

list는 이중 연결 리스트(doubly-linked list)를 구현한 표준 라이브러리 클래스 템플릿이다.

리스트의 모든 지점에 원소를 추가하거나 삭제하는 속도는 상수 시간이지만 각각의 원소를 조회하는 작업은 다소 느린 선형 시간이다. 사실 list는 operator[]처럼 랜덤 액세스 연산을 제공하지 않는다. 반복자로만 개별 원소에 접근할 수 있다.

list에서 제공하는 대부분의 연산은 vector와 같기 때문에 vector와 다른 부분만 소개한다.

원소 접근 연산

list에서 원소에 접근하는 용도로 제공하는 메서드는 front()와 back() 뿐이다. 둘 다 상수 시간에 처리한다. 이 메서드는 각각 list에 담긴 첫 번째와 마지막 원소에 대한 레퍼런스를 리턴한다. 나머지 원소는 반복자로만 접근할 수 있다.

list는 첫 번째 원소를 참조하는 반복자를 리턴하는 begin() 메서드와 list의 마지막 원소 바로 다음 항목을 참조하는 반복자를 리턴하는 end() 메서드를 제공한다. 또한 vector와 마찬가지로 cbegin(), cend(), rbegin(), rend(), crbegin(), crend()도 제공한다.

반복자

list의 반복자는 양방향으로 작동한다. vector의 반복자와 달리 랜덤 액세스는 지원하지 않는다. 그래서 list는 반복자끼리 더하거나 뺄 수 없고, 반복자를 가리키는 포인터에 대해 산술 연산을 할 수 없다.

예컨대 list 반복자에 대한 포인터 p가 있을 때 ++p, –p와 같은 연산으로 list의 원소를 탐색할 수 있지만 p+n이나 p-n 같이 덧셈이나 뺄셈 연산자를 적용할 수는 없다.

원소 추가와 삭제 연산

list도 vector처럼 원소를 추가하고 삭제하는 메서드를 제공한다. 이런 메서드로 push_back(), pop_back(), emplace(), emplace_back(), 다섯 가지 버전의 insert(), 두 가지 버전의 erase(), clear() 등이 있다. deque와 마찬가지로 push_front(), emplace_front(), pop_front()도 제공한다.

clear()를 제외한 모든 메서드는 정확한 위치를 지정할 수 있다면 상수 시간에 처리된다. 따라서 list는 데이터 구조에 추가나 삭제 연산이 빈번하지만 원소를 인덱스로 빠르게 접근할 일은 없는 애플리케이션에 적합하다. 그렇다 해도 vector가 훨씬 빠르다.

list 크기에 관련된 연산

list는 deque처럼 내부 메모리 모델에 관련된 메서드가 없다. 그래서 size(), empty(), resize()는 제공하지만 reverse(), capacity()와 같은 메서드는 제공하지 않는다.

참고로 list에 대한 size() 메서드의 성능 복잡도는 상수지만 forward_list에 대한 size()는 그렇지 않다.

list의 특수 연산

list는 원소의 빠른 추가와 삭제 성능을 활용한 몇 가지 특수 연산을 제공한다.

splice()

list는 기본적으로 연결 리스트이기 때문에 한 리스트를 통째로 다른 리스트에 이어붙이기(splice, 스플라이스) 할 수 있다. 이 연산은 상수 시간에 처리된다. 이 메서드에 대한 간단한 예는 다음과 같다.

// a로 시작하는 단어를 저장한 리스트를 만든다.
list<string> dictionary{ "aardvark", "ambulance" };

// b로 시작하는 단어를 저장한 리스트를 만든다.
list<string> bWrods{ "bathos", "balderdash" };

// 첫 번째 리스트에 c로 시작하는 단어를 추가한다.
dictionary.push_back("canticle");
dictionary.push_back("consumerism");

// b로 시작하는 단어를 저장한 리스트를 첫 버째 리스트에 이어붙인다.

if (!bWords.empty())
{
// b로 시작하는 원소 중 마지막 항목을 참조하는 반복자를 구한다.
auto iterLastB = --(cend(bWords));

// b로 시작하는 단어를 넣을 위치까지 반복한다.
auto it = cbegin(dictionary);

for (; it != cend(dictionary); ++it)
{
if (*it > *iterLastB)
{
break;
}
}

// b로 시작하는 단어를 추가한다. 그러면 bWords에 있던 원소가 삭제된다.
dictionary.splice(it, bWords);
}

// 결과를 화면에 출력한다.
for (const auto& word : dictionary)
{
cout << word << endl;
}

splice()는 두 가지 버전이 더 있다. 하나는 다른 list에 있는 원소 하나를 추가하는 것이고 다른 하나는 다른 list에서 특정한 범위에 있는 원소를 통째로 추가하는 것이다. 또한 splice()의 모든 버전은 원본 list에 대한 일반 레퍼런스나 우측값(rvalue) 레퍼런스를 받을 수 있다.

Caution) 인수로 전달한 list에 splice() 연산을 적용하면 원본 list가 변경된다. 다른 list에 추가하면 원본 list에 있던 원소는 삭제되기 때문이다.

좀 더 효율적인 버전의 알고리즘

list는 splice() 외에도 표준 라이브러리의 제네릭 알고리즘을 list에 특화시킨 버전을 제공한다.

Note) 가능하면 표준 라이브러리에서 제공하는 제네릭 알고리즘보다 list에 특화된 메서드를 사용하는 것이 좋다. 후자가 훨씬 효율적이기 때문이다. 때로는 어쩔 수 없이 list에 특화된 메서드를 사용해야 하는 경우가 있다. 예컨대 제네릭 std::sort() 알고리즘을 사용하려면 RandomAccessIterators를 써야하는데 list에서는 이를 제공하지 않기 때문에 list에서 제공하는 sort() 메서드를 사용할 수 밖에 없다.

메서드 설명
remove()
revmoe_if()
list에서 특정한 원소를 제거한다.
unique() list에서 같은 원소가 연달아 나온 부분을 제거한다. 이때 operator==이나 사용자가 지정한 이항 프리디케이트(binary predicate, 인수 두 개를 받아서 bool 값을 리턴하는 함수 객체)를 활용한다.
merge() 두 list를 합친다. 둘 다 operator<나 사용자가 지정한 비교 연산자로 정렬된 상태여야 한다. splice()와 마찬가지로 merge()도 인수로 전달된 list를 변경한다.
sort() list을 정렬한다. 순위가 같은 원소는 그대로 둔다.
reverse() list의 순서를 반대로 바꾼다.

list 활용 예제: 입학 등록 관리 프로그램

(생략)

forward_list

forward_list는 단방향 연결 리스트란 점을 제외하면 양방향 연결 리스트인 list와 비슷하다. 이름에서 알 수 있듯 forward_list는 한 방향으로만 반복할 수 있다. 그래서 범위를 지정하는 방식도 list와 다르다. 리스트를 변경하려면 대상 원소가 처음 나오는 지점의 바로 전 원소에 접근해야 한다.

하지만 forward_list의 반복자는 역방향으로 이동할 수 없기 때문에 바로 직전 원소를 구하기 쉽지 않다. 그래서 수정할 대상을 가리키는 범위(예: erase()나 splice()에 지정할 범위)의 시작 부분은 반드시 열려있어야 한다.

앞서 살펴본 begin() 함수는 첫 번째 원소를 참조하는 반복자를 리턴하므로 시작이 닫힌 범위를 만들 때만 사용할 수 있다. 그래서 forward_list 클래스는 before_begin()이란 메서드를 제공한다. 이 메서드는 리스트의 시작 우너소의 바로 전에 있는 가상의 원소를 가리킨다. 말 그대로 가상의 데이터를 가리키기 때문에 이 반복자를 역참조 할 수는 없다. 하지만 이 반복자를 하나 증가시키면 begin()이 리턴한 반복자와 같아진다. 따라서 시작이 열린 범위를 만들 수 있다.

(이하 list와 차이점 내용 생략)

array

array는 크기가 고정된 점을 제외하면 vector와 같다. 다시 말해 크기를 늘리거나 줄일 수 없다. 크기를 고정시키는 이유는 vector처럼 항상 힙에 저장해서 접근하지 않고 원소를 스택에 할당하기 위해서다. vector와 마찬가지로 array의 반복자도 랜덤 액세스를 지원하고, 원소를 메모리에 연속적으로 저장한다.

array는 front(0, back(), at(), operator[]를 제공한다. array를 특정 원소로 채우는 fill()도 지원한다. 크기가 고정되어 있기 때문에 push_back(), pop_back(), insert(), erase(), clear(), resize(), reserve(), capacity() 등은 지원하지 않는다.

vector에 비해 아쉬운 점은 array의 swap() 메서드 성능이 선형 시간이라는 점이다. vector는 이 연산을 상수 시간에 처리한다. 또한 array는 vector와 달리 상수 시간에 이동할 수 없다. array는 size() 메서드를 제공하기 때문에 C 스타일 배열보다 훨씬 좋다.

(이하 예시 생략)

컨테이너 어댑터

표준 라이브러리는 queue, priority_queue, stack 등 세 가지 컨테이너 어댑터(container adaptor)도 제공한다. 각각 내부적으로 순차 컨테이너를 사용한다. 일종의 래퍼다. 그래서 내부 컨테이너를 교체하더라도 어댑터를 사용하는 코드에 아무런 영향을 주지 않는다. 이렇게 어댑터를 제공하는 목적은 인터페이스를 간결하게 제공할 뿐만 아니라 stack이나 queue 같은 추상 개념에 적합한 부분만 외부에 노출하기 위해서다. 그래서 여러 원소를 동시에 추가/ 삭제하는 기능이나 반복자는 제공하지 않는다.

queue

queue는 표준 FIFO(First-In First-Out, 선입선출) 방식을 구현한 것이다. queue도 클래스 템플릿으로 구현됐으며 다음과 같이 선언한다.

template<class T, class Container = deque<T>> class queue;

템플릿 매개변수 T는 queue에 저장할 타입을 지정한다. 두 번째 템플릿 매개변수는 queue의 내부에 사용할 컨테이너를 지정한다. 그런데 queue에서 push_back()과 pop_front()를 모두 지원하려면 순차 컨테이너를 지정해야 한다. 결국 기본으로 제공되는 컨테이너 중에서는 deque이나 list 중 하나만 쓸 수 있다. 대부분 디폴트값인 deque를 그대로 쓴다.

queue 연산

queue 인터페이스는 굉장히 간단하다. 메서드 8개와 생성자, 일반 비교 연산자가 전부다. push()와 emplace() 메서드는 큐의 끝에 원소를 추가하는 반면 pop()은 큐의 앞에서 원소를 제거한다. front()와 back()을 이용하면 원소를 제거하지 않고도 맨 앞과 맨 뒤의 원소에 대한 레퍼런스를 구할 수 있다.

또한 다른 메서드처럼 const 객체에 대해 호출하면 const 레퍼런스를 리턴하고, non-const 객체에 대해 호출하면 const가 아니면서 읽고 쓸 수 있는 레퍼런스를 리턴한다.

queue는 size(), empty(), swap()도 제공한다.

queue 사용 예: 네트워크 패킷 버퍼

(예시 생략)

priority_queue

priority_queue(우선순위 큐)는 원소를 정렬된 상태로 저장하는 큐다. FIFO에 따라 정렬하지 않으며, 맨 앞에 있는 원소의 우선순위가 가장 높다. 그래서 큐에 가장 오랫동안 저장된 것일 수도 있고, 가장 최근에 추가된 것일 수도 있다. 우선순위가 같은 원소 중 어느 것을 먼저 처리할지는 명확히 정해져 있지 않다.

(이하 설명 생략)

priority_queue 연산

priority_queue에서 제공하는 연산의 종류는 queue 보다 훨씬 적다. push()와 emplace() 메서드는 원소를 추가하고, pop() 메서드는 원소를 제거하고, top() 메서드는 맨 앞의 원소에 대한 const 레퍼런스를 리턴한다.

Caution) non-const 객체에 대해 top()을 호출해도 const 레퍼런스를 리턴한다. 원소를 수정하면 순서가 바뀔 수 있기 때문이다. priority_queue는 맨 끝(tail) 우너소를 구하는 메서드를 제공하지 않는다.

queue와 마찬가지로 priority_queue도 size(), empty(), swap()을 지원하지만 비교 연산자는 제공하지 않는다.

priority_queue의 사용 예: 에러 상관관계(ErrorCorrelator V1)

(예시 생략)

stack

stack은 queue와 거의 같다. FIFO가 아닌 FILO(First-In Last-Out, 선입후출) 또는 LIFO(Last-In, First-Out, 후입선출)란 점만 다르다.

stack의 내부 컨테이너로 vector, list 또는 deque를 사용할 수 있다.

stack 연산

queue와 마찬가지로 stack도 push(), emplace(), pop() 메서드를 제공한다. 하짐나 queue와 달리 push()는 최상단에 원소를 추가하고, 그 전에 있던 원소를 모두 아래로 밀어낸다. 또한 pop()은 stack에 가장 최근에 추가한 최상단 원소를 제거한다.

top() 메서드를 const 객체에 대해 호출하면 최상단 원소에 대한 const 레퍼런스를 리턴하고, non-const 객체에 대해 호출하면 non-const 레퍼런스를 리턴한다.

stack은 empty(), size(), swap()과 표준 비교 연산자도 지원한다.

stack 사용 예: 에러 상관관계(ErrorCorrelator V2)

(생략)

정렬 연관 컨테이너

정렬 연관 컨테이너는 순차 컨테이너와 달리 원소를 한 줄로 저장하지 않고 키와 값의 쌍으로 저장한다. 연관 컨테이너에서 제공하는 추가, 삭제, 조회 연산의 성능은 대체로 비슷하다.

표준 라이브러리는 map, multimap, set, multiset이라는 네 종류의 정렬 연관 컨테이너를 저장한다. 각각 원소를 트리 형태의 데이터 구조에 정렬된 상태로 저장한다.

또한 unordered_map, unordered_multimap, unordered_set, unordered_multiset이라는 비정렬 연관 컨테이너도 제공한다.

pair 유틸리티 클래스

정렬 연관 컨테이너를 보기 전에 pair 클래스부터 알아보자. pair는 두 값을 그룹으로 묶는 클래스 템플릿이다. 두 값의 타입은 서로 다르게 지정할 수 있다. pair에 담긴 두 값은 각각 first와 second라는 public 데이터 멤버로 접근한다. pair에 정의된 operator==와 operator<는 두 pair의 first와 second 원소를 모두 비교한다.

(예시 코드 생략)

표준 라이브러리는 두 값으로 pair를 만들어주는 make_pair()라는 유틸리티 함수 템플릿도 제공한다. 예컨대 다음과 같다.

pair<int, double> aPair = make_pair(5, 10.10);

물론 이렇게 하지 않고 pair 클래스에서 제공하는 인수 두 개를 받는 생성자로 만들어도 된다. 하지만 make_pair()는 함수에 pair를 전달하거나 미리 정의된 변수에 pair를 대입할 때 유용하다. 클래스 템플릿과 달리 함수 템플릿은 매개변수에 대한 타입을 추론할 수 있기 때문에 make_pair()로 pair를 생성할 때 명시적으로 타입을 지정하지 않아도 된다. 게다가 다음과 같이 make_pair()는 auto 키워드와 함께 사용할 수 있다.

auto aSecondPair = make_pair(5, 10.10);

C++ 17부터는 생성자에 대해서도 템플릿 매개변수 추론 기능이 추가됐다. 그래서 make_pair()를 쓸 필요 없이 곧바로 다음과 같이 작성해도 된다.

auto aThirdPair = pair(5, 10.10);

또한 C++ 17부터 구조적 바인딩도 추가됐다. 그래서 다음과 같이 pair에 속한 원소를 각자 다른 변수로 분리할 수 있다.

pair<string, int> myPair("hello", 5);
auto[thString, thInt] = myPair; // 구조적 바인딩으로 pair의 원소를 분리한다.
cout << "theString: " << theString << endl;
cout << "theInt: " << theInt << endl;

map

map은 단일 값이 아닌 키와 값의 쌍으로 저장한다. 추가, 조회, 삭제 연산도 모두 키를 기준으로 수행한다. 닶은 단지 키에 딸린 값을 뿐이다. map이란 용어는 이 컨테이너가 키를 값에 매핑(mapping) 한다는 개념에서 따온 것이다.

map은 원소를 키 값을 기준으로 정렬된 상태로 유지한다. 그래서 추가, 삭제, 조회 연산의 성능이 모두 로그 시간이다. 순서가 있기 때문에 원소를 나열하면 원소의 타입에 대한 operator< 연산이나 사용자가 정의한 비교자를 순서대로 나열된다.

대체로 레드-블랙 트리(red-black tree)와 같은 균형 트리(balanced tree) 형태로 구현한다. 물론 트리 구조는 클라이언트에 드러나지 않는다. ‘키’를 이용하여 원소를 저장하거나 조회하면서 일정한 순서를 유지하고 싶다면 map을 사용한다.

map 생성하기

map 클래스 템플릿은 키 타입, 값 타입, 비교 타입, 할당자 타입이라는 네 가지 타입을 매개변수로 받는다.

(이하 설명 생략)

원소 추가하기

vector나 list와 같은 순차 컨테이너에 원소를 추가하려면 항상 추가할 위치를 지정해야 한다. 하지만 map은 다른 정렬 연관 컨테이너와 마찬가지로 위치를 지정할 필요가 없다. map은 새로 추가할 원소의 위치를 내부적으로 알아서 결정하기 때문에 키와 값만 지정하면 된다.

Note) map을 비롯한 정렬 연관 컨테이너는 반복자의 위치를 인수로 받는 버전의 insert() 메서드도 제공한다. 하지만 여기서 지정하는 위치는 단지 ‘힌트’에 불과할 분 컨테이너가 반드시 그 위치에 원소를 추가한다는 보장은 없다.

원소를 추가할 때 map에 있는 키가 중복되면 안 된다. 다시 말해 map에 있는 키는 고유한 값이어야 한다. 키가 같은 원소를 여러 개 넣으려면 두 가지 방법을 사용할 수 있다. 하나는 map에서 어떤 키에 대한 값을 vector나 array 같은 다른 컨테이너를 저장하는 것이고, 다른 하나는 multimap을 사용하는 것이다.

(이하 설명 생략)

map의 반복자

map의 반복자는 순차 컨테이너의 반복자와 비슷하게 작동한다. 다른 반복자와 가장 큰 차이점은 하나의 값이 아닌 키/값으로 구성된 pair를 가리킨다는 점이다. 값에 접근하려면 반드시 pair 객체의 second 필드를 조회해야 한다. map의 반복자는 양방향으로 작동한다. 그래서 앞이나 뒤로 탐색할 수 있다. map을 반복자로 탐색하는 방법은 다음과 같다.

for (auto iter = cbegin(dataMap); iter != cend(dataMap); ++iter)
{
cout << iter->second.getValue() << endl;
}

여기서 값에 접근하는 부분을 보자.

iter->second.getValue();

iter는 키/값 pair를 가리킨다. 그래서 pair의 second 필드인 Data 객체에 접근할 때 -> 연산자를 사용했다. 그러고 나서 Data 객체의 getValue() 메서드를 호출해서 값을 구했다. 참고로 다음과 같이 ㅈ가성해도 된다.

(*iter).second.getValue()

범위 기반 for 문으로 작성하면 다음과 같이 좀 더 직관적으로 표현할 수 있다.

for (const auto& p : dataMap)
{
cout << p.second.getValue() << endl;
}

C++ 17부터 추가된 구조적 바인딩을 적용하면 훨씬 세련되게 표현할 수 있다.

for (const auto& [key, data] : dataMap)
{
cout << p.second.getValue() << endl;
}

Caution) non-const 반복자로도 원소의 값을 변경할 수 있지만 우너소의 키를 변경할 때는 컴파일 에러가 발생한다. 키가 달라지면 map에 담긴 원소의 정렬 순서가 바뀔 수 있기 때문이다.

원소 조회하기

map에서 주어진 키로 원소를 조회하는 연산의 성능은 로그 시간이다. map에서 지정한 키로 원소를 조회하는 가장 간단한 방법은 operator[]로 조회하는 것이다. 단 이 연산은 non-const map에 대해 호출하거나 map에 대한 non-const 레퍼런스에 대해 호출해야 한다.

operator[]이 좋은 점은 원소에 대한 레퍼런스를 리턴하기 때문에 pair 객체에서 값을 빼오지 않고도 그 자리에서 값을 직접 수정하거나 활용할 수 있다는 것이다.

그런데 원소기 이미 있는지 모르는 상태에서 operator[]를 호출하면 안 된다. 지정한 키에 대해 원소가 없다면 원소를 새로 만들어서 추가할 수 있기 때문이다. 이렇게 하지 않고 map에서 제공하는 find() 메서드로 원하는 키에 대한 원소를 참조하는 iterator를 리턴받거나 주어진 키에 대해 값이 없다면 end() 반복자를 활용해도 된다.

auto it = dataMap.find(1);

if (it != end(dataMap))
{
it->second.setValue(100);
}

이처럼 find() 메서드를 사용하면 코드가 약간 지저분해진다. 주어진 키에 대한 원소가 map에 존재하는지만 알고 싶다면 count() 메서드를 활용한다. 이 메서드는 지정한 키에 대해 map에 담긴 원소 수를 리턴한다. map에 대해 이 메서드를 호출하면 항상 0 아니면 1을 리턴하는데 같은 키를 가진 원소가 여러 개 있을 수 없기 때문이다.

원소 삭제하기

map은 반복자가 가리키는 특정한 지점에 있는 원소를 삭제하거나 반복자로 지정한 범위에 있는 원소를 모두 삭제하는 기능을 제공한다. 각각에 대한 성능은 분할 상환 상수 시간과 로그 시간이다. 클라이언트 입장에서 볼 때 map에서 제공하는 두 가지 버전의 erase() 메서드는 순차 컨테이너에서 제공하는 erase() 메서드와 차이가 없다. 하지만 map은 주어진 키와 일치하는 원소를 삭제하는 버전의 erase()를 제공한다는 점에서 좀 더 뛰어나다.

노드

정렬 및 비정렬 연관 컨테이너를 흔히 노드 기반(node-based) 데이터 구조라 부른다. C++ 17부터 표준 라이브러리에서 노드(node)를 노드 핸들(node handle)의 형태로 직접 접근하는 기능이 추가됐다. 정확한 타입이 정해져 있지 않지만 컨테이너마다 그 컨테이너의 노드 핸들 타입을 가리키는 node_type이란 타입 앨리어스가 있다. 노드 핸들은 이동시킬 수만 있고, 노드에 저장된 원소를 소유하고 있다. 키와 값 모두에 대해 읽기/쓰기 권한이 있다.

extract() 메서드에 반복자 위치나 키를 지정해서 호출하면 연관 컨테이너에서 노드를 노드 핸들로 가져올 수 있다. extract()로 컨테이너에서 노드를 가져오면 그 노드는 컨테이너에서 삭제된다. 리턴된 노드 핸들만 그 원소를 소유하기 때문이다.

노드 핸들을 컨테이너에 추가할 수 있도록 insert()를 오버로딩한 메서드도 새로 추가됐다.

extract()로 노드 핸들을 가져와서 insert()로 노드 핸들을 추가하면 복제나 이동 연산을 수행하지 않고도 한쪽 연관 컨테이너에 있는 데이터를 다른 쪽 연관 컨테이너로 옮기는 효과를 볼 수 있다. 심지어 map에서 multimap으로 set에서 multiset으로도 노드를 이동시킬 수 있다.

앞서 본 예제에 이어서 키가 1인 노드를 두 번째 map으로 이동시키는 부분을 추가하면 다음과 같다.

map<int, Data> dataMap2;
auto extractedNode = dataMap.extract(1);
dataMap2.insert(std::move(extractedNode));

마지막 두 문장을 다음과 같이 하나로 합칠 수 있다.

dataMap2.insert(dataMap.extract(1));

또한 merge()를 이용하면 한쪽 연관 컨테이너에 있는 노드를 모두 다른 쪽으로 이동시킬 수 있다. 대상 컨테이너의 노드와 중복되거나 다른 이유로 이동시킬 수 없는 노드는 원본 컨테이너에 남는다. 예컨대 다음과 같다.

map<int, int> src = { {1, 11}, {2, 22} };
map<int, int> dst = { {2, 22}, {3, 33}, {4, 44}, {5, 55} };
dst.merge(src);

이렇게 merge() 연산을 실행하고 나면 src에 여전히 [2, 22]란 원소가 남아 있다. 대상 컨테이너에 [2, 22]가 있어서 이동시킬 수 없기 때문이다. 이 연산을 실행한 뒤 dst는 [1, 11], [2, 22], [3, 33], [4, 44], [5, 55]란 원소를 갖게 된다.

map 사용 예제: 은행 계좌

(생략)

multimap

multimap은 한 키에 여러 개의 값을 담을 수 있는 map이다. map과 마찬가지로 multimap도 유니폼 초기화를 지원한다. multimap의 인터페이슨느 다음과 같은 점을 제외하면 map의 인터페이스와 같다.

  • multilmap은 operator[]와 at()을 제공하지 않는다. 한 키가 여러 원소를 가리키는 상황에서 두 메서드의 의미가 맞지 않기 때문이다.
  • multimap에 원소를 추가하는 연산은 항상 성공한다. 원소 하나를 추가하는 multimap::insert() 메서드는 두 값을 pair로 묶지 않고 iterator 하나만 리턴한다.
  • map에서 제공하던 insert_or_assign()과 try_emplace() 메서드를 multimap에서는 제공하지 않는다.

Note) multimap을 이용하면 키/값 쌍을 중복해서 추가할 수 있다. 중복되지 않게 하려면 원소를 추가할 때마다 중복 여부를 검사해야 한다.

multimap을 사용할 때 까다로운 부분은 원소를 조회하는 연산이다. operator[]를 제공하지 않기 때문에 이를 사용할 수 없다. find()도 지정한 키에 해당하는 모든 원소를 참조하는 iterator를 리턴하기 때문에 (그래서 주어진 키에 대해 첫 번째 원소가 아닐 수 있기 때문에) 그리 도움 되지 않는다.

하지만 multimap은 한 키에 모든 원소를 저장할 수 있고 컨테이너에서 같은 키에 속한 원소의 범위를 가리키는 iterator를 리턴하는 메서드를 제공한다. lower_bound()와 upper_bound() 메서드는 주어진 키에 일치하는 원소 중에서 각각 첫 번째와 마지막 바로 뒤에 있는 원소를 가리키는 iterator를 리턴한다. 키에 매칭되는 원소가 없다면 lower_bound()와 upper_bound()가 리턴하는 iterator가 서로 같다.

주어진 키에 대한 원소의 양쪽 경계를 가리키는 iterator를 구하고 싶다면 lower_bound()를 호출한 뒤 upper_bound()를 호출하는 것보다 equal_range()를 호출하는 것이 좀 더 효율적이다. equal_range() 메서드는 lower_bound()와 upper_bound()가 각각 리턴한느 두 가지 iterator를 pair로 묶어서 리턴한다.

Note) lower_bound(), upper_bound(), equal_range() 메서드는 map에서도 제공한다. 하지만 map은 같은 키를 가진 원소가 여러 개 있을 수 없기 때문에 이 메서드의 활용도가 떨어진다.

multimap 사용 예제: 친구 목록(BuddyList)

(생략)

set

set은 map과 상당히 비슷하다. map과 다른 점은 키/값 쌍으로 저장하지 않고 키가 곧 값이라는 점이다. set은 키를 따로 갖지 않고 정보를 중복되지 않게 정렬해서 저장하고, 추가, 조회, 삭제 연산도 빠르게 처리하고 싶을 때 적합하다.

set에서 제공하는 인터페이스는 map과 같다. 가장 큰 차이점은 set은 operator[], insert_or_assign(), try_emplace()를 제공하지 않는다는 것이다.

set에 있는 원소의 키/값은 변경할 수 없다. set의 원소를 변경하면 순서가 바뀔 수 있기 때문이다.

set 사용 예제: 접근 권한 관리(AccessList)

(생략)

multiset

multiset과 set의 관계는 multimap과 map의 관계와 같다. multiset은 set에서 제공하는 연산을 모두 제공한다. 하지만 똑같은 원소를 중복해서 가질 수 있다.

비정렬 연관 컨테이너(해시 테이블)

표준 라이브러리는 흔히 해시 테이블이라 부르는 비정렬 연관 컨테이너도 제공하며 unordered_map, unordered_multimap, unordered_set, unordered_multiset의 네 종류가 있다. 이 컨테이너들은 원소를 정렬하지 않는다.

해시 함수

비정렬 연관 컨테이너를 해시 함수(hash function)으로 구현하기 때문에 흔히 해시 테이블이라 부른다. 해시 테이블은 버킷(bucket)이라 부르는 배열 형태로 원소를 저장한다. 버킷마다 0, 1, 2 같은 숫자 인덱스가 붙어 있다. 해시 함수를 키를 해시값(hash value)으로 변환하는데, 이 값은 다시 버킷 인덱스(bucket index)로 변환된다. 그래서 이렇게 버킷 인덱스로 표현된 키에 대응되는 값을 해당 버킷에 저장한다.

해시 함수의 결과는 중복될 수 있다. 두 개 이상의 키가 같은 버킷 인덱스를 가리키는 현상을 해시 충돌(collision)이라 부른다. 충돌은 서로 다른 키의 해시값이 같거나 서로 다른 해시값이 동일한 버킷 인덱스로 변환될 때 발생한다.

이런 충돌을 해결하기 위해 이차 함수 재해싱(quadratic re-hashing), 리니어 체이닝(linear chaining)과 같은 다양한 방법이 나왔다. 표준 라이브러리는 충돌 처리 알고리즘을 특별히 정해두지 않았지만, 최근 구현된 라이브러리는 대부분 리니어 체이닝을 적용하고 있다.

리니어 체이닝은 키에 대응되는 데이터 값을 버킷에 직접 저장하지 않고, 연결 리스트에 대한 포인터를 저장한다. 이 연결 리스트에 해당 버킷에 대한 데이터 값을 모두 담고 있다. 이를 그림으로 표현하면 아래 그림과 같다.

위 그림을 보면 충돌이 두 번 발생한다. 첫 번째 충돌은 ‘Mark G’와 ‘John D’라는 키의 해시 함수 결과가 모두 버킷 인덱스 128에 대응되는 해시값으로 나와서 발생한다. 이 버킷은 ‘Mark G’와 ‘John D’라는 키와 각각에 해당하는 데이터 값을 담은 연결 리스트에 대한 포인터값을 갖고 있다. 두 번째 충돌은 ‘Scott K’와 ‘Johan G’에 대한 해시값이 모두 버킷 인덱스 129번에 대응되기 때문에 발생한다.

또한 위 그림을 보면 키로 값을 조회(룩업 lookup) 하는 과정과 이때 발생하는 복잡도도 알 수 있다. 조회 연산은 먼저 해시값을 계산하는 해시 함수를 한 번 호출해서 해시값을 구한뒤 이를 버킷 인덱스로 변환한다. 버킷 인덱스를 구했다면 한 개 이상의 등호(equality) 연산을 이용해서 연결 리스트에서 적합한 키를 찾는다. 그래서 기존 map에 비해 해시 테이블에 대한 조회 연산이 굉장히 빠르다. 하지만 충돌 발생 횟수에 따라 성능이 달라질 수 있다.

어떤 해시 함수를 사용하느냐도 중요하다. 충돌이 전혀 없는 해시 함수를 ‘완전 해시(perfect hash)’라 부른다. 완전 해시의 조회 시간은 상수다. 일반 해시의 조회 시간은 원소 수에 관계 없이 평균적으로 1에 가깝다. 충돌 횟수가 많아질수록 조회 시간은 늘어나서 성능이 떨어진다. 기본 해시 테이블의 크기를 늘리면 충돌을 줄일 수 있지만 캐시 크기도 함께 고려해서 결정해야 한다.

C++ 표준에서는 포인터 타입이나 bool, char, int, float, double 등과 같은 기본 타입에 대해 해시 함수를 제공한다. 또한 error_code, error_condition, optional, variant, bitset, unique_ptr, shared_ptr, type_index, string, string_view, vector<bool>, thread::id에 대해서도 해시 함수를 제공한다.

원하는 키 타입을 제공하는 해시 함수가 표준에 없다면 직접 구현해야 한다. 완전 해시를 만들기란 쉽지 않다. 키의 개수와 종류가 정해져 있더라도 그렇다. 수학적으로 깊이 있게 분석해야 하기 때문이다. 그런데 완전 해시가 아니더라도 충분한 성능을 내는 해시를 만드는 것도 쉽지 않다. 

다음 코드는 해시 함수를 직접 만드는 방법을 보여준다. 여기서는 요청된 사항을 표준 해시 함수 중 하나로 전달하기만 한다. 여기서 정의한 IntWrapper 클래스는 정숫값 하나를 감싸기만 한다. 비정렬 연관 컨테이너에서는 키에 대한 operator== 연산을 반드시 제공해야 한다.

class IntWrapper
{
public:
IntWrapper(int i) : mWrappedInt(i) { }
int getValue() const { return mWrappedInt; }

private:
int mWrappedInt;
};

bool operator==(const IntWrapper& lhs, const IntWrapper& rhs)
{
return lhs.getValue() == rhs.getValue();
}

IntWrapper에 대한 해시 함수는 std::hash 템플릿을 IntWrapper에 대해 특수화는 방식으로 구현한다. std::hash 템플릿은 <functional> 헤더 파일에 정의돼 있다. 이렇게 특수화 하려면 주어진 IntWrapper 인스턴스에 대한 해시를 계산해서 리턴하는 함수 호출 연산자를 구현 해야 한다. 예제에서는 정수 타입에 대한 표준 해시 함수로 전달하는 방식으로 처리한다.

namespace std
{
template<> struct hash<IntWrapper>
{
using argument_type = IntWrapper;
using result_type = size_t;

result_type operator()(const argument_type& f) const
{
return std::hash<int>()(f.getValue());
}
}
}

일반적으로 std 네임스페이스에 새로운 것을 추가하면 안 된다. 하지만 std 클래스 템플릿을 특수화 할 때는 예외적으로 이를 허용한다. hash 클래스 템플릿을 특수화하려면 두 가지 타입을 정의해야 한다. 함수 호출 연산자는 단 한 줄로 구현했는데, 정수에 대한 표준 해시 함수인 std::hash<int>()의 인스턴스를 생성한 뒤 이 인스턴스의 함수 호출 연산자에 f.getValue()를 인수로 지정해서 호출한다. 

여기서는 IntWrapper에 정수에 대한 데이터 멤버가 하나만 있기 때문에 이런 식으로 요청을 전달할 수 있다. 하지만 클래스에 데이터 멤버가 여러 개면 해시값을 계산할 때 여기 나온 데이터 멤버를 모두 반영해야 한다.

unordered_map

(설명 생략)

map과 마찬가지로 unordered_map도 키가 중복될 수 없다.

unordered_map은 한 버킷에 담긴 원소에 대해 루프를 돌도록 local_iterator와 const_local_iterator도 제공한다. 이 반복자는 다른 버킷에 대해서는 적용할 수 없다.

bucket(key) 메서드는 인수로 지정한 키의 버킷 인덱스를 리턴한다. begin(n))은 인덱스가 n인 버킷에 있는 첫 번째 원소를 가리키는 local_iterator를 리턴하고, end(n)은 인덱스가 n인 버킷에 있는 마지막 바로 다음번 원소를 가리키는 local_iterator를 리턴한다.

unordered_map 사용 예제: 전화번호부(phoneBook)

(예시 생략)

unordered_multimap

unordered_multimap은 같은 키로 여러 우너소를 대응시킬 수 있는 unordered_map이다. 다음과 같은 차이를 제외하면 인터페이스는 똑같다.

  • unordered_multimap은 operator와 at()을 제공하지 않는다. 한 키가 여러 원소를 가리키는 상황에서 두 메서드의 의미가 맞지 않기 때문이다.
  • unordered_multimap에 대한 추가 연산은 항상 성공한다. 그래서 원소 하나를 추가하는 unordered_multimap::insert() 메서드는 두 값을 pair로 묶지 않고 iterator 하나만 리턴한다.
  • unordered_map에서 제공하던 insert_or_assign()과 try_emplace() 메서드를 unordered_multimap에서는 제공하지 않는다.

unordered_multimap은 원소를 operator[]로 조회할 수 없다. 이 연산을 제공하지 않기 때문이다. find()로 조회해도 되지만 지정한 키에 대응되는 원소 중에서 첫 번째가 아닌 다른 원소를 가리키는 반복자를 리턴할 수 있다. 따라서 주어진 키에 대응되는 첫 번째 원소와 마지막 바로 다음번 원소를 가리키는 원소를 가리키는 반복자를 pair로 묶어서 리턴하는 equal_range() 메서드로 조회하는 것이 좋다. equal_range() 메서드의 사용법은 multimap과 같다.

unordered_set과 unordered_multiset

각각 키를 정렬하지 않고 해시 함수를 사용한다는 점만 빼면 set, multiset과 상당히 유사하다. unordered_set과 unordered_map의 차이점은 앞서 설명한 set과 map의 차이점과 같다.

기타 컨테이너

C++ 은 표준 라이브러리와 함께 사용할 수 있는 컨테이너를 다양하게 제공한다. 대표적인 예로 표준 C 스타일 배열, string, 스트림, bitset 등이 있다.

표준 C 스타일 배열

앞서 일반 포인터도 일종의 반복자라고 설명한 적 있는데, 반복자와 관련된 연산을 제공하기 때문이다. 이 점을 가볍게 볼 수 없는 이유는 원소에 대한 포인터를 반복자로 사용하면 표준 C 스타일 배열을 표준 라이브러리 컨테이너처럼 사용할 수 있기 때문이다.

표준 C 스타일 배열은 당연히 size(), empty(), insert(), erase()와 같은 메서드를 제공하지 않기 때문에 정식 표준 라이브러리 컨테이너는 아니다. 하지만 포인터를 이용한 반복자를 제공하기 때문에 18장에서 소개하는 표준 라이브러리의 알고리즘과 이 장에서 소개하는 몇 가지 메서드에 적용할 수 있다.

예컨대 모든 컨테이너의 반복자 범위를 인수로 받는 vector의 insert() 메서드로 표준 C 스타일 배열에 담긴 원소를 모두 vector로 복제할 수 있다. 이 버전의 insert() 메서드의 프로토타입은 다음과 같다.

template <class InputIterator> iterator insert(const_iterator position, InputIterator first, InputIterator last);

원본이 int 타입에 대한 표준 C 스타일 배열일 때 InputIterator를 템플릿화한 타입은 int*가 된다. 예컨대 다음과 같다.

const size_t count = 10;
int arr[count]; // 표준 C 스타일 배열

// 배열의 원소를 모두 인덱스 값으로 초기화한다.
for (int i = 0; i < count; i++)
{
arr[i] = i;
}

// 배열에 담긴 내용을 vector 뒤에 추가한다.
vector<int> vec;
vec.insert(end(vec), arr, arr + count);

// vector의 내용을 화면에 출력한다.
for (const auto& i : vec)
{
cout << i << " ";
}

여기서 배열의 첫 번째 원소를 가리키는 반복자는 첫 번째 원소(arr)의 주소다. 그러므로 배열의 이름은 첫 번째 원소에 대한 주소처럼 쓸 수 있다. 마지막 원소를 가리키는 반복자는 반드시 마지막 바로 다음 번째 원소를 가리켜야 한다. 그러므로 첫 번째 원소의 주소에 count를 더한 arr+count의 주소가 된다.

std::begin()이나 std::cbegin()을 이용하면 포인터로 접근하지 않는 정적 할당 C 스타일 배열의 첫 번째 원소에 대한 반복자를 쉽게 구할 수 있다. 또한 std::end()나 std::cend()를 사용하면 이 배열의 마지막 바로 다음 번째 원소에 대한 반복자를 얻을 수 있다. 예컨대 앞서 나온 코드에서 insert()를 호출하는 부분을 다음과 같이 표현할 수 있다.

vec.insert(end(vec), cbegin(arr), cend(arr));

Caution) std::begin()과 std::end() 같은 함수는 포인터로 접근하지 않는 정적 할당 C 스타일 배열에 대해서만 사용할 수 있다. 포인터를 사용하거나 동적으로 할당된 C 스타일 배열에는 적용할 수 없다.

string

string을 문자에 대한 순차 컨테이너로 볼 수 있다. 실제로 C++의 string은 정식 순차 컨테이너다. 반복자를 리턴하는 begin(), end() 메서드와 insert(), push_back, erase(), size(), empty()를 비롯한 순차 컨테이너에서 기본적으로 제공하는 메서드를 모두 갖추고 있다. reserve()와 capacity() 같은 메서드도 제공해서 vector와도 상당히 비슷하다.

(예시 코드 생략)

string은 표준 라이브러리의 순차 컨테이너에서 제공하는 메서드 뿐만 아니라 여러 유틸리티 메서드와 friend 함수도 제공한다. string의 인터페이스는 좀 지저분한데, 6장에서 설명한 잘못된 디자인의 대표적 예다.

스트림

전통적인 분류에 따르면 입력과 출력 스트림은 컨테이너가 아니다. 원소를 저장하지 않기 때문이다. 하지만 원소를 순차적으로 다룬다는 점에서 표준 라이브러리의 컨테이너와 공통점이 많다.

C++ 스트림은 표준 라이브러리 관련 메서드를 하나도 제공하지 않지만 표준 라이브러리에서 istream_iterator와 ostream_iterator라는 특수 반복자를 제공한다. 그래서 입력과 출력 스트림에 대해 반복할 수 있다.

bitset

bitset은 고정된 크기의 비트열을 추상화한 것이다. 한 비트는 0과 1이라는 두 가지 값만 가질 수 있으며, 흔히 참/거짓, 켜기/끄기와 같은 속성을 표현한다. bitset은 셋(set)과 언셋(unset)이란 용어를 사용한다. 값을 반전(토글(toggle)또는 플립(flip)) 시킬 수도 있다.

bitset은 정식 표준라이브러리 컨테이너가 아니다. 크기가 고정됐고, 원소 타입에 대해 템플릿화 할 수 없고, 반복자를 제공하지 않기 때문이다. 하지만 컨테이너와 함께 사용하는 일이 많을 정도로 유용하기 때문에 살펴보겠다.

bitset의 기본 기능

bitset은 저장할 비트 수에 대해 템플릿화할 수 있다. 디폴트 생성자는 bitset의 모든 필드를 0으로 초기화한다. 0과 1이란 문자로 구성된 string을 bitset으로 만드는 생성자도 있다.

각각의 비트값은 set(), unset(), flip() 메서드로 설정할 수 있고, bitset에 대해 오버로딩된 operator[] 연산으로 각 필드값을 조회하거나 설정할 수도 있다. 참고로 non-const 객체에 대해 operator[] 연산을 수행하면 flip()을 호출하거나 operator~ 연산으로 부울값을 할당할 수 있는 프록시 객체를 리턴한다. 또한 test() 메서드로 개별 필드에 접근할 수도 있다.

그리고 추가 및 추출 연산으로 bitset을 스트림으로 처리할 수도 있다. bitset을 스트림으로 처리하면 0과 1이란 문자로 구성된 string으로 표현된다.

bitset<10> myBitset;

myBitSet.set(3);
myBitSet.set(6);
myBitSet[8] = true;
myBitSet[9] = myBitSet[3];

if (myBitSet.test(3))
{
cout << "Bit 3 is Set!" << endl;
}

cout << myBitSet << endl;

// 코드 실행 결과
// Bit 3 is Set!
// 1101001000

string의 출력 결과를 보면 가장 왼쪽에 나온 문자가 최상위 비트다. 우리가 흔히 이진수를 표현할 때 가장 낮은 자리 비트인 20 = 1을 가장 오른쪽에 표현하는 방식과 일치한다.

비트 연산

bitset은 기본적인 비트 조작 메서드뿐만 아니라 &, |, ^, ~, <<, >>, &=, |=, ^=, <<=, >>=와 같은 비트 연산자도 제공한다. 이 연산은 실제 비트열에 적용할 때와 똑같이 작용한다. 예컨대 다음과 같다.

auto str1 = "0011001100";
auto str2 = "0000111100";
bitset<10> bitsOne(str1);
bitset<10> bitsTwo(str2);

auto bitTHree = bitsOne & bitsTwo;
cout << bitsThree << endl;

bitsThree <<= 4;
cout << bitsThree << endl;

// 실행 결과
// 0000001100
// 0011000000

bitset 사용 예제: 케이블 채널 관리(CableCompany)

(생략)

전문가를 위한 C++/ C++ 표준 라이브러리 둘러보기

(전체가 아니라 C#과 차이가 있는 부분을 중심으로 요약 정리)

C++ 프로그래머에게 가장 중요한 라이브러리는 C++ 표준 라이브러리(Standard Library)이다. C++ 표준 라이브러리 중에서 가장 핵심은 제네릭 컨테이너와 제네릭 알고리즘이다. 이 라이브러리는 원래 표준 템플릿 라이브러리(Standard Template Library, STL)라는 이름의 서드파티 라이브러리였다. 그래서 아직도 표준 라이브러리는 STL이라 부르는 사람이 많은데 이는 표준 용어가 아니다.

(이하 내용 생략)

코드 작성법

표준 라이브러리는 C++의 템플릿과 연산자 오버로딩 기능을 상당히 많이 사용한다.

템플릿 활용

템플릿을 활용하면 제네릭 프로그래밍을 할 수 있다. 

(이하 설명 생략)

연산자 오버로딩 활용

(내용 생략)

C++ 표준 라이브러리 둘러보기

스트링

C++은 string이란 클래스를 기본으로 제공한다. C++에서 C 스타일의 문자 배열로 스트링을 표현해도 되지만 C++의 string을 활용하는 것이 여러모로 유리하다. 메모리를 관리해 줄 뿐만 아니라 인덱스 경계 검사, 대입과 비교 기능, 스트링 결합 , 스트링 추출, 부분 스트링 만들기, 문자 치환 등과 같은 다양한 기능도 제공한다.

Note) 엄밀히 말하면 std::string은 std::basic_string 템플릿을 char 타입 매개변수로 인스턴스화한 것의 타입 앨리어스이지만 이런 세부사항은 알 필요 없고 일반 클래스처럼 사용해도 된다.

표준 라이브러리는 string_view 클래스도 제공한다. string_view는 스트링을 읽기 전용으로 표현한다. 또한 const string& 자리에 그대로 넣을 수 있고 오버헤드도 발생하지 않는다. 스트링을 복제하지 않기 때문이다.

정규표현식

정규표현식을 활용하면 패턴 매칭을 쉽게 구현할 수 있다. 정규 표현식은 19장에서 설명한다.

I/O 스트림

C++이 제공하는 입력과 출력에 대한 모델.

스마트 포인터

C++은 unique_ptr, shared_ptr, weak_ptr와 같은 스마트 포인터를 제공한다. shared_ptr과 weak_ptr은 스레드에 안전하다.

C++ 11버전 이전에는 unique_ptr에 대한 기능을 auto_ptr 타입으로 처리했지만 C++ 17부터 auto_ptr이 없어졌기 때문에 이제는 이렇게 사용하지 않는 것이 좋다.

익셉션

C++는 익셉션 메커니즘을 제공한다. C++ 표준 라이브러리는 익셉션에 대해 정의된 상속 계층을 제공하는데, 적절한 타입의 익셉션을 코드에서 곧바로 사용하거나 그중에서 원하는 익셉션을 커스터마이즈 할 수도 있다.

수학 관련 유틸리티

C++ 표준라이브러리는 수학 연산에 관련된 다양한 클래스와 함수도 제공한다.

C++ 17부터는 르장드르 다항식, 베타 함수, 타원 적분, 베셀 함수, 원기둥 함수인 노이만 함수와 같은 특수한 수학 함수도 추가됐다.

또한 complex란 이름의 복소수 클래스도 제공한다.

컴파일 시간 유리수 연산 라이브러리는 ratio 클래스 템플릿을 제공한다.

표준 라이브러리는 valarray란 클래스도 제공하는데 이 클래스는 vector와 비슷하지만 고성능 수치 연산용으로 최적화한 것이다. 이 라이브러리는 벡터 슬라이스(vector slice)를 표현하는 클래스를 다양하게 제공한다. 이런 기본 요소로 행렬 연산을 수행하는 클래스를 정의할 수 있다. 표준 라이브러리는 행렬을 직접 다루는 클래스는 제공하지 않지만 부스트 같은 서드파티 라이브러리는 행렬 연산에 관련된 클래스를 제공한다.

시간 관련 유틸리티

C++은 시간에 관련된 chrono 라이브러리도 제공한다.

무작위수

C++은 예전부터 srand()와 rand() 함수를 통해 의사 무작위수 생성기능을 제공했다. 하지만 이 함수는 굉장히 기초적인 수준으로만 무작위수를 생성할 수 있다.

C++ 11부터 이보다 훨씬 강력한 무작위수 라이브러리가 표준에 추가됐다. 이 라이브러리는 <random> 헤더 파일에 정의돼 있으며 무작위수 엔진, 무작위수 엔진 어댑터, 무작위수 분포 등도 제공한다. 이 기능을 활용하면 정규 분포, 역지수 분포 등에 보다 적합한 무작위수를 생성할 수 있다.

이니셜라이즈 리스트

이니셜라이즈 리스트는 인수의 개수가 다양한 함수를 쉽게 작성할 수 있다.

pair와 tuple

<utility> 헤더 파일에서는 서로 다른 타입의 두 원소를 하나로 묶어서 저장하는 pair 템플릿을 정의하고 있다. 이런 저장 방식을 이종(heterogeneous) 원소 저장이라 부른다. 이 장에서 소개하는 표준 라이브러리 컨테이너는 모두 동종(homogeneous) 원소 저장 방식을 따른다. 즉 컨테이너에 담긴 원소의 타입은 모두 같다. pair 템플릿을 이용하면 서로 타입이 다른 두 원소를 객체 하나에 담을 수 있다.

<tuple> 헤더에 정의된 tuple은 pair를 일반화한 것이다. 고정된 크기의 수열로서 서로 타입이 다른 원소를 저장할 수 있다. tuple에 담긴 원소 개수와 타입은 tuple 인스턴스를 생성하는 컴파일 시간에 결정된다. 튜플은 20장에서 자세히 설명한다.

optional, variant, any

C++ 17부터 다음의 클래스가 새로 추가됐다.

  • optional
    • 특정한 타입의 값을 저장하거나 값을 가지지 않을 수 있다. 값이 없을 수도 있는 함수의 매개변수나 리턴 타입으로 사용할 수 있다.
  • variant
    • 지정한 타입 집합 중 하나의 값을 가지거나 값을 가지지 않을 수 있다.
  • any
    • 모든 타입의 값을 단 하나만 가진다.

이 세 가지 클래스는 20장에서 설명한다.

함수 객체

함수 호출 연산자를 구현하는 클래스를 함수 객체(function object), 펑터(functor)라 부른다. 함수 객체는 특정한 표준 라이브러리 알고리즘에 대한 조건식(predicate) 등에 활용된다.

함수 객체는 18장에서 자세히 소개한다.

파일시스템

C++ 17부터 파일시스템을 지원하는 라이브러리가 추가됐다. 그래서 파일시스템을 다루는 코드를 이식(포팅)하기 쉽게 만들 수 있다. 파일시스템 지원 라이브러리는 20장에서 자세히 설명한다.

멀티스레딩

표준 라이브러리는 멀티스레딩을 지원하는데 필요한 다양한 기본 기능을 제공한다. <thread> 헤더 파일에 정의된 thread 클래스를 이용하면 스레드를 하나씩 생성할 수 있다.

멀티스레드 코드를 작성할 때는 여러 스레드가 같은 데이터를 동시에 읽고 쓰지 않도록 조심해야 한다. 이때 <atomic> 헤더 팡리에 정의된 atomic을 사용하면 데이터를 스레드에 안전하고 아토믹하게 (여러 스레드가 동시에 접근하지 않게) 만들 수 있다. <condition_variable>과 <mutex> 헤더 파일에서도 다양한 스레드 동기화 메커니즘을 제공한다.

여러 스레드로 뭔가 계산해서 적절한 예외 처리 방식을 통해 결과를 받기만 한다면 async와 future를 활용한다. 둘 다 <future> 헤더 파일에 정의돼 있으며 thread 클래스를 직접 다룰 때보다 훨씬 사용하기 쉽다.

타입 트레이드

타입 트레이트(type traits, 타입 특성/속성) 기능은 컴파일 시간에 타입 정보를 조회할 수 있다. 이 기능은 고급 템플릿을 작성할 때 유용하며 22장에서 자세히 설명한다.

표준 정수 타입

<cstdint> 헤더 파일에서는 다양한 표준 정수 타입을 정의하고 이싿. 또한 이러한 타입의 최댓값과 최솟값을 지정하는 매크로도 제공한다. 이런 정수 타입에 대한 자세한 사항은 30장에서 다룬다.

컨테이너

표준 라이브러리는 연결 리스트(linked list)나 큐(queue)와 같이 흔히 사용되는 데이터 구조를 제공한다. C++로 프로그래밍할 때는 이러한 데이터 구조를 직접 구현할 필요 없다. 이러한 데이터 구조는 정보를 원소(element) 단위로 저장하는 컨테이너(container) 개념에 따라 구현했으며, 연결 리스트나 큐와 같은 구쳊거인 데이터의 구조의 특성에 맞게 다양하게 구현했다.

표준 라이브러리에서 제공하는 컨테이너는 모두 클래스 템플릿이다. 그래서 int나 double 같은 기본 타입 뿐만 아니라 사용자가 정의한 클래스에 이르기까지 모든 타입의 데이터를 담을 수 있다. 컨테이너 인스턴스마다 단 한가지 타입의 객체만 저장할 수 있다. 다시 말해 동형(homogeneous) 컬렉션이다.

크기가 고정되지 않은 동형 컬렉션이 필요하다면 각각의 원소를 std::any 인스턴스로 만들어서 컨테이너에 저장한다. 아니면 std::variant 인스턴스로 저장해도 된다. 지원할 타입의 범위가 작고 컴파일 시간에 결정할 수 있다면 variant로 만든다.

C++ 17 이전 버전에서 이형(heeterogeneous) 컬렉션이 필요하다면 각 타입에 맞는 파생 클래스로 구성된 클래스를 새로 정의한다.

(이하 개별 컬렉션 설명 생략)

기본 설명

컨테이너 클래스 이름 컨테이너 타입 사용 시기
vector 순차 컨테이너 기본 컨테이너로 사용한다. 프로파일러로 분석한 결과 이보다 낫다고 판단될 때만 다른 컨테이너를 사용한다.
list 순차 사용할 일이 거의 없다. 프로파일러로 분석한 결과 list가 vector 보다 낫다고 판단되지 않으면 웬만하면 vector를 쓴다.
forward_list 순차 사용할 일이 거의 없다. 프로파일러로 분석한 결과 forward_list가 vector 보다 낫다고 판단되지 않으면 웬만하면 vector를 쓴다.
deque 순차 사용할 일이 많지 않다. 주로 vector를 쓴다.
array 순차 표준 C 스타일 배열 대신 고정 크기 배열이 필요할 때
queue 컨테이너 어댑터 FIFO 구조가 필요할 떄
priority_queue 컨테이너 어댑터 우선순위가 있는 queue를 구현하고 싶을 때
stack 컨테이너 어댑터 FILO이나 LIFO 구조를 구현하고 싶을 때
set
multiset
정렬 연관 원소를 정렬된 묶음에 담고, 조회/추가/삭제 성능도 모두 같게 만들고 싶을 때. 원소의 중복을 허용하지 않으려면 set을 이용한다.
map
multimap
정렬 연관 원소를 키와 값이 연관된 순서쌍으로 키 값에 대해 정렬된 상태, 즉 연관 배열로 저장하면서 조회/추가/삭제 성능도 모두 같게 만들고 싶을 때
unodered_map
unordered_multimap
비정렬 연관  키와 값을 묶어서 저장하고 조회, 추가, 삭제 성능이 모두 같게 만들고 싶으면서 원소를 정렬하지 않아도 될 때. 일반 map 보다 성능이 좋지만 원소의 종류에 따라 달라질 수 있다.
unorderd_set
unorderd_multiset 
비정렬 연관 조회, 추가, 삭제 성능이 모두 같게 만들고 싶으면서 우너소를 정렬하지 않아도 될 때, 일반 set보다 성능이 좋지만 원소의 종류에 따라 달라질 수 있다.
bitset 특수 플래그 묶음을 표현하고 싶을 때

성능

컨테이너 클래스 이름 추가 연산 성능 삭제 연산 성능 조회 연산 성능
vector 끝에서는 분할 상환 성능이 O(1), 나머지는 O(N) 끝에서는 O(1), 나머지는 O(N) O(1)
list 시작과 끝점 그리고 추가할 위치가 정확히 결정된 상태에는 O(1) 시작과 끝점 그리고 추가할 위치가 정확히 결정된 상태에는 O(1) 첫 번째와 마지막 원소를 조회할 때는 O(1), 나머지는 O(N)
forward_list 추가할 지점이 시작점이거나 정확한 위치를 안다면 O(1) 삭제할 지점이 시작점이거나 정확한 위치를 안다면 O(1) 첫 번째 원소를 조회할 때는 O(1), 나머지는 O(N)
deque 시작과 끝에서는 O(1), 나머지는 O(N) 시작과 끝에서는 O(1), 나머지는 O(N) O(1)
array N/A N/A N/A
queue 내부 컨테이너의 종류에 따라 다르다. list나 deque로 구현할 때는 O(1) 내부 컨테이너의 종류에 따라 다르다. list나 deque로 구현할 때는 O(1) N/A
priority_queue 내부 컨테이너에 따라 다르다 vector를 사용할 때는 분할 상환 성능이 O(log(N))이고 deque를 사용할 때는 O(log(N))이다. 내부 컨테이너에 따라 다르다. vector나 deque를 사용할 때는 O(log(N)) N/A
stack 내부 컨테이너에 따라 다르다. list나 deque를 사용하면 O(1), vector를 사용하면 분할 상환 성능으로 O(1) 내부 컨테이너에 따라 다르다. list, vector, deque일 때 O(1), N/A
set
multiset
O(log(N)) O(log(N)) O(log(N))
map
multimap
O(log(N)) O(log(N)) O(log(N))
unodered_map
unordered_multimap
평균 O(1),
최악 O(N)
평균 O(1),
최악 O(N)
평균 O(1),
최악 O(N)
unorderd_set
unorderd_multiset 
평균 O(1),
최악 O(N)
평균 O(1),
최악 O(N)
평균 O(1),
최악 O(N)
bitset N/A N/A N/A

Note) 반드시 vector를 기본 컨테이너로 사용하기 바란다. 실전에서 list나 forward_list를 사용하는 것보다 vector로 구현하는 것이 추가나 삭제 연산이 훨씬 빠르다. 그 이유는 최신 CPU에서 메모리와 캐시를 처리하는 방식 때문이기도 하고, list, forward_list를 사용할 때는 추가나 삭제할 지점까지 탐색하는 오버헤드가 있기 때문이다. list나 foward_list는 메모리 공간에 연속적으로 저장되지 않을 수 있다. 그래서 vector보다 반복문의 성능이 떨어질 수 있다.

알고리즘

표준 라이브러리는 컨테이너 뿐만 다양한 제네릭 알고리즘도 제공한다. 표준 라이브러리에서 제공하는 알고리즘은 함수 템플릿으로 구현되어 있어서 다양한 타입의 컨테이너에 적용할 수 있다. 참고로 알고리즘은 컨테이너에 속하지 않는다는 점을 주의한다.

표준 라이브러리는 데이터(컨테이너)와 기능(알고리즘)을 엄격히 구분한다. 얼핏 생각하면 객체지향 프로그래밍 정신에 어긋나 보이지만 표준 라이브러리의 범용성을 유지하기 위해서는 중요한 원칙이다. 이렇나 직교성 원칙에 따라 컨테이너와 알고리즘을 서로 독립적으로 관리한다. 그래서 거의 모든 종류의 알고리즘과 컨테이너를 조합해서 사용할 수 있다.

Note) 알고리즘과 컨테이너가 이론상 구분돼 있지만, 어떤 컨테이너는 클래스 메서드 형태로 알고리즘을 제공하기도 한다. 컨테이너의 성격에 따라 제네릭 알고리즘으로 처리하면 성능이 떨어지기 때문이다. 예컨대 set에서 제공하는 find() 메서드에 제공된 알고리즘은 제네릭 버전의 find() 보다 더 빠르다.

여기서 제네릭 알고리즘을 곧바로 컨테이너에 적용할 수 없다는 점에 주의한다. 대부분 반복자(iterator)라 부르는 중간 매체를 거친다. 표준 라이브러리에서 제공하는 컨테이너는 대부분 그 컨테이너에 담긴 원소를 순차적으로 탐색하도록 반복자를 제공한다. 컨테이너마다 반복자의 동작은 다르지만, 모두 표준 인터페이스를 따르기 때문에 내부적으로 구현된 컨테이너의 종류에 관계 없이 반복자를 구현하는 코드의 형태는 모두 같다. <iterator> 헤더 파일은 다음과 같이 컨테이너에 맞는 반복자를 리턴하는 헬퍼 함수를 제공하고 있다.

함수 이름 설명
begin()
end()
첫 번째 원소부터 마지막 항목의 바로 다음 원소까지 순차적으로(정방향으로) 탐색하는 non-const 반복자를 리턴한다.
cbegin()
cend()
첫 번째 원소부터 마지막 항목의 바로 다음 원소까지 순차적으로(정방향으로) 탐색하는 const 반복자를 리턴한다.
rbegin()
rend()
마지막 원소부터 첫 번째 항목의 바로 전 원소까지 순차적으로(역방향으로) 탐색하는 non-const 반복자를 리턴한다.
crbegin()
crend()
마지막 원소부터 첫 번째 항목의 바로 전 원소까지 순차적으로(역방향으로) 탐색하는 const 반복자를 리턴한다.

Note) 반복자는 알고리즘과 컨테이너를 연결한다. 컨테이너에 담긴 원소를 순차적으로 탐색하기 위한 표준 인터페이스를 제공하기 때문에 알고리즘과 컨테이너의 종류에 관계 없이 똑같은 방식으로 코드를 작성할 수 있다.

Note) 알고리즘을 훑어볼 때 표준 라이브러리는 범용성(일반화)에 주안점을 두고 디자인했다는 사실을 명심하기 바란다. 얼핏 쓸데없어 보이지만 범용성을 위해 반드시 필요한 기능과 구조가 반영돼 있다.

불변형 순차 알고리즘

불변형 순차 알고리즘(non-modifying sequence algorithm)이란 원소를 순차적으로 조회하여 각 원소에 대한 정보를 리턴하는 알고리즘을 말한다. ‘불변형’ 이란 표현에서 눈치챌 수 있듯이 원소의 값이나 순서를 변경하지 않는다. 여기에 속한 알고리즘을 크게 세 가지로 구분할 수 있다.  여기 나온 알고리즘을 사용하면 원소를 순차적으로 탐색할 때 for 문을 작성할 일이 거의 없다.

탐색 알고리즘

탐색 알고리즘(search algorithm)은 원소가 정렬돼 있지 않아도 사용할 수 있다. 여기서 N은 탐색할 대상의 크기를 의미하고 M은 탐색할 패턴의 크기를 의미한다.

알고리즘 이름 설명 복잡도
adjacent_find() 조건으로 입력한 값과 같거나 조건식에 대입한 결과가 같은 연속된 두 원소 중 처음 나온 것을 찾는다. O(N)
find()
find_if()
조건으로 입력한 값과 같거나 조건식의 결과가 true인 원소 중 첫 번째 원소를 찾는다. O(N)
find_first_of() find()와 비슷하지만 여러 원소를 동시에 찾는다. O(NM)
find_if_not() 조건식의 결과가 false인 원소 중 첫 번째 원소를 찾는다. O(N)
find_end() 입력한 시퀀스나 조건식에 맞는 시퀀스 중에서 마지막 부분을 찾는다. O(M*(N-M))
search() 입력된 시퀀스와 일치하거나 입력한 조건식을 기준으로 같다고 판단되는 시퀀스 중에서 첫 번째 항목을 찾는다. O(NM)*
search_n() 입력한 값과 같거나 입력한 조건식을 기준으로 같다고 판단되는 원소 중 n번 연속해서 일치하는 결과 중 첫 번째 결과를 찾는다. O(N)
비교 알고리즘

여기 나온 알고리즘은 입력값이 정렬되지 않아도 사용할 수 있다. 모두 최악의 경우 선형 복잡도를 갖는다.

알고리즘 이름 설명
equal() 입력한 두 시퀀스가 서로 같거나, 입력한 조건식을 모두 만족하는지 검사한다.
mismatch() 입력한 시퀀스와 일치하지 않는 지점의 첫 번째 원소를 리턴한다.
lexicographical_compare() 입력한 두 시퀀스를 사전 나열 순서대로 비교한다. 이 알고리즘은 첫 번째 인수와 두 번째 인수로 입력한 시퀀스의 모든 항목을 하나씩 비교한다. 각 원소를 비교할 때마다 어느 하나가 사전 순으로 더 작다고 판단되면 그 시퀀스가 먼저다. 두 원소가 같으면 그 다음 번째의 원소를 비교한다.
집계 알고리즘
알고리즘 이름 설명
all_of() 입력 시퀀스에 있는 모든 원소에 대해 조건식이 true를 리턴하거나 입력 시퀀스가 공백이면 true를 리턴한다. 나머지는 false를 리턴한다.
any_of() 입력 시퀀스에 있는 원소 중 최소 하나에 대해 조건식이 true를 리턴하면 true를 리턴한다. 나머지는 false를 리턴한다.
none_of() 입력 시퀀스에 있는 모든 원소에 대해 조건식이 false를 리턴하거나 입력 시퀀스가 공백이면 true를 리턴한다. 나머지는 false를 리턴한다.
count()
count_if()
입력한 값과 일치하는 원소나 입력한 조건식의 결과가 true가 되는 원소 수를 센다.

가변형 순차 알고리즘

가변형 순차 알고리즘(modifying sequence algorithm)이란 시퀀스의 모든 원소나 일부 원소를 수정하는 알고리즘이다. 어떤 알고리즘은 원소가 있는 자리에서 바로 수정하기 때문에 순서가 바뀔 수 있다. 또 어떤 알고리즘은 결과를 별도의 시퀀스로 복사하기 때문에 원래 순서가 그대로 유지된다. 두 가지 알고리즘 모두 최악의 경우 선형 복잡도의 성능을 낸다.

알고리즘 이름 설명
copy()
copy_backward()
원본 시퀀스를 대상 시퀀스로 복제한다.
copy_if() 원본 시퀀스에서 조건식이 true를 리턴하는 원소를 대상 시퀀스로 복제한다.
copy_n() 원본 시퀀스에서 n개 원소를 대상 시퀀스로 복제한다.
fill() 시퀀스의 원소를 모두 새 값으로 설정한다.
fill_n() 시퀀스에서 n개 원소를 새 값으로 설정한다.
generate() 지정한 함수를 호출해서 시퀀스의 원소에 채울 새 값을 생성한다.
generate_n() 지정한 함수를 호출해서 시퀀스의 앞부터 n개 원소에 채울 새 값을 생성한다.
move()
move_backward()
원본 시퀀스의 원소를 대상 시퀀스로 옮긴다. 효율적으로 옮기도록 이동 의미론을 적용한다.

revmove()
remove_if()
remove_copy()
remove_copy_if()

지정한 값과 일치하거나 지정한 조건식이 true가 되는 원소를 바로 그 자리에서 삭제하거나 다른 시퀀스로 복제해서 삭제한다.
replace()
replace_if()
replace_copy()
replace_copy_if()
지정한 값과 일치하거나 지정한 조건시기 true가 되는 원소를 모두 그 자리에서 새 원소로 교체하거나 다른 시퀀스로 복제해서 교체한다
reverse()
reverse_copy()
원본 시퀀스에 나열된 원소의 순서를 그 자리에서 반대로 바꾸거나 다른 시퀀스에 복제해서 바꾼다.
rotate()
rotate_copy()
주어진 시퀀스를 두 개로 나눠서 앞부분과 뒷부분의 위치를 그 자리에서 바꾸거나 결과를 다른 시퀀스에 복제한다. 이때 두 시퀀스의 길이는 서로 달라도 된다.
sample() 주어진 시퀀스에서 n개 원소를 무작위로 선택한다.
shuffle()
random_shuffle()
주어진 시퀀스에 담긴 원소의 순서를 무작위로 바꾼다. 이때 사용할 무작위수 생성기를 직접 지정할 수 있다. 
random_shuffle()은 C++ 14부터 폐기 됐고, C++ 17부터 완전히 삭제됐다.
transform() 주어진 시퀀스의 각 우너소에 대해 단항 함수를 호출하거나 두 시퀀스에서 위치가 같은 원소에 대해 이항 함수를 호출한다. 변환은 그 자리에서 수행한다.
unique()
unique_copy()
주어진 시퀀스에서 연속적으로 중복되는 항목을 그 자리에서 제거하거나 다른 시퀀스로 복제해서 제거한다.

작업 알고리즘

작업 알고리즘(operational algorithm)은 시퀀스의 원소마다 함수를 실행한다. 표준 라이브러리에서 제공하는 작업 알고리즘은 두 가지가 있으며 둘 다 선형 복잡도를 갖고 원본 시퀀스를 정렬하지 않아도 사용할 수 있다.

알고리즘 이름 설명
for_each() 주어진 시퀀스에 담긴 원소마다 함수를 실행한다. 시퀀스는 시작 반복자와 끝 반복자로 지정한다.
for_each_n() for_each()와 비슷하지만 주어진 시퀀스에서 첫 n개 원소만 처리한다. 시퀀스는 시작 반복자와 원소 수(n)로 지정한다.

교환 알고리즘

알고리즘 이름 설명
iter_swap()
swap_ranges()
두 원소 또는 시퀀스를 맞바꾼다.
swap() 두 값을 맞바꾼다.
exchage() 지정한 값을 새 값으로 교체하고 기존 값을 리턴한다.

분할 알고리즘

주어진 조건식(predicate)에 시퀀스를 적용했을 때 true를 리턴하는 원소가 false를 리턴하는 원소보다 앞에 있으면 그 시퀀스를 분할(partition)한다. 시퀀스에서 조건식을 만족하지 않는 첫 원소를 분할 지점(partition point, 파티션 포인트)이라 부른다.

알고리즘 이름 설명 복잡도
is_partitioned() 조건식이 true가 되는 원소가 모두 조건식이 false가 되는 원소보다 앞에 있으면 true를 리턴한다. 선형
partition() 조건식이 true가 되는 원소가 모두 조건식이 false가 되는 원소보다 앞에 있도록 시퀀스를 정렬한다. 각 파티션의 원소는 원본 순서를 유지하지 않는다. 선형
stable_partition() 조건식이 true가 되는 원소가 모두 조건식이 false가 되는 원소보다 앞에 있도록 시퀀스를 정렬한다. 각 파티션의 원소는 원본 순서를 유지한다. 선형 로그
partition_copy() 원본 시퀀스에 있는 원소를 서로 다른 두 시퀀스로 복제한다. 대상 시퀀스는 조건식의 결과가 true인지 false인지에 따라 결정한다. 선형
partition_point() 반복자 앞에 나온 원소가 모두 조건식에 대해 true가 되고 반복자 뒤에 나온 원소가 모두 조건식에 대해 false가 되는 반복자를 리턴한다. 로그

정렬 알고리즘

알고리즘 이름 설명 복잡도
is_sorted()
is_sorted_until()
시퀀스 또는 부분 시퀀스가 정렬된 상태인지 검사한다. 선형
nth_element() 시퀀스를 정렬했을 때 인수로 지정한 원소가 n번째 원소가 되도록 위치를 이동하고, 그 앞에 나온 원소가 모두 n번째 원소보다 작고, 그 뒤에 나온 원소가 n번째 원소보다 크도록 정렬한다. 선형
partial_sort()
partial_sort_copy()
시퀀스의 일부분, 즉 첫 n개 원소만 정렬한다(지정한 반복자를 기준으로) 나머지는 그대로 둔다. 그 자리에서 곧바로 정렬하거나 새 시퀀스에 복제해서 정렬한다. 선형 로그
sort()
stable_sort()
원소를 그 자리에서 곧바로 정렬한다. 중복된 원소는 순서가 바뀔수도 있고(sort()), 유지될 수도 있다(stable_sort()) 선형 로그

이진 탐색 알고리즘

여기서 소개할 이진 탐색 알고리즘(binary search algorithm)은 주로 정렬된 시퀀스에 적용한다. 구체적으로 설명하면 대상 시퀀스가 최소한 탐색할 원소를 기준으로 분할된 상태여야 한다. 대상 시퀀스는 std::partition() 등으로 분할 할 수 있다. 정렬된 시퀀스도 마찬가지로 이 조건을 만족해야 한다. 여기 소개하는 알고리즘의 복잡도는 모두 로그다.

알고리즘 이름 설명
lower_bound() 주어진 값보다 작지 않은(크거나 같은) 첫 번째 원소를 시퀀스에서 찾는다.
upper_bound() 주어진 값보다 큰 첫 번째 원소를 시퀀스에서 찾는다.
equal_range() lower_bound()와 upper_bound()의 결과를 모두 담은 pair를 리턴한다.
binary_search() 지정한 값이 시퀀스에 있으면 true, 아니면 false를 리턴한다.

집합 알고리즘

집합 알고리즘(set algorithm)은 시퀀스에 대해 집합 연산을 수행하는 특수한 형태의 가변 알고리즘(modifying algorithm)이다. set 컨테이너에 있는 시퀀스에 가장 적합하지만 다른 컨테이너의 정렬된 시퀀스에도 대부분 적용할 수 있다.

(전체가 아니라 C#과 차이가 있는 부분을 중심으로 요약 정리)

C++ 프로그래머에게 가장 중요한 라이브러리는 C++ 표준 라이브러리(Standard Library)이다. C++ 표준 라이브러리 중에서 가장 핵심은 제네릭 컨테이너와 제네릭 알고리즘이다. 이 라이브러리는 원래 표준 템플릿 라이브러리(Standard Template Library, STL)라는 이름의 서드파티 라이브러리였다. 그래서 아직도 표준 라이브러리는 STL이라 부르는 사람이 많은데 이는 표준 용어가 아니다.

(이하 내용 생략)

코드 작성법

표준 라이브러리는 C++의 템플릿과 연산자 오버로딩 기능을 상당히 많이 사용한다.

템플릿 활용

템플릿을 활용하면 제네릭 프로그래밍을 할 수 있다. 

(이하 설명 생략)

연산자 오버로딩 활용

(내용 생략)

C++ 표준 라이브러리 둘러보기

스트링

C++은 string이란 클래스를 기본으로 제공한다. C++에서 C 스타일의 문자 배열로 스트링을 표현해도 되지만 C++의 string을 활용하는 것이 여러모로 유리하다. 메모리를 관리해 줄 뿐만 아니라 인덱스 경계 검사, 대입과 비교 기능, 스트링 결합 , 스트링 추출, 부분 스트링 만들기, 문자 치환 등과 같은 다양한 기능도 제공한다.

Note) 엄밀히 말하면 std::string은 std::basic_string 템플릿을 char 타입 매개변수로 인스턴스화한 것의 타입 앨리어스이지만 이런 세부사항은 알 필요 없고 일반 클래스처럼 사용해도 된다.

표준 라이브러리는 string_view 클래스도 제공한다. string_view는 스트링을 읽기 전용으로 표현한다. 또한 const string& 자리에 그대로 넣을 수 있고 오버헤드도 발생하지 않는다. 스트링을 복제하지 않기 때문이다.

정규표현식

정규표현식을 활용하면 패턴 매칭을 쉽게 구현할 수 있다. 정규 표현식은 19장에서 설명한다.

I/O 스트림

C++이 제공하는 입력과 출력에 대한 모델.

스마트 포인터

C++은 unique_ptr, shared_ptr, weak_ptr와 같은 스마트 포인터를 제공한다. shared_ptr과 weak_ptr은 스레드에 안전하다.

C++ 11버전 이전에는 unique_ptr에 대한 기능을 auto_ptr 타입으로 처리했지만 C++ 17부터 auto_ptr이 없어졌기 때문에 이제는 이렇게 사용하지 않는 것이 좋다.

익셉션

C++는 익셉션 메커니즘을 제공한다. C++ 표준 라이브러리는 익셉션에 대해 정의된 상속 계층을 제공하는데, 적절한 타입의 익셉션을 코드에서 곧바로 사용하거나 그중에서 원하는 익셉션을 커스터마이즈 할 수도 있다.

수학 관련 유틸리티

C++ 표준라이브러리는 수학 연산에 관련된 다양한 클래스와 함수도 제공한다.

C++ 17부터는 르장드르 다항식, 베타 함수, 타원 적분, 베셀 함수, 원기둥 함수인 노이만 함수와 같은 특수한 수학 함수도 추가됐다.

또한 complex란 이름의 복소수 클래스도 제공한다.

컴파일 시간 유리수 연산 라이브러리는 ratio 클래스 템플릿을 제공한다.

표준 라이브러리는 valarray란 클래스도 제공하는데 이 클래스는 vector와 비슷하지만 고성능 수치 연산용으로 최적화한 것이다. 이 라이브러리는 벡터 슬라이스(vector slice)를 표현하는 클래스를 다양하게 제공한다. 이런 기본 요소로 행렬 연산을 수행하는 클래스를 정의할 수 있다. 표준 라이브러리는 행렬을 직접 다루는 클래스는 제공하지 않지만 부스트 같은 서드파티 라이브러리는 행렬 연산에 관련된 클래스를 제공한다.

시간 관련 유틸리티

C++은 시간에 관련된 chrono 라이브러리도 제공한다.

무작위수

C++은 예전부터 srand()와 rand() 함수를 통해 의사 무작위수 생성기능을 제공했다. 하지만 이 함수는 굉장히 기초적인 수준으로만 무작위수를 생성할 수 있다.

C++ 11부터 이보다 훨씬 강력한 무작위수 라이브러리가 표준에 추가됐다. 이 라이브러리는 <random> 헤더 파일에 정의돼 있으며 무작위수 엔진, 무작위수 엔진 어댑터, 무작위수 분포 등도 제공한다. 이 기능을 활용하면 정규 분포, 역지수 분포 등에 보다 적합한 무작위수를 생성할 수 있다.

이니셜라이즈 리스트

이니셜라이즈 리스트는 인수의 개수가 다양한 함수를 쉽게 작성할 수 있다.

pair와 tuple

<utility> 헤더 파일에서는 서로 다른 타입의 두 원소를 하나로 묶어서 저장하는 pair 템플릿을 정의하고 있다. 이런 저장 방식을 이종(heterogeneous) 원소 저장이라 부른다. 이 장에서 소개하는 표준 라이브러리 컨테이너는 모두 동종(homogeneous) 원소 저장 방식을 따른다. 즉 컨테이너에 담긴 원소의 타입은 모두 같다. pair 템플릿을 이용하면 서로 타입이 다른 두 원소를 객체 하나에 담을 수 있다.

<tuple> 헤더에 정의된 tuple은 pair를 일반화한 것이다. 고정된 크기의 수열로서 서로 타입이 다른 원소를 저장할 수 있다. tuple에 담긴 원소 개수와 타입은 tuple 인스턴스를 생성하는 컴파일 시간에 결정된다. 튜플은 20장에서 자세히 설명한다.

optional, variant, any

C++ 17부터 다음의 클래스가 새로 추가됐다.

  • optional
    • 특정한 타입의 값을 저장하거나 값을 가지지 않을 수 있다. 값이 없을 수도 있는 함수의 매개변수나 리턴 타입으로 사용할 수 있다.
  • variant
    • 지정한 타입 집합 중 하나의 값을 가지거나 값을 가지지 않을 수 있다.
  • any
    • 모든 타입의 값을 단 하나만 가진다.

이 세 가지 클래스는 20장에서 설명한다.

함수 객체

함수 호출 연산자를 구현하는 클래스를 함수 객체(function object), 펑터(functor)라 부른다. 함수 객체는 특정한 표준 라이브러리 알고리즘에 대한 조건식(predicate) 등에 활용된다.

함수 객체는 18장에서 자세히 소개한다.

파일시스템

C++ 17부터 파일시스템을 지원하는 라이브러리가 추가됐다. 그래서 파일시스템을 다루는 코드를 이식(포팅)하기 쉽게 만들 수 있다. 파일시스템 지원 라이브러리는 20장에서 자세히 설명한다.

멀티스레딩

표준 라이브러리는 멀티스레딩을 지원하는데 필요한 다양한 기본 기능을 제공한다. <thread> 헤더 파일에 정의된 thread 클래스를 이용하면 스레드를 하나씩 생성할 수 있다.

멀티스레드 코드를 작성할 때는 여러 스레드가 같은 데이터를 동시에 읽고 쓰지 않도록 조심해야 한다. 이때 <atomic> 헤더 팡리에 정의된 atomic을 사용하면 데이터를 스레드에 안전하고 아토믹하게 (여러 스레드가 동시에 접근하지 않게) 만들 수 있다. <condition_variable>과 <mutex> 헤더 파일에서도 다양한 스레드 동기화 메커니즘을 제공한다.

여러 스레드로 뭔가 계산해서 적절한 예외 처리 방식을 통해 결과를 받기만 한다면 async와 future를 활용한다. 둘 다 <future> 헤더 파일에 정의돼 있으며 thread 클래스를 직접 다룰 때보다 훨씬 사용하기 쉽다.

타입 트레이드

타입 트레이트(type traits, 타입 특성/속성) 기능은 컴파일 시간에 타입 정보를 조회할 수 있다. 이 기능은 고급 템플릿을 작성할 때 유용하며 22장에서 자세히 설명한다.

표준 정수 타입

<cstdint> 헤더 파일에서는 다양한 표준 정수 타입을 정의하고 이싿. 또한 이러한 타입의 최댓값과 최솟값을 지정하는 매크로도 제공한다. 이런 정수 타입에 대한 자세한 사항은 30장에서 다룬다.

컨테이너

표준 라이브러리는 연결 리스트(linked list)나 큐(queue)와 같이 흔히 사용되는 데이터 구조를 제공한다. C++로 프로그래밍할 때는 이러한 데이터 구조를 직접 구현할 필요 없다. 이러한 데이터 구조는 정보를 원소(element) 단위로 저장하는 컨테이너(container) 개념에 따라 구현했으며, 연결 리스트나 큐와 같은 구쳊거인 데이터의 구조의 특성에 맞게 다양하게 구현했다.

표준 라이브러리에서 제공하는 컨테이너는 모두 클래스 템플릿이다. 그래서 int나 double 같은 기본 타입 뿐만 아니라 사용자가 정의한 클래스에 이르기까지 모든 타입의 데이터를 담을 수 있다. 컨테이너 인스턴스마다 단 한가지 타입의 객체만 저장할 수 있다. 다시 말해 동형(homogeneous) 컬렉션이다.

크기가 고정되지 않은 동형 컬렉션이 필요하다면 각각의 원소를 std::any 인스턴스로 만들어서 컨테이너에 저장한다. 아니면 std::variant 인스턴스로 저장해도 된다. 지원할 타입의 범위가 작고 컴파일 시간에 결정할 수 있다면 variant로 만든다.

C++ 17 이전 버전에서 이형(heeterogeneous) 컬렉션이 필요하다면 각 타입에 맞는 파생 클래스로 구성된 클래스를 새로 정의한다.

(이하 개별 컬렉션 설명 생략)

기본 설명

컨테이너 클래스 이름 컨테이너 타입 사용 시기
vector 순차 컨테이너 기본 컨테이너로 사용한다. 프로파일러로 분석한 결과 이보다 낫다고 판단될 때만 다른 컨테이너를 사용한다.
list 순차 사용할 일이 거의 없다. 프로파일러로 분석한 결과 list가 vector 보다 낫다고 판단되지 않으면 웬만하면 vector를 쓴다.
forward_list 순차 사용할 일이 거의 없다. 프로파일러로 분석한 결과 forward_list가 vector 보다 낫다고 판단되지 않으면 웬만하면 vector를 쓴다.
deque 순차 사용할 일이 많지 않다. 주로 vector를 쓴다.
array 순차 표준 C 스타일 배열 대신 고정 크기 배열이 필요할 때
queue 컨테이너 어댑터 FIFO 구조가 필요할 떄
priority_queue 컨테이너 어댑터 우선순위가 있는 queue를 구현하고 싶을 때
stack 컨테이너 어댑터 FILO이나 LIFO 구조를 구현하고 싶을 때
set
multiset
정렬 연관 원소를 정렬된 묶음에 담고, 조회/추가/삭제 성능도 모두 같게 만들고 싶을 때. 원소의 중복을 허용하지 않으려면 set을 이용한다.
map
multimap
정렬 연관 원소를 키와 값이 연관된 순서쌍으로 키 값에 대해 정렬된 상태, 즉 연관 배열로 저장하면서 조회/추가/삭제 성능도 모두 같게 만들고 싶을 때
unodered_map
unordered_multimap
비정렬 연관  키와 값을 묶어서 저장하고 조회, 추가, 삭제 성능이 모두 같게 만들고 싶으면서 원소를 정렬하지 않아도 될 때. 일반 map 보다 성능이 좋지만 원소의 종류에 따라 달라질 수 있다.
unorderd_set
unorderd_multiset 
비정렬 연관 조회, 추가, 삭제 성능이 모두 같게 만들고 싶으면서 우너소를 정렬하지 않아도 될 때, 일반 set보다 성능이 좋지만 원소의 종류에 따라 달라질 수 있다.
bitset 특수 플래그 묶음을 표현하고 싶을 때

성능

컨테이너 클래스 이름 추가 연산 성능 삭제 연산 성능 조회 연산 성능
vector 끝에서는 분할 상환 성능이 O(1), 나머지는 O(N) 끝에서는 O(1), 나머지는 O(N) O(1)
list 시작과 끝점 그리고 추가할 위치가 정확히 결정된 상태에는 O(1) 시작과 끝점 그리고 추가할 위치가 정확히 결정된 상태에는 O(1) 첫 번째와 마지막 원소를 조회할 때는 O(1), 나머지는 O(N)
forward_list 추가할 지점이 시작점이거나 정확한 위치를 안다면 O(1) 삭제할 지점이 시작점이거나 정확한 위치를 안다면 O(1) 첫 번째 원소를 조회할 때는 O(1), 나머지는 O(N)
deque 시작과 끝에서는 O(1), 나머지는 O(N) 시작과 끝에서는 O(1), 나머지는 O(N) O(1)
array N/A N/A N/A
queue 내부 컨테이너의 종류에 따라 다르다. list나 deque로 구현할 때는 O(1) 내부 컨테이너의 종류에 따라 다르다. list나 deque로 구현할 때는 O(1) N/A
priority_queue 내부 컨테이너에 따라 다르다 vector를 사용할 때는 분할 상환 성능이 O(log(N))이고 deque를 사용할 때는 O(log(N))이다. 내부 컨테이너에 따라 다르다. vector나 deque를 사용할 때는 O(log(N)) N/A
stack 내부 컨테이너에 따라 다르다. list나 deque를 사용하면 O(1), vector를 사용하면 분할 상환 성능으로 O(1) 내부 컨테이너에 따라 다르다. list, vector, deque일 때 O(1), N/A
set
multiset
O(log(N)) O(log(N)) O(log(N))
map
multimap
O(log(N)) O(log(N)) O(log(N))
unodered_map
unordered_multimap
평균 O(1),
최악 O(N)
평균 O(1),
최악 O(N)
평균 O(1),
최악 O(N)
unorderd_set
unorderd_multiset 
평균 O(1),
최악 O(N)
평균 O(1),
최악 O(N)
평균 O(1),
최악 O(N)
bitset N/A N/A N/A

Note) 반드시 vector를 기본 컨테이너로 사용하기 바란다. 실전에서 list나 forward_list를 사용하는 것보다 vector로 구현하는 것이 추가나 삭제 연산이 훨씬 빠르다. 그 이유는 최신 CPU에서 메모리와 캐시를 처리하는 방식 때문이기도 하고, list, forward_list를 사용할 때는 추가나 삭제할 지점까지 탐색하는 오버헤드가 있기 때문이다. list나 foward_list는 메모리 공간에 연속적으로 저장되지 않을 수 있다. 그래서 vector보다 반복문의 성능이 떨어질 수 있다.

알고리즘

표준 라이브러리는 컨테이너 뿐만 다양한 제네릭 알고리즘도 제공한다. 표준 라이브러리에서 제공하는 알고리즘은 함수 템플릿으로 구현되어 있어서 다양한 타입의 컨테이너에 적용할 수 있다. 참고로 알고리즘은 컨테이너에 속하지 않는다는 점을 주의한다.

표준 라이브러리는 데이터(컨테이너)와 기능(알고리즘)을 엄격히 구분한다. 얼핏 생각하면 객체지향 프로그래밍 정신에 어긋나 보이지만 표준 라이브러리의 범용성을 유지하기 위해서는 중요한 원칙이다. 이렇나 직교성 원칙에 따라 컨테이너와 알고리즘을 서로 독립적으로 관리한다. 그래서 거의 모든 종류의 알고리즘과 컨테이너를 조합해서 사용할 수 있다.

Note) 알고리즘과 컨테이너가 이론상 구분돼 있지만, 어떤 컨테이너는 클래스 메서드 형태로 알고리즘을 제공하기도 한다. 컨테이너의 성격에 따라 제네릭 알고리즘으로 처리하면 성능이 떨어지기 때문이다. 예컨대 set에서 제공하는 find() 메서드에 제공된 알고리즘은 제네릭 버전의 find() 보다 더 빠르다.

여기서 제네릭 알고리즘을 곧바로 컨테이너에 적용할 수 없다는 점에 주의한다. 대부분 반복자(iterator)라 부르는 중간 매체를 거친다. 표준 라이브러리에서 제공하는 컨테이너는 대부분 그 컨테이너에 담긴 원소를 순차적으로 탐색하도록 반복자를 제공한다. 컨테이너마다 반복자의 동작은 다르지만, 모두 표준 인터페이스를 따르기 때문에 내부적으로 구현된 컨테이너의 종류에 관계 없이 반복자를 구현하는 코드의 형태는 모두 같다. <iterator> 헤더 파일은 다음과 같이 컨테이너에 맞는 반복자를 리턴하는 헬퍼 함수를 제공하고 있다.

함수 이름 설명
begin()
end()
첫 번째 원소부터 마지막 항목의 바로 다음 원소까지 순차적으로(정방향으로) 탐색하는 non-const 반복자를 리턴한다.
cbegin()
cend()
첫 번째 원소부터 마지막 항목의 바로 다음 원소까지 순차적으로(정방향으로) 탐색하는 const 반복자를 리턴한다.
rbegin()
rend()
마지막 원소부터 첫 번째 항목의 바로 전 원소까지 순차적으로(역방향으로) 탐색하는 non-const 반복자를 리턴한다.
crbegin()
crend()
마지막 원소부터 첫 번째 항목의 바로 전 원소까지 순차적으로(역방향으로) 탐색하는 const 반복자를 리턴한다.

Note) 반복자는 알고리즘과 컨테이너를 연결한다. 컨테이너에 담긴 원소를 순차적으로 탐색하기 위한 표준 인터페이스를 제공하기 때문에 알고리즘과 컨테이너의 종류에 관계 없이 똑같은 방식으로 코드를 작성할 수 있다.

Note) 알고리즘을 훑어볼 때 표준 라이브러리는 범용성(일반화)에 주안점을 두고 디자인했다는 사실을 명심하기 바란다. 얼핏 쓸데없어 보이지만 범용성을 위해 반드시 필요한 기능과 구조가 반영돼 있다.

불변형 순차 알고리즘

불변형 순차 알고리즘(non-modifying sequence algorithm)이란 원소를 순차적으로 조회하여 각 원소에 대한 정보를 리턴하는 알고리즘을 말한다. ‘불변형’ 이란 표현에서 눈치챌 수 있듯이 원소의 값이나 순서를 변경하지 않는다. 여기에 속한 알고리즘을 크게 세 가지로 구분할 수 있다.  여기 나온 알고리즘을 사용하면 원소를 순차적으로 탐색할 때 for 문을 작성할 일이 거의 없다.

탐색 알고리즘

탐색 알고리즘(search algorithm)은 원소가 정렬돼 있지 않아도 사용할 수 있다. 여기서 N은 탐색할 대상의 크기를 의미하고 M은 탐색할 패턴의 크기를 의미한다.

알고리즘 이름 설명 복잡도
adjacent_find() 조건으로 입력한 값과 같거나 조건식에 대입한 결과가 같은 연속된 두 원소 중 처음 나온 것을 찾는다. O(N)
find()
find_if()
조건으로 입력한 값과 같거나 조건식의 결과가 true인 원소 중 첫 번째 원소를 찾는다. O(N)
find_first_of() find()와 비슷하지만 여러 원소를 동시에 찾는다. O(NM)
find_if_not() 조건식의 결과가 false인 원소 중 첫 번째 원소를 찾는다. O(N)
find_end() 입력한 시퀀스나 조건식에 맞는 시퀀스 중에서 마지막 부분을 찾는다. O(M*(N-M))
search() 입력된 시퀀스와 일치하거나 입력한 조건식을 기준으로 같다고 판단되는 시퀀스 중에서 첫 번째 항목을 찾는다. O(NM)*
search_n() 입력한 값과 같거나 입력한 조건식을 기준으로 같다고 판단되는 원소 중 n번 연속해서 일치하는 결과 중 첫 번째 결과를 찾는다. O(N)
비교 알고리즘

여기 나온 알고리즘은 입력값이 정렬되지 않아도 사용할 수 있다. 모두 최악의 경우 선형 복잡도를 갖는다.

알고리즘 이름 설명
equal() 입력한 두 시퀀스가 서로 같거나, 입력한 조건식을 모두 만족하는지 검사한다.
mismatch() 입력한 시퀀스와 일치하지 않는 지점의 첫 번째 원소를 리턴한다.
lexicographical_compare() 입력한 두 시퀀스를 사전 나열 순서대로 비교한다. 이 알고리즘은 첫 번째 인수와 두 번째 인수로 입력한 시퀀스의 모든 항목을 하나씩 비교한다. 각 원소를 비교할 때마다 어느 하나가 사전 순으로 더 작다고 판단되면 그 시퀀스가 먼저다. 두 원소가 같으면 그 다음 번째의 원소를 비교한다.
집계 알고리즘
알고리즘 이름 설명
all_of() 입력 시퀀스에 있는 모든 원소에 대해 조건식이 true를 리턴하거나 입력 시퀀스가 공백이면 true를 리턴한다. 나머지는 false를 리턴한다.
any_of() 입력 시퀀스에 있는 원소 중 최소 하나에 대해 조건식이 true를 리턴하면 true를 리턴한다. 나머지는 false를 리턴한다.
none_of() 입력 시퀀스에 있는 모든 원소에 대해 조건식이 false를 리턴하거나 입력 시퀀스가 공백이면 true를 리턴한다. 나머지는 false를 리턴한다.
count()
count_if()
입력한 값과 일치하는 원소나 입력한 조건식의 결과가 true가 되는 원소 수를 센다.

가변형 순차 알고리즘

가변형 순차 알고리즘(modifying sequence algorithm)이란 시퀀스의 모든 원소나 일부 원소를 수정하는 알고리즘이다. 어떤 알고리즘은 원소가 있는 자리에서 바로 수정하기 때문에 순서가 바뀔 수 있다. 또 어떤 알고리즘은 결과를 별도의 시퀀스로 복사하기 때문에 원래 순서가 그대로 유지된다. 두 가지 알고리즘 모두 최악의 경우 선형 복잡도의 성능을 낸다.

알고리즘 이름 설명
copy()
copy_backward()
원본 시퀀스를 대상 시퀀스로 복제한다.
copy_if() 원본 시퀀스에서 조건식이 true를 리턴하는 원소를 대상 시퀀스로 복제한다.
copy_n() 원본 시퀀스에서 n개 원소를 대상 시퀀스로 복제한다.
fill() 시퀀스의 원소를 모두 새 값으로 설정한다.
fill_n() 시퀀스에서 n개 원소를 새 값으로 설정한다.
generate() 지정한 함수를 호출해서 시퀀스의 원소에 채울 새 값을 생성한다.
generate_n() 지정한 함수를 호출해서 시퀀스의 앞부터 n개 원소에 채울 새 값을 생성한다.
move()
move_backward()
원본 시퀀스의 원소를 대상 시퀀스로 옮긴다. 효율적으로 옮기도록 이동 의미론을 적용한다.

revmove()
remove_if()
remove_copy()
remove_copy_if()

지정한 값과 일치하거나 지정한 조건식이 true가 되는 원소를 바로 그 자리에서 삭제하거나 다른 시퀀스로 복제해서 삭제한다.
replace()
replace_if()
replace_copy()
replace_copy_if()
지정한 값과 일치하거나 지정한 조건시기 true가 되는 원소를 모두 그 자리에서 새 원소로 교체하거나 다른 시퀀스로 복제해서 교체한다
reverse()
reverse_copy()
원본 시퀀스에 나열된 원소의 순서를 그 자리에서 반대로 바꾸거나 다른 시퀀스에 복제해서 바꾼다.
rotate()
rotate_copy()
주어진 시퀀스를 두 개로 나눠서 앞부분과 뒷부분의 위치를 그 자리에서 바꾸거나 결과를 다른 시퀀스에 복제한다. 이때 두 시퀀스의 길이는 서로 달라도 된다.
sample() 주어진 시퀀스에서 n개 원소를 무작위로 선택한다.
shuffle()
random_shuffle()
주어진 시퀀스에 담긴 원소의 순서를 무작위로 바꾼다. 이때 사용할 무작위수 생성기를 직접 지정할 수 있다. 
random_shuffle()은 C++ 14부터 폐기 됐고, C++ 17부터 완전히 삭제됐다.
transform() 주어진 시퀀스의 각 우너소에 대해 단항 함수를 호출하거나 두 시퀀스에서 위치가 같은 원소에 대해 이항 함수를 호출한다. 변환은 그 자리에서 수행한다.
unique()
unique_copy()
주어진 시퀀스에서 연속적으로 중복되는 항목을 그 자리에서 제거하거나 다른 시퀀스로 복제해서 제거한다.

작업 알고리즘

작업 알고리즘(operational algorithm)은 시퀀스의 원소마다 함수를 실행한다. 표준 라이브러리에서 제공하는 작업 알고리즘은 두 가지가 있으며 둘 다 선형 복잡도를 갖고 원본 시퀀스를 정렬하지 않아도 사용할 수 있다.

알고리즘 이름 설명
for_each() 주어진 시퀀스에 담긴 원소마다 함수를 실행한다. 시퀀스는 시작 반복자와 끝 반복자로 지정한다.
for_each_n() for_each()와 비슷하지만 주어진 시퀀스에서 첫 n개 원소만 처리한다. 시퀀스는 시작 반복자와 원소 수(n)로 지정한다.

교환 알고리즘

알고리즘 이름 설명
iter_swap()
swap_ranges()
두 원소 또는 시퀀스를 맞바꾼다.
swap() 두 값을 맞바꾼다.
exchage() 지정한 값을 새 값으로 교체하고 기존 값을 리턴한다.

분할 알고리즘

주어진 조건식(predicate)에 시퀀스를 적용했을 때 true를 리턴하는 원소가 false를 리턴하는 원소보다 앞에 있으면 그 시퀀스를 분할(partition)한다. 시퀀스에서 조건식을 만족하지 않는 첫 원소를 분할 지점(partition point, 파티션 포인트)이라 부른다.

알고리즘 이름 설명 복잡도
is_partitioned() 조건식이 true가 되는 원소가 모두 조건식이 false가 되는 원소보다 앞에 있으면 true를 리턴한다. 선형
partition() 조건식이 true가 되는 원소가 모두 조건식이 false가 되는 원소보다 앞에 있도록 시퀀스를 정렬한다. 각 파티션의 원소는 원본 순서를 유지하지 않는다. 선형
stable_partition() 조건식이 true가 되는 원소가 모두 조건식이 false가 되는 원소보다 앞에 있도록 시퀀스를 정렬한다. 각 파티션의 원소는 원본 순서를 유지한다. 선형 로그
partition_copy() 원본 시퀀스에 있는 원소를 서로 다른 두 시퀀스로 복제한다. 대상 시퀀스는 조건식의 결과가 true인지 false인지에 따라 결정한다. 선형
partition_point() 반복자 앞에 나온 원소가 모두 조건식에 대해 true가 되고 반복자 뒤에 나온 원소가 모두 조건식에 대해 false가 되는 반복자를 리턴한다. 로그

정렬 알고리즘

알고리즘 이름 설명 복잡도
is_sorted()
is_sorted_until()
시퀀스 또는 부분 시퀀스가 정렬된 상태인지 검사한다. 선형
nth_element() 시퀀스를 정렬했을 때 인수로 지정한 원소가 n번째 원소가 되도록 위치를 이동하고 그 앞에 나온 원소가 모두 n번째 원소보다 작고, 그 뒤에 나온 원소가 n번째 원소보다 크도록 정렬한다. 선형
partial_sort()
partial_sort_copy()
시퀀스의 일부분, 즉 첫 n개 원소만 정렬한다(지정한 반복자를 기준으로) 나머지는 그대로 둔다. 그 자리에서 곧바로 정렬하거나 새 시퀀스에 복제해서 정렬한다. 선형 로그
sort()
stable_sort()
원소를 그 자리에서 곧바로 정렬한다. 중복된 원소는 순서가 바뀔수도 있고(sort()), 유지될 수도 있다(stable_sort()) 선형 로그

이진 탐색 알고리즘

여기서 소개할 이진 탐색 알고리즘(binary search algorithm)은 주로 정렬된 시퀀스에 적용한다. 구체적으로 설명하면 대상 시퀀스가 최소한 탐색할 원소를 기준으로 분할된 상태여야 한다. 대상 시퀀스는 std::partition() 등으로 분할할 수 있다. 정렬된 시퀀스도 마찬가지로 이 조건을 만족해야 한다. 여기서 소개하는 알고리즘의 복잡도는 모두 로그다.

알고리즘 이름 설명
lower_bound() 주어진 값보다 작지 않은(크거나 같은) 첫 번째 원소를 시퀀스에서 찾는다.
upper_bound() 주어진 값보다 큰 첫 번째 원소를 시퀀스에서 찾는다.
equal_range() lower_bound()와 upper_bound()의 결과를 모두 담은 pair를 리턴한다.
binary_search() 지정한 값이 시퀀스에 있으면 true, 아니면 false를 리턴한다.

집합 알고리즘

집합 알고리즘(set algorithm)은 시퀀스에 대해 집합 연산을 수행하는 특수한 형태의 가변 알고리즘(modifying algorithm)이다. set 컨테이너에 있는 시퀀스에 가장 적합하지만 다른 컨테이너의 정렬된 시퀀스에도 대부분 적용할 수 있다.

알고리즘 이름 설명 복잡도
inplace_merge() 정렬된 시퀀스 두 개를 그 자리에서 병합한다. 선형 로그
merge() 정렬된 시퀀스 두 개를 새 시퀀스에 복제해서 병합한다. 선형
includes() 어떤 정렬된 시퀀스가 다른 정렬된 시퀀스에 완전히 포함되는지 검사한다. 선형
set_union()
set_intersection()
set_difference()
set_symmetric_difference()
정렬된 시퀀스 두 개에 대해 합집합, 교집합, 차집합, 대칭 차집합 연산을 수행해서 제 3의 정렬된 시퀀스로 복제한다. 선형

힙 알고리즘

힙은 최상단(top)에 있는 원소를 빨리 찾도록 적절히 정렬된 상태를 유지하는 배열이나 시퀀스에 대한 데이터 구조다. 대표적인 예로 priority_queue를 구현할 때 힙을 사용한다.

알고리즘 이름 설명 복잡도
is_heap() 주어진 범위의 원소가 힙을 만족하는지 검사한다. 선형
is_heap_until() 주어진 범위의 원소에서 힙을 만족하는 최대 범위를 찾는다. 선형
make_heap() 주어진 범위의 원소에서 힙을 생성한다. 선형
push_heap()
pop_heap()
힙에 원소를 추가하거나 힙에서 원소를 삭제한다. 로그
sort_heap() 힙을 오름차순으로 정렬된 시퀀스로 변환한다. 선형 로그

최대/최소 알고리즘

알고리즘 이름 설명
clamp() 주어진 값 v가 최솟값(lo)과 최댓값(hi) 사이에 있는지 확인한다. v < lo면 lo에 대한 레퍼런스를 리턴하고, v > hi면 hi에 대한 레퍼런스를 리턴하고, 둘 다 아니면 v에 대한 레퍼런스를 리턴한다.
min()
max()
주어진 두 개 이상의 값 중에서 최댓값 또는 최솟값을 리턴한다.
minmax() 주어진 두 개 이상의 값 중에서 최댓값과 최솟값을 pair로 묶어서 리턴한다.
min_element()
max_element()
주어진 시퀀스에서 최대 원소 또는 최소 원소를 리턴한다.
minmax_element() 주어진 시퀀스에서 최대 원소와 최소 원소를 pair로 묶어서 리턴한다.

수치 연산 알고리즘

다음의 수치 연산 알고리즘(numerical processing algorithm)은 모두 정렬되지 않은 시퀀스에 대해 적용할 수 있으며 선형 복잡도의 성능을 낸다.

알고리즘 이름 설명
iota() 시퀀스를 주어진 값에서 싲가해서 연속적으로 증가하는 값으로 채운다.
gcd() 주어진 두 정수의 최대 공약수를 리턴한다.
lcm() 주어진 두 정수의 최소 공배수를 리턴한다.
adjacent_difference() 주어진 시퀀스에서 인접한 두 원소를 골라서 뒤쪽 원소에서 앞쪽 원소를 뺀(또는 다른 바이너리 연산을 적용한) 결과가 원소가 되는 시퀀스를 생성한다. 
(ex a0, a1, … -> a0, a1-a0, a2-a1, …)
partial_sum() 주어진 시퀀스의 각 원소를 그 앞에 나온 모든 원소와 더한 (또는 다른 바이너리 연산을 적용한) 결과가 원소가 되는 시퀀스를 생성한다. 
(ex a0, a1, … -> a0, a0+a1, a0+a1+a2, …)
exclusive_scan()
inclusive_scan()
기본 기능은 partial_sum()과 같지만 inclusive_scan()은 주어진 합 연산이 결합법칙을 만족할 때만 partial_sum()과 같다. 그런데 inclusive_sum()을 더하는 순서는 일정하지 않은 반면 partial_sum은 항상 왼쪽에서 오른쪽 순서로 더한다. 따라서 결합법칙을 만족하지 않는 합 연산을 적용하면 결과가 일정하지 않다. exclusive_scan() 알고리즘도 합 연산의 순서가 일정하지 않다. 
inclusive_scan()을 수행할 때 i번째 원소는 i번째 합에 포함된다. 이는 partial_sum()과 같다. 반면 exclusive_scan()에서는 i번째 원소가 i번째 합에 포함되지 않는다.
transform_exclusive_scan()
transform_inclusive_scan()
주어진 시퀀스의 각 원소마다 변환을 적용한 다음 exclusive_scan() 또는 inclusive_scan()을 적용한다.
accumulate() 주어진 시퀀스의 모든 원솟값을 누적한다. 기본적으로 원소의 합을 구하지만 얼마든지 다른 이항 함수를 지정할 수 있다.
inner_product() accumulate()와 비슷하지만 두 개의 시퀀스에 대해 적용한다. 이 알고리즘은 두 시퀀스에서 위치가 같은 원소를 이항 함수(디폴트 함수는 곱셈)에 적용한 뒤 그 결과를 다른 이항 함수(디폴트 함수는 덧셈)에 적용해서 결과를 누적한다. 주어진 시퀀스가 수학의 벡터라면 두 벡터의 내적을 구한다.
reduce() accumulate()와 비슷하지만 병렬 실행을 지원한다. reduce() 연산의 실행 순서는 일정하지 않지만 accumulate()는 항상 왼쪽에서 오른쪽 순으로 처리한다. 따라서 reduce()에 주어진 이항 함수가 결합법칙이나 교환법칙을 만족하지 않으면 결과가 일정하지 않다.
transform_reduce() 주어진 시퀀스에 있는 각 원소를 변환한 다음 reduce()를 수행한다.

순열 알고리즘

순열(permutation)이란 시퀀스에 담긴 원소를 다양한 순서로 나열하는 것이다.

알고리즘 이름 설명 복잡도
is_Permutation() 두 시퀀스 중 하나가 다른 시퀀스의 순열이면 true를 리턴한다. 제곱(quadratic)
next_permutation()
prev_permutation()
주어진 시퀀스를 사전 순으로 다음 또는 이전에 나오는 순열로 변환한다. 어느 하나에 대해 연속적으로 호출하면 모든 경우의 순열을 구할 수 있다. 단, 제대로 정렬된 시퀀스로 호출하기 싲가해야 한다. 더는 나올 수 있는 순열이 없으면 false를 리턴한다. 선형

표준 라이브러리에서 제공하지 않는 기능

다음과 같은 기능은 표준 라이브러리에 없다.

  • 여러 스레드가 컨테이너에 동시에 접근할 때 스레드 안전성을 보장하지 않는다.
  • 표준 라이브러리는 제네릭 트리나 그래프 구조를 제공하지 않는다. map과 set이 균형 이진트리(balanced binary tree)로 구현돼 있지만, 이를 직접 사용하도록 인터페이스를 제공하지 않는다. 파서 등을 구현하기 위해 트리나 그래프 구조가 필요하다면 직접 만들거나 다른 라이브러리를 활용한다.

전문가를 위한 C++/ C++ 연산자 오버로딩하기

(전체가 아니라 C#과 차이가 있는 부분을 중심으로 요약 정리)

연산자 오버로딩 개요

연산자 오버로딩을 하는 이유

연산자를 오버로딩 하는 이유는 자신이 정의할 클래스를 기본타입처럼 다루기 위함과 프로그램을 좀 더 세밀하게 제어하기 위함이다.

연산자 오버로딩의 한계

다음과 같은 것은 연산자 오버로딩을 할 수 없다.

  • 연산자 기호를 새로 만들 수 없다.
  • .(객체의 멤버에 접근하는 연산자), ::(스코프 지정 연산자), sizeof.?: (조건 연산자)를 비롯한 일부 연산자는 오버로딩할 수 없다.
  • 에리티(arity)는 연산자의 인수 또는 피연산자(operand)의 개수다. 에리티를 변경할 수 있는 곳은 함수 호출, new, delete 연산자 뿐이다. 그 외에 나머지 연산자는 에리티를 변경할 수 없다. ++과 같은 단항 연산자(유너리)는 피 연산자를 하나만 받고, /와 같은 이항 연산자(바이너리)는 피연산자를 두 개 받는다.
  • 연산자의 평가(evaluation) 순서를 결정하는 우선순위(precedence)와 결합순위(associativity)는 바꿀 수 없다.
  • 기본 타입 연산자의 이미는 바꿀 수 없다. 오버로딩할 수 있는 연산자는 클래스의 메서드이거나 오버로딩하려는 전역 함수의 인수 중 최소 하나가 사용자 정의타입(예: 클래스)이어야 한다. 다시 말해 int 타입의 +가 뺄셈이 되도록 의미를 변경할 수 없다.

연산자 오버로딩 선택

연산자 오버로딩은 operatorX 란 이름의 메서드나 함수를 정의하는 방식으로 정의한다. 여기서 X 자리에 오버로딩할 연산자 기호를 적는다. operator와 X사이에 공백이 있어도 된다. 예컨대 9장에 나온 SpreadsheetCell은 operator+ 연산자를 다음과 같이 선언했다.

SpreadsheetCell operator+(const SpreadsheetCell& lhs, const SpreadsheetCell& rhs);

메서드와 전역 함수

오버로딩할 연산자를 클래스의 메서드로 정의할지, 전역 함수(주로 클래스의 friend)로 정의할지 선택해야 한다. 그러기 위해서는 먼저 두 방식의 차이점부터 알아야 한다. 연산자가 클래스의 메서드일 때는 연산자를 사용한 표현식의 좌변은 반드시 그 클래스의 객체여야 한다. 반면 전역 변수로 정의하면 좌변에 다른 타입의 객체를 적을 수 있다.

연산자는 크게 3 종류가 있다.

  • 반드시 메서드로 정의해야 하는 연산자. 
    • 어떤 연산자는 클래스 문맥을 벗어나면 의미가 없기 때문에 반드시 어떤 클래스에 속한 연산자여야 한다. 예컨대 operator=는 소속 클래스와 밀접한 관계에 있어서 클래스와 별도로 존재할 수 없다.
  • 반드시 전역 함수로 정의해야 하는 연산자
    • 연산자의 좌변에 소속 클래스가 아닌 다른 타입의 변수도 나와야 한다면 연산자를 전역 함수로 만들어야 한다. 대표적인 예로 좌변에 연산자가 속한 클래스 객체가 아닌 iostream 객체가 나와야 하는 operator<<와 operator>>가 있다. 또한 이항 연산자인 +나 -처럼 교환 법칙을 따르는 연산자는 반드시 좌변에 연산자가 속한 클래스가 아닌 타입의 변수가 나와야 한다.
  • 메서드와 전역 함수 둘 다 가능한 연산자.
    • C++ 프로그래머 사이에서 연산자를 오버로딩할 때 메서드로 만들어야 할지 전역 함수로 만들어야 할지에 대해 의견이 분분한데, 필자가 생각하는 원칙은 특별히 전역 함수로 만들어야 할 이유가 없다면 무조건 메서드로 만드는 것이 좋다. 왜냐하면 메서드로 만들면 virtual로 선언할 수 있지만 전역함수는 그럴 수 없기 때문이다.

인수 타입에 대한 결정사항

인수 타입에 대해서는 선택할 사항이 많지 않다. 앞서 설명했듯이 인수 개수를 변경할 일이 별로 없기 때문이다. 

(설명 생략)

값으로 받을지 레퍼런스로 받을지는 결정하기 쉽다. 비기본(non-primitive) 타입 매개변수는 모두 레퍼런스로 받게 만들면 된다.

const 지정 여부도 쉽게 결정할 수 있다. 변경할 일이 없다면 무조건 const로 지정한다. 

리턴 타입에 대한 결정사항

C++은 적합한 오버로딩 연산자를 찾을 때 리턴 타입을 고려하지 않는다. 이 때문에 연산자를 오버로딩할 때 리턴 타입을 마음껏 지정할 수 있다. 하지만 이렇게 허용한다고해서 반드시 그렇게 해야 한다는 것은 아니다. 굳이 하면 할 수 있지만 바람직한 코드는 아니다. 오버로딩한 연산자도 기본 타입 연산자와 동일한 타입을 리턴해야 한다.

레퍼런스와 const에 대한 결정 원칙은 리턴타입에도 똑같이 적용된다. 일반적으로 레퍼런스로 리턴할 수 있으면 레퍼런스로 리턴하고, 그렇지 않으면 값으로 리턴하는 원칙을 적용한다.

그렇다면 레퍼런스로 리턴할 수 있다는 것은 어떻게 알 수 있을까? 이 문제는 객체를 리턴하는 연산자에서만 발생한다. bool 타입, 리턴값이 없는 변환 연산자, 아무 타입이나 리턴할 수 있는 함수 호출 연산자 등은 이런 고민을 할 일이 없다. 연산자에서 객체를 새로 생성한다면 반드시 값으로 리턴한다. 그렇지 않으면 연산자를 호출한 객체나 연산자의 인수에 대한 레퍼런스를 리턴한다.

동작에 대한 결정사항

연산자를 오버로딩할 때 동작을 마음껏 구현할 수 있지만 클라이언트의 기대에 어긋나지 않게 구현해야 한다. operator+를 오버로딩한다면 덧셈 성격의 동작을 수행해야 한다. (ex 스트링 결합 같은)

오버로딩하면 안 되는 연산자

C++에서 허용하지만 오버로딩하면 안 되는 연산자가 있다. 그중에서 특히 주소 연산자 (operator&)는 오버로딩해서 좋은 점이 없을 뿐 아니라 변수의 주소를 가져온다는 C++의 기본 동작을 상식과 다르게 변경해버려 오히려 헷갈리게 된다. 연산자 오버로딩을 상당히 많이 사용하는 표준 라이브러리도 주소 연산자만큼은 오버로딩하지 않는다.

이항 부울 연산자인 operator&&와 operator||도 오버로딩하면 안 된다. C++의 단락 평가 규칙(short-circuit evaluation rule)을 적용할 수 없기 때문이다.

마지막으로 콤마 연산자(operator,)도 오버로딩할 수 없다. 콤마 연산자를 순차 연산자(sequencing operator)라고도 부르며, 한 문장에 나온 두 표현식을 분리하며 왼쪽에서 오른쪽 순으로 평가된다.

오버로딩 할 수 있는 연산자

아래 표는 오버로딩할 연산자가 속한 클래스의 이름을 T로 표현하고 이 클래스가 아닌 다른 타입을 E로 표현한다. 여기서 제시한 프로토타입은 한 예에 불과하고 연산자마다 T와 E를 조합해서 선언할 수 있다.

연산자 종류 메서드 또는 전역 함수 오버로딩 용도 프로토타입 예

operator+
operator-
operator*
operator/
operator%

이항 산술 연산자 전역 함수 권장 클래스에 이런 연산이 필요할 때

T operator+(const T&, const T&);
T operator+ (const T&, const E&);

operator+
operator-
operator~
단항 산술 및 비트 연산자 메서드 권장 클래스에 이런 연산이 필요할 때 T operator- () const;
operator++
operator—
선행 증가, 선행 감소 메서드 권장 산술 인수(int, long …)을 받는 +=이나 -=를 오버로딩할 때 T& operator++();
operator++
operator–
후행 증가, 후행 감소 메서드 권장 산술 인수(int, long …)을 받는 +=이나 -=를 오버로딩할 때 T operator++(int);
operator= 대입 연산자 메서드 클래스에 동적으로 할당한 메모리나 리소스 또는 레퍼런스 멤버가 있을 때 T& operator=(const T&);
operator+=
operator-=
operator*=
operator/=
operator%=
축약 산술 대입 연산자 메서드 권장 불변형이 아닌 클래스에서 이항 산술 연산자를 오버로딩할 때 T& operator+=(const T&);
T& operator+=(const E&);
operator<<
operator>>
operator&
operator|
operator^
이항 비트 연산자 전역 함수 권장 이런 연산이 필요할 때 T operator<<(const T&, const T&);
T operator<<(const T&, const E&);
operator<<=
operator>>=
operator&=
operator|=
operator^=
이항 비트 대입 연산자 메서드 권장 불변형으로 디자인되지 않은 클래스에서 이항 비트 연산을 오버로딩할 때 bool operator<(const T&, const T&);
bool operator<(const T&, const E&);

operator<
operator>
operator<=
operator>=
operator==
operator!=

이항 비교 연산자 전역 함수 권장 이런 연산이 필요할 때 ostream& operator<<(ostream&, const T&);
istream& operator>>(istream&, T&);
operator>>
operator<<
 I/O 스트림 연산자(입력과 출력)  전역 함수  이런 연산이 필요할 때 bool operator!() const;
operator!  부울 부정 연산자 메서드 권장  사용할 일이 거의 없다. 이 연산자보다는 bool 또는 void* 변환을 주로 사용한다 bool operator&& (const T&, const T&);
operator&&
operator||
 이항 부울 연산자 전역 함수 권장  사용할 일이 거의 없다. 있더라도 단란 평가를 적용할 수 없다. 차라리 단락 평가가 적용되지 않는 &와 |를 오버로딩하는 것이 좋다. bool operator&&(const T&, const T&);
operator[]  배열 인덱스 연산자  메서드  배열 인덱스를 지원할 때 E& operator[] (size_t) const E& operator[] (size_t) const;
operator()  함수 호출 연산자  메서드  객체를 함수 포인터처럼 만들고 싶을 때 또는 다차원 배열을 접근할 때 또는 []에서 인덱스 하나만 지정할 수 있는 제약을 벗어나고 싶을 때 리턴 타입과 매개변수를 다양하게 지정할 수 있다.
operator type()  변환 또는 캐스팅 연산자(타입마다 연산자를 따로 정의)  메서드  작성한 클래스를 다른 타입으로 변환하는 기능을 제공할 때 operator double() const;
operator new
operator new []
 메모리 할당 루틴 메서드 권장  작성할 클래스에서 메모리 할당을 제어할 때 (사용할 일이 거의 없음) void* operator new(size_t size);
void* operator new[] (size_t size);
operator delete
operator delete[]
 메모리 해제 루틴 메서드 권장  메모리 할당 루틴을 오버로딩할 때(사용할 일이 거의 없음) void operator delete(void* ptr) noexcept;
void operator delete[] (void* ptr) noexcept;
operator*
operator->
 역참조 연산자  operator*는 메서드 권장
operator->는 반드시 메서드
 스마트 포인터를 사용할 때 유용하다. E& operator*() const;
E* operator->() const;
operator&  주소 연산자  해당 사항 없음 해당 사항 없음 해당 사항 없음 
operator->*   멤버 포인터 역참조 해당 사항 없음 해당 사항 없음 해당 사항 없음
operator,  콤마 연산자 해당 사항 없음 해당 사항 없음  해당 사항 없음

우측값 레퍼런스

9장에서 우측값 레퍼런스는 좌측값 레퍼런스에서 쓰는 & 대신 &&를 쓴다고 설명한 적 있다. 9장에서 이동 대입 연산자를 구현할 때 사용했으며, 두 번째 객체가 대입 연산 후 삭제될 임시 객체일 때 컴파일러에서 이렇게 처리한다.

앞의 표에서 일반 대입 연산자의 프로토타입은 다음과 같다.

T& operator=(const T&);

이동 대입 연산자의 프로토타입도 우측값 레퍼런스를 사용한다는 점을 제외하면 이와 같다. 이 연산자는 인수를 변경하므로 const 인수를 전달할 수 없다.

T& operator=(T&&);

앞의 표에는 우측값 레퍼런스를 적용한 프로토타입 예가 없지만 대부분의 연산자는 기존 좌측값 레퍼런스를 사용하는 버전과 우측값 레퍼런스를 사용하는 버전이 함께 있어도 상관없다. 어느 버전이 적합한지는 클래스 구현마다 다르다. 9장에서 소개한 operator=를 예로 들 수 있다.

또 다른 예로 불필요한 메모리 할당을 방지한는 operator+가 있다. 예컨대 표준 라이브러리에서 제공하는 std::string 클래스는 operator+를 다음과 같이 우측값 레퍼런스로 구현했다. (여기서는 간단히 표현했다.)

string operator+(string&& lhs, string&& rhs);

이 연산자는 두 인수가 우측값 레퍼런스로 전달됐기 때문에 둘 중 하나에 대한 메모리를 재사용할 수 있다. 참고로 인수가 우측값 레퍼런스라는 말은 연산이 끝나면 삭제되는 임시 객체라는 뜻이다. 이렇게 구현된 operator+는 두 인수의 크기와 용량에 따라 다음 두 가지 동작 중 하나를 수행한다.

return std::move(lhs.append(rhs));

// 또는
return std::move(rhs.insert(0, lhs));

실제로 std::string에서 제공하는 operator+의 오버로딩 버전들을 보면 좌측값 레퍼런스와 우측값 레퍼런스를 다양하게 조합하고 있다. 그중에서 인수로 string 두 개를 받는 operator+ 연산자를 보면 다음과 같다. (여기서는 간략히 표현했다)

string operator+(const string& lhs, const string& rhs);
string operator+(string&& lhs, const string& rhs);
string operator+(const string& lhs, string&& rhs);
string operator+(string&& lhs, string&& rhs);

관계 연산자

C++ 표준 라이브러리는 <utility>라는 헤더 파일을 제공하는데, 여기서 다양한 헬퍼 함수와 클래스를 제공한다. 또한 다음과 같은 관계 연산자(relational operator)에 대한 함수 템플릿도 std::rel_ops 네임스페이스에서 제공한다.

template<class T> bool operator!=(const T& a, const T& b);  // operator== 정의 필요
template<class T> bool operator>(const T& a, const T& b);  // operator< 정의 필요
template<class T> bool operator<=(const T& a, const T& b);  // operator< 정의 필요
template<class T> bool operator>=(const T& a, const T& b);  // operator< 정의 필요

이러한 함수 템플릿은 다른 클래스에 있는 ==, < 연산자로 !=, >, <=, >=와 같은 연산자를 정의한다. 예컨대 operator==과 operator<를 구현할 때 이 템플릿에 나온 다른 관계 연산자를 그대로 가져다 쓸 수 있다. 코드에서 #include <utility> 문과 다음과 같이 using 문만 추가하면 이 템플릿을 곧바로 쓸 수 있다.

using namespace std::rel_ops;

하지만 현재 정의한 클래스뿐만 아니라 관계 연산에 관련된 모든 클래스에 대해 이 연산자가 생성된다는 문제가 있다. 또한 이렇게 자동 생성된 관계 연산자는 std::greater<T>와 같은 유틸리티 템플릿과 함께 작동하지 않는다. 마지막으로 암묵적 변환도 적용되지 않는다.

Note) 작성할 클래스에서 std::rel_ops를 그대로 사용하지 말고, 관계 연산자를 모두 구현하는 것이 좋다.

산술 연산자 오버로딩하기

단항 뺄셈과 단항 덧셈 연산자 오버로딩하기

int i, j = 4;
int i = -j; // 단항 뺄셈 연산자
int i = +i; // 단항 덧셈 연산자
int i = +(-i); // i에 대한 단항 뺄셈 연산의 결과에 대한 단항 덧셈 연산자를 적용한 예
int i = -(-i); // i에 대한 단항 뺄셈 연산의 결과에 대한 단항 뺄셈 연산자를 적용한 예

단항 뺄셈 연산자는 피연산자의 부호를 반대로 바꾸는 반면 단항 덧셈 연산자는 피연산자를 그대로 리턴한다. 여기서 주목할 점은 단항 덧셈 또는 뺄셈 연산자를 적용한 결과에 다시 단항 덧셈이나 뺄셈 연산을 적용할 수 있다는 것이다. 단항 연산자는 객체를 변경하지 않기 때문에 const로 선언해야 한다.

단항 operator- 연산자를 SpreadsheetCell 클래스의 멤버 함수로 정의한 예를 살펴보자. 단항 덧셈은 대체로 항등 연산 (identity operation)을 수행한다. 따라서 단항 덧셈에 대해서는 오버로딩을 하지 않는다.

SpreachsheetCell SpreadsheetCell::operator-() const
{
return SpreadsheetCell(-getValue());
}

operator-는 피연산자를 변경하지 않기 때문에 음수를 갖도록 SpreadsheetCell 객체를 새로 만들어 리턴해야 한다. 다시 말해 레퍼런스로 리턴할 수 없다. 이렇게 오버로딩한 연산자의 사용법은 다음과 같다.

SpreadsheetCell c1(4);
SpreadsheetCell c3 = -c1;

증가와 감소 연산자 오버로딩하기

(내용 생략)

증가와 감소 연산자는 포인터에도 적용할 수 있다. 스마트 포인터나 반복자로 사용할 클래스를 작성할 때 operator++와 operator–를 오버로딩해서 포인터 증가와 감소 연산을 제공할 수 있다.

비트 연산자와 논리 연산자 오버로딩하기

논리 연산자를 오버로딩하는 과정은 조금 복잡하다. &&나 ||는 오버로딩하지 않는 것이 좋다. 이 연산자는 개별 타입에 적용되지 않고 부울 표현식의 결과를 취합하기만 한다. 게다가 단락 규칙도 적용할 수 없다. 오버로딩한 &&나 || 연산자의 매개변수에 바인딩하기 전에 좌변과 우변을 모두 평가해야 하기 때문이다. 따라서 혹시라도 오버로딩해야 한다면 구체적인 타입에 대해 오버로딩한다.

스트림 입출력 연산자 오버로딩하기

(내용 생략)

인덱스 연산자 오버로딩하기

(예시 코드 생략)

그런데 매번 setElementAt()이나 getElementAt() 메서드를 호출하자니 좀 번거롭다. 기존 배열 인덱스를 활용할 수 있다면 훨씬 편할 것이다. 이럴 때는 인덱스 연산자를 오버로딩하면 된다. 따라서 다음과 같이 operator[]를 구현한다.

template <typename T> T& Array<T>::operator[](size_t x)
{
if (x >= mSize)
{
// 클라이언트가 요청한 원소 뒤로 kAllocSize만큼 공간을 할당한다.
// kAllocSize는 멤버변수
resize(x + kAllocSize);
}
return mElement[x];
}

template <typename T> void Array<T>::resize(size_t newSize)
{
// 더 큰 배열을 생성한다. 값은 0으로 초기화한다.
auto newArray = std::make_unique<T[]>(newSize);

// 새로 생성할 배열은 반드시 이전(mSize)보다 커야 한다.
for (size_t i = 0; i < mSize; i++)
{
// 이전 배열의 원소를 모두 새 배열로 복사한다.
newArray[i] = mElements[i];
}

// 이전 배열을 삭제하고 새 배열을 설정한다.
delete[] mElements;
mSize = newSize;
mElements = newArray.release();
}

operator[]는 x 지점의 원소를 레퍼런스로 리턴하기 때문에 원소를 설정하거나 가져오는데 모두 활용할 수 있다. 이렇게 리턴된 레퍼런스는 원소를 대입할 때도 활용할 수 있다. operator[]가 대입문의 좌변에 있으면 mElement 배열의 x 지점의 원솟값이 실제로 변경된다.

읽기 전용 operator[] 만들기

(생략)

배열의 인덱스가 정수가 아닐 때

(생략)

함수 호출 연산자 오버로딩하기

함수 호출 연산자(operator())도 오버로딩할 수 있다. 클래스를 정의할 때 operator()를 추가하면 이 클래스의 객체를 함수 포인터처럼 사용할 수 있다. 함수 호출 연산자를 제공하는 클래스의 객체를 함수 객체(function object) 또는 갆낟히 펑터(functor, 모듈함수)라 부른다. 이 연산자는 non-static 메서드로 오버로딩해야 한다.

operator()를 오버로딩하는 예를 살펴보기 위해 다음과 같이 클래스 메서드와 똑같은 동작을 수행하는 함수 호출 연산자를 정의해 보자.

class FunctionObject
{
public:
int operator() (int param); // 함수 호출 연산자
int doSquare(int param); // 일반 메서드
};

int FunctionObject::operator() (int param)
{
return doSquare(param);
}

int FunctionObject::doSquare(int param)
{
return param * param;
}

이렇게 정의한 함수 호출 연산자는 다음과 같이 사용한다. 이 코드를 보면 오버로딩 연산자 버전과 일반 메서드 버전을 비교할 수 있다.

int x = 3, xSquared, xSquaredAgain;
FunctionObject square;
xSquared = square(x); // 함수 호출 연산자를 호출
xSquaredAgain = square.doSquare(x); // 일반 메서드 호출

얼핏 보면 함수 호출 연산자를 사용하는 코드가 어색해 보이는데, 클래스의 객체를 만든느 특수한 메서드를 굳이 이렇게 함수 포인터처럼 보이는 형태로 정의할 필요가 있을까 싶지만, 이렇게 일반 메서드 대신 함수 객체로 만들면 함수 객체를 함수 포인터로 표현해서 다른 함수에 콜백 함수로 전달할 수 있다.

전역 함수보다 함수 객체로 만들면 다음과 같은 미묘한 장점이 있다.

  • 함수 호출 연산자를 여러 번 호출하더라도 객체의 데이터 멤버를 통해 정보를 지속적으로 유지할 수 있다. 예컨대 함수 호출 연산자를 호출할 때마다 누적된 숫자의 합을 함수 객체에 유지할 수 있다.
  • 데이터 멤버를 설정하는 방식으로 함수 객체의 동작을 변경할 수 있다. 예컨대 함수 호출 연산자에 지정한 인수를 데이터 멤버의 값과 비교하는 함수 객체를 만들 수 있다. 이때 데이터 멤버를 설정할 수 있다면 비교 방식을 마음대로 변경할 수 있다.

물론 앞의 장점을 전역 변수나 static 변수로도 구현할 수 있지만 함수 객체를 활용하면 코드가 훨씬 깔끔하다. 게다가 전역 변수나 static 변수는 멀티스레드 애플리케이션에서 문제를 발생시킬 수 있다. 함수 객체의 진정한 장점은 18장에서 구체적으로 설명하겠다.

메서드 오버로딩 규칙에 따르면 클래스에 operator()를 얼마든지 추가할 수 있다. 예컨대 다음과 같이 FunctionObject 클래스에 std::string_view 타입의 인수를 받는 operator()를 추가할 수 있다.

int operator() (int param);
void operator() (std::string_view str);

다차원 배열의 인덱스를 지정할 때도 함수 호출 연산자를 활용할 수 있다. operator()를 단순히 operator[] 처럼 작동하도록 작성하되 인덱스를 하나 이상 받도록 만들면 된다. 하지만 이 기법을 적용할 때는 인덱스를 []가 아닌 ()로 묶어야 한다. (ex myArray(3, 4) = 6;)

역참조 연산자 오버로딩하기

역참조 연산자(*, ->. ->*)도 오버로딩할 수 있다. ->* 연산자에 대한 설명은 잠시 미루고, *와 -> 연산자의 기본 의미부터 살펴보자. *는 포인터가 가리키는 값에 직접 접근하는 역참조 연산자다. 반면 ->는 * 연산자 뒤에 멤버를 지정하는 . 연산자를 붙인 것을 축약한 것이다. 예컨대 다음 두 문장은 서로 의미가 같다.

SpreadsheetCell* cell = new SpreadsheetCell;
(*cell).set(5);
cell->set(5);

클래스를 직접 정의할 때 역참조 연산자를 오버로딩하면 그 클래스의 객체를 포인터처럼 다룰 수 있다. 이 기능은 스마트 포인터를 구현할 때 사용한다. 또한 반복자를 다룰 때도 유용하다. 그래서 표준 라이브러리는 이 기능을 상당히 많이 활용한다.

Caution) 아래 예제는 역참조 연산을 오버로딩하는 방법을 보여주기 위한 것일 뿐 스마트 포인터는 직접 정의하는 것보다 표준 스마트 포인터 클래스를 활용하는 것이 바람직하다.

예제에서 활용할 스마트 포인터 클래스 템플릿의 정의는 다음과 같다. 아직은 역참조 연산자를 작성하지 않았다.

template <typename T> class Pointer
{
public:
Pointer(T* ptr);
virtual ~Pointer();

Pointer(const Pointer<T>& src) = delete;
Pointer<T>& operator=(const Pointer<T>& rhs) = delete;

private:
T* mPtr = nullptr;
}

여기서는 스마트 포인터를 최대한 간단히 정의했는데, 일반 포인터로 저장했다가 스마트 포인터가 소멸될 때 그 포인터가 가리키던 공간을 해제하기만 한다. 구현 코드도 간안하다. 생성자에서 일반 포인터를 받아서 클래스의 데이터 멤버에 저장한다. 이 클래스의 유일한 데이터 멤버이기도 하다. 소멸자는 포인터가 참조하던 공간을 해제한다.

template <typename T> Pointer<T>::Pointer(T* ptr) : mPtr(ptr)
{
}

template <typename T> Pointer<T>::~Pointer()
{
delete mPtr;
mPtr = nullptr;
}

이렇게 정의한 스마트 포인터 클래스 템플릿을 사용하는 방법은 다음과 같다.

Pointer<int> smartInt(new int);
*smartInt = 5; // 스마트 포인터를 역참조한다.
cout << *smartInt << endl;

Pointer<SpreadsheetCell> smartCell(new SpreadsheetCell);
smartCell->set(5);
cout << smartCell->getValue() << endl;

예제에서 볼 수 있듯이 이 클래스에 대해 operator*와 operator-> 연산자를 구현해야 한다.

operator* 구현 방법

일반적으로 포인터를 역참조 한다는 말은 포인터가 가리키던 메모리에 접근한다는 의미다. 포인터가 가리키는 메모리가 int와 같은 기본 타입 값을 담고 있다면 그 값을 직접 변경할 수 있다. 반면 포인터가 가리키는 메모리가 객체와 같이 복합 타입으로 된 대상을 담고 있다면 그 안에 있는 데이터 멤버나 메서드에 접근하기 위해서는 . 연산자를 사용해야 한다.

이렇게 하려면 operator*가 레퍼런스를 리턴해야 한다. 따라서 Pointer 클래스에 다음과 같이 정의한다.

template <typename T> class Pointer
{
public:
T& operator*();
cosnt T& operator*() const;

// 나머지 코드 생략
}

template <typename T> T& Pointer<T>::operator*()
{
return *mPtr;
}

template <typename T> const T& Pointer<T>::operator*() const
{
return *mPtr;
}

여기서 볼 수 있듯이 operator*는 클래스 내부의 일반 포인터가 가리키던 객체나 변수에 대한 레퍼런스를 리턴한다. 인덱스 연산자를 오버로딩할 때처럼 이 메서드를 const와 non-const 버전을 모두 제공해서 const 레퍼런스와 non-const 레퍼런스를 리턴하도록 정의하면 좋다.

operator-> 구현 방법

화살표 연산자를 구현하는 방법은 좀 까다롭다. 화살표 연산자를 적용한 결과는 반드시 객체의 멤버나 메서드여야 한다. 그런데 이렇게 구현하려면 operator*를 실행한 뒤 곧바로 operator.를 호출하게 만들어야 한다.

하지만 C++에서는 operator.를 오버로딩할 수 없다. 이렇게 제한하는데는 이유가 있는데 프로토타입 하나만으로 임의의 멤버나 메서드를 선택하게 만들 수 없기 때문이다. 그래서 C++은 operator->를 예외로 취급한다. 예컨대 다음 문장을 보자.

smartCell->set(5);

C++은 이 문장을 다음과 같이 해석한다.

(smartCell.operator->())->set(5);

이처럼 C++은 오버로딩한 operator->에서 리턴한 값에 다른 operator->를 적용한다. 그래서 다음과 같이 반드시 포인터로 리턴하게 오버로딩해야 한다.

template <typename T> class Pointer
{
public:
T& operator->();
cosnt T& operator->() const;

// 나머지 코드 생략
}

template <typename T> T* Pointer<T>::operator->()
{
return mPtr;
}

template <typename T> const T* Pointer<T>::operator->() const
{
return mPtr;
}

operator.*와 operator->*

C++은 클래스의 데이터 멤버와 메서드에 대한 주소를 받아서 포인터를 만드는 기능을 정식으로 지원한다. 하지만 객체를 거치지 않고서는 non-static 메서드나 데이터 멤버를 호출하거나 접근할 수 없다. 클래스에서 데이터 멤버와 메서드를 제공하는 목적은 객체마다 데이터 멤버와 메서드를 따로 갖게 하기 위해서다. 따라서 포인터를 통해 데이터 멤버에 접근하거나 메서드를 호출하려면 반드시 객체의 문맥 안에서 포인터를 역참조 해야 한다.

다음 예를 통해 구체적인 방법을 알아보자. 정확한 문법은 11.3.3절의 ‘메서드와 데이터 멤버를 가리키는 포인터에 대한 타입 앨리어스’를 참조한다.

SpreadsheetCell myCell;
double (SpreadsheetCell::*methodPtr) () const = &SpreadsheetCell::getvValue;
cout << (myCell.*methodPtr)() << endl;

이 코드를 보면 .* 연산자로 메서드 포인터를 역참조하는 방식으로 메서드를 호출했다. 또한 객체 자체는 없고, 객체에 대한 포인터만 있을 때는 다음과 같이 operator->*로 메서드를 호출할 수도 있다.

SpreadsheetCell* myCell = new SpreadsheetCell();
double (SpreadsheetCell::*methodPtr) () const = &SpreadsheetCell::getvValue;
cout << (myCell->*methodPtr)() << endl;

operator.와 마찬가지로 operator.*도 오버로딩할 수 없다. operator->*는 오버로딩할 수 있지만 구현 방법이 복잡할 뿐만 아니라 이렇게 포인터로 데이터 멤버나 메서드를 접근할 수 있다는 사실을 아는 C++ 프로그래머도 거의 없기 때문에 굳이 이렇게 작성할 필요가 없다. 표준 라이브러리에서 제공하는 std::shared_ptr 템플릿도 operator->*를 오버로딩하지 않는다.

변환 연산자 구현하기

다음 두 문장을 살펴보자.

SpreadsheetCell cell(1.23);
double d1 = cell; // 컴파일 에러

SpreadsheetCell을 double으로도 표현할 수 있지만 컴파일러는 SpreadsheetCell을 double로 변환하는 방법을 모르기 때문에 에러가 발생한다. 이를 해결하기 위해 다음과 같이 수정해도 마찬가지다.

double d1 = (double)cell;  // 그래도 컴파일 에러가 발생한다.

여전히 컴파일러는 SpreadsheetCell을 double로 변환하는 방법을 모르기 때문이다. 이 문장처럼 대입하고 싶다면 컴파일러에 구체적인 방법을 알려줘야 한다. 다시 말해 SpreadsheetCell을 double로 변환하는 변환 연산자를 구현해야 한다. 여기에 사용할 변환 연산자의 프로토타입은 다음과 같이 선언한다.

operator double() const;

이 함수의 이름은 operator double이다. 함수 이름 안에 리턴 타입이 표현됐기 때문에 리턴 타입을 지정할 필요는 없다. 그리고 이 연산자를 호출할 객체는 변경되지 않기 때문에 const로 지정했다. 이 연산자의 구현 코드는 다음과 같다.

SpreadsheetCell::operator double() const
{
return getValue();
}

다른 타입에 대한 변환 연산자도 이와 똑같은 방식으로 작성한다. 예컨대 SpreadsheetCell의 std::string 변환 연산자를 다음과 같이 구현할 수 있다.

SpreadsheetCell::operator std::string() const
{
return doubleToString(getValue());
}

명시적 변환 연산자로 모호한 문제 해결하기

SpreadsheetCell 객체에 대해 double 변환 연산자를 추가하면 모호함이 발생할 수 있다. 예컨대 다음 코드를 보자.

SpreadsheetCell cell(1.23);
double d2 = cell + 3.3; // operator double()을 정의했다면 컴파일 에러가 발생한다.

에러가 발생하는 이유는 컴파일러가 cell을 operator double()에 적용해서 double 덧세믕로 처리할지, 아니면 3.3을 double 생성자에 적용해서 SpreadsheetCell로 변환한 뒤 SpreadsheetCell 덧셈으로 처리할지 결정할 수 없기 때문이다. operator double()을 구현하기 전에는 고민할 일이 없었다.

C++ 11 이전에는 이런 모호함이 발생하면 생성자 앞에 explicit 키워드를 지정해서 자동 변환할 때 이 생성자를 사용하지 않게 할 수 있었다. 하지만 생성자를 explicit으로 지정하는 것은 바람직하지 않은데, double을 SpreadsheetCell로 자동으로 변환하는 기능이 필요할 때가 많기 때문이다. C++ 11부터는 생성자 대신 double 변환 연산자를 explicit으로 선언하는 방식으로 해결한다.

explicit operator double() const;

부울 표현식으로 변환하기

때로 객체를 부울 표현식에서 사용하면 좋을 때가 있다.

if (ptr != nullptr) { /* 역참조 연산을 수행한다. */ }

if (ptr) { /* 역참조 연산을 수행한다. */ }

if (!ptr) { /* 역참조 연산을 수행한다. */ }

앞서 정의한 Pointer를 이렇게 사용하면 컴파일 에러가 발생하는데, Pointer에 포인터 타입으로 변환하는 연산자를 추가하면 문제없이 쓸 수 있다. 그래서 if 문의 조건에 nullptr과 비교하는 문장을 적을 때 뿐만 아니라 그냥 객체만 적어도 자동으로 포인터 타입으로 변환된다.

이런 변환 연산자는 주로 void* 타입을 사용한다. 이 포인터 타입을 사용하면 부울 표현식에서 테스트하는 용도 외에는 다르게 활용할 수 없기 때문이다. 포인터 변환 연산자의 구현 예는 다음과 같다.

template <typename T> Pointer<T>::operator void*() const
{
return mPtr;
}

이제 다음과 같이 코드를 작성해도 문제없이 컴파일 되고 의도대로 실행된다.

if (p != nullptr) { cout << "not nullptr" << endl; }
if (p != NULL) { cout << "not NULL" << endl; }
if (p) { cout << "not nullptr" << endl; }
if (!p) { cout << "nullptr" << endl; }

또 다른 방법으로 operator void*() 대신 다음과 같이 operator bool()을 오버로딩해도 된다. 어차피 객체를 부울 표현식에서 사용할 것이기 때문에 직접 bool로 변환하는 것이 낫다.

template <typename T> Pointer<T>::operator bool() const
{
return mPtr != nullptr;
}

다음과 같은 비교 연산도 문제없이 실행된다.

if (p != NULL) { cout << "not NULL" << endl; }
if (p) { cout << "not nullptr" << endl; }
if (!p) { cout << "nullptr" << endl; }

그런데 operator bool()을 이용할 때 다음과 같이 nullptr과 비교하는 문장에서 에러가 발생한다.

if (p != nullptr) { cout << "not nullptr" << endl; }

nullptr의 타입은 nullptr_t이고 자도응로 0(false)로 변환되지 않기 때문이다. operator !=과 같은 연산자를 Pointer 클래스의 friend로 구현해도 된다.

template <typename T>
class Pointer
{
public:
// 코드 생략
template <typename T>
friend bool operator!=(const Pointer<T>& lhs, std::nullptr_t rhs);
};

template<typename T>
bool operator!=(const Pointer<T>& lhs, std::nullptr_t rhs)
{
return lhs.mPtr != rhs;
}

그런데 operator!=을 이렇게 구현하면 다음과 같은 비교는 할 수 없게 된다. 어느 operator!=를 사용할지 컴파일러가 결정할 수 없기 때문이다.

if (p != NULL) { cout << "not NULL" << endl; }

이 예제를 보면 포인터를 표현하지 않는 객체를 사용할 때와 이렇게 포인터 타입으로 변환하는 것이 맞지 않을 때만 operator bool()을 추가하는 방식으로 구현해야 한다고 생각하기 쉽다. 아쉽게도 bool로 변환하는 연산자를 추가하면 이 문제 뿐만 아니라 다른 예상치 못한 문제도 발생한다. 이런 경우가 발생하면 C++은 bool을 int로 자동으로 변환하는 ‘프로모션(promotion, 승격)’ 규칙을 적용한다. 따라서 operator bool()이 정의됐을 때 다음과 같이 작성하면 문제 없이 컴파일해서 실행할 수 있다.

Pointer<SpreadsheetCell> anotherSmartCell(new SpreadsheetCell(5.0));
int i = anotherSmartCell; // Pointer를 bool로, 다시 int로 변환한다.

그런데 이는 의도한 동작이 아니다. 대입문이 이렇게 처리되지 않게 하려면 int, long, long long 등에 대한 변환 연산자를 명싲거으로 delete해야 한다. 하지만 코드가 지저분해진다. 그래서 대부분 operator bool() 대신 operator void*()를 선호한다.

메모리 할당과 해제 연산자 오버로딩하기

C++은 메모리 할당과 해제 작업을 원하는 형태로 정의하는 기능을 제공한다. 이러한 커스터마이즈 작업은 프로그램 전반에 적용하게 만들 수도 있고, 클래스 단위로 적용하게 만들 수도 있다. 이 기능은 조그만 개겣들을 여러 차례 할당하고 해제하는 과정에서 발생하기 쉬운 메모리 파편화(memory fragmentation, 메모리 단편화)를 방지하는데 주로 사용된다. 

예컨대 메모리가 필요할 때마다 디폴트 C++ 메모리 할당 기능 대신 고정된 크기의 메모리 영역을 미리 할당해서 메모리 풀 할당자(memory pool allocator)로 만들고 여기서 메모리를 재사용하도록 구현할 수 있다. 

여기서는 이렇게 메모리 할당과 해제 루틴을 직접 만드는 방법과 이 과정에서 발생하는 여러 가지 이슈를 소개하겠다. 이 기법을 익혀두면 나중에 메모리 할당자를 직접 만들 때 써먹을 수 있다.

Caution) 다양한 메모리 할당 전략을 잘 모른다면 힘들여 메모리 할당 루틴을 오버로딩해도 큰 효과를 보기 힘들다. 단지 좋아 보인다는 이유만으로 메모리 할당 및 해제 루틴을 오버로딩하지 말기 바란다.

new와 delete의 구체적인 작동 방식

C++에서 난해한 부분 중 하나는 new와 delete의 세부 작동과정이다. 예컨대 다음 코드를 살펴보자.

SpreadsheetCell* cell = new SpreadsheetCell();

여기서 new SpreadsheetCell()을 new-표현식이라 부른다. 이 문장은 두 가지 일을 한다. 먼저 operator new를 호출해서 SpreadsheetCell 객체에 대한 메모리를 할당한다. 그러고 나서 객체의 생성자를 호출한다. 생성자의 실행이 끝나야 객체에 대한 포인터가 리턴된다.

delete 작동 방식도 비슷하다. 다음 코드를 보자.

delete cell;

이렇게 작성한 문장을 delete-표현식이라 부른다. 이 문장을 실행하면 먼저 cell의 소멸자를 호출한 다음 operator delete를 호출해서 cell에 할당된 메모리를 해제한다.

operator new와 operator delete를 오버로딩해서 메모리 할당과 해제 과정을 직접 제어할 수 있다. 그런데 new-표현식과 delete-표현식 자체를 오버로딩할 수는 없다. 다시 말해 실제로 메모리를 할당하고 해제하는 과정은 커스터마이징 할 수 있지만 생성자와 소멸자를 호출하는 동작은 변경할 수 없다.

new-표현식과 operator new

new-표션식은 여섯 가지 종류가 있다. 각 버전마다 적용되는 operator new가 따로 있다. 그중 네 가지(new, new[], new(nothorw), new(nothrow)[])는 앞서 본 ㅈ거이 있다. 이러한 네 가지 형태에 대응되는 opeartor new는 다음과 같으며 모두 <new> 헤더 파일에 정의돼 있다.

void* operator new(size_t size);
void* operator new[](size_t size);
void* operator new(size_t size, const std::nothrow_t&) noexcept;
void* operator new[](size_t size, const std::nothrow_t&) noexcept;

나머지 두 개는 실제로 객체를 할당하지 않고 기존에 저장된 객체의 생성자를 호출만 하는 특수한 형태의 new-표현식이다. 이를 배치 new 연산자(placement new operator)라 부르며 일반 변수 버전과 배열 버전이 있다. 이 연산자를 이용하면 다음과 같이 기존에 확배된 메모리에서 객체를 생성할 수 있다.

void* ptr = allocateMemorySomehow();
SpreadsheetCell* cell = new (ptr) SpreadsheetCell();

구문이 좀 어색하지만 이런 기능이 있다는 사실은 반드시 알아둘 필요가 있다. 매번 메모리를 해제하지 않고 재사용할 수 있도록 메모리 풀을 구현할 때 유용하기 때문이다. 두 가지 버전의 배치 new 연산자에 대응되는 operator new는 다음과 같다. 참고로 C++ 표준에서는 다음 두 가지 operator new에 대한 오버로딩을 금지하고 있다.

void* operator new(size_t size, void* p) noexcept;
void* operator new[](size_t size, void* p) noexcept;

delete-표현식과 operator delete

직접 호출할 수 있는 delete-표현식은 단 두 개(delete, delete[]) 뿐이다. nothrow나 배치 버전은 없다. 하지만 operator delete는 여섯 가지나 있다. delete-표현식과 operator delete의 짝이 맞지 않는 이유는 nothrow 버전 두 개와 배치 버전 두 개는 생성자에서 익셉션이 발생할 때만 사용되기 때문이다.

이렇게 익셉션이 발생하면 생성자를 호출하기 전에 메모리를 할당하는데 사용했던 operator new에 대응되는 operator delete가 호출된다. 그런데 기존 방식대로 포인터를 delete로 삭제하면 (nothrow나 배치 버전이 아닌) operator delete나 operator delete[]가 호출된다. 실제로 이로 인해 문제가 발생하지는 않는다.

C++ 표준에서는 delete에서 익셉션을 던질 때의 동작이 명확히 정의돼 있지 않아서 실행 결과를 예측할 수 없다. 다시 말해 delete에서 절대로 익셉션을 던지면 안 되기 때문에 nothrow 버전의 operator delete를 따로 둘 필요가 없다. 배치 버전의 delete도 아무런 작업을 하지 않아야 한다. 배치 버전의 new로는 메모리가 할당되지 않기 때문에 해제할 대상이 없다. 여섯 가지 버전의 operator delete의 프로토타입은 다음과 같다.

void operator delete(void* ptr) noexcept;
void operator delete[](void* ptr) noexcept;
void operator delete(void* ptr, const std::nothrow_t&) noexcept;
void operator delete[](void* ptr, const std::nothrow_t&) noexcept;
void operator delete(void* p, void*) noexcept;
void operator delete[](void* p, void*) noexcept;

operator new와 operator delete 오버로딩하기

전역 함수 버전인 operator new와 operator delete는 필요에 따라 오버로딩할 수 있다. 이 함수는 프로그램에 new-표현식이나 delete-표현식이 나올 때마다 호출된다. 단 클래스마다 이보다 구체적인 루틴이 정의돼 있다면 호출되지 않는다.

그런데 C++를 만든 비야네 스트롭스트룹은 <The C++ Programming Language> 3판에서 ‘간이 크지 않다면 전역 operator new와 operator delete를 교체하지 말라고 했다. 필자도 같은 생각이다.

Caution) 이렇게 경고했음에도 전역 operator new를 교체했다면 최소한 그 연산자 코드에 new를 호출하는 문장을 절대 넣지 말기 바란다. 여기에 new를 호출하는 문장을 넣어버리면 무한 재귀 호출이 발생해서 cout으로 메시지를 콘솔에 출력하는 것조차 못하게 된다.

앞서 섦옇나 기법보다는 operator new와 operator delete를 프로그램 전체가 아닌 특정한 클래스에 대해서만 오버로딩하는 것이 좋다. 이렇게 오버로딩하면 해당 클래스의 객체를 할당하거나 해제할 때만 그 연산자가 호출되게 할 수 있다. 배치 버전이 아닌 네 가지 operator new와 operator delete를 클래스에 대해 오버로딩하는 예를 살펴보자.

#include <cstddef>
#include <new>

class Memorydemo
{
public:
virtual ~MemoryDemo() = default;

void* operator new(size_t size);
void operator delete(void* ptr) noexcept;

void* operator new[](size_t size);
void operator delete[](void* ptr) noexcept;

void* operator new(size_t size, const std::nothrow_t&) noexcept;
void operator delete(void* ptr, const std::nothrow_t&) noexcept;

void* operator new[](size_t size, const std::nothrow_t&) noexcept;
void operator delete[](void* ptr, const std::nothrow_t&) noexcept;
};

전역 버전의 연산자에 단순히 인수만 전달해서 호출하는 방식으로 구현한 예는 다음과 같다. 여기서 nothrow는 실제로 nothrow_t 타입의 변수다.

void* MemoryDemo::operator new(size_t size)
{
cout << "operator new" << endl;
return ::operator new(size);
}

void MemoryDemo::operator delete(void* ptr) noexcept
{
cout << "operator delete" << endl;
::operator delete(ptr);
}

void* MemoryDemo::operator new[](size_t size)
{
cout << "operator new[]" << endl;
return ::operator new[](size);
}

void MemoryDemo::operator delete[](void* ptr) noexcept
{
cout << "operator delete[]" << endl;
::operator delete[](ptr);
}

void* MemoryDemo::operator new(size_t size, const nothrow_t&) noexcept
{
cout << "operator new nothrow" << endl;
return ::operator new(size, nothrow);
}

void MemoryDemo::operator delete(void* ptr, const nothrow_t&) noexcept
{
cout << "operator delete nothrow" << endl;
::operator delete(ptr, nothrow);
}

void* MemoryDemo::operator new[](size_t size, const nothrow_t&) noexcept
{
cout << "operator new[] nothrow" << endl;
return ::operator new[](size, nothrow);
}

void MemoryDemo::operator delete[](void* ptr, const nothrow_t&) noexcept
{
cout << "operator delete[] nothrow" << endl;
::operator delete[](ptr, nothrow);
}

이렇게 정의한 MemoryDemo 클래스에 대해 객체를 할당하고 해제하는 방법은 다음과 같다.

MemoryDemo* mem = new MemoryDemo();
delete mem;

mem = new MemoryDemo[10];
delete [] mem;

mem = new (nothrow) MemoryDemo();
delete mem;

mem = new (nothrow) MemoryDemo[10];
delete [] mem;

여기 나온 operator new와 operator delete의 구현 방식은 너무 간단해서 실전에 쓸 가능성은 없다. 실전에서 구현할 일이 있을 때 어떻게 작성하는지 보여주기 위한 용도일 뿐이다.

Caution) operator new를 오버로딩할 때 반드시 이에 대응되는 operator delete도 오버로딩해야 한다. 그렇지 않으면 메모리가 지정한 크기만큼 할당되긴 하지만, 메모리를 해제할 때는 C++의 기본 동작에 따라 처리하기 때문에 할당 로직과 맞지 않을 수 있다.

모든 버전의 operator new를 오버로딩하는 것은 지나치다고 생각하기 쉽지만 이렇게 하는 것이 메모리 할당 방식의 일관성을 유지하는데 도움이 된다. 일부 버전에 대한 구현을 생략하고 싶다면 =delete로 삭제한다.

Caution) operator new의 모든 버전을 오버로딩하거나 일부만 오버로딩할 때는 사용하지 않을 버전에 명시적으로 delete를 지정해서 삭제한다. 그래야 메모리 할당 방식의 일관성을 유지할 수 있다.

operator new와 operator delete를 명시적으로 삭제하거나 디폴트로 만들기

8장에서 생성자와 대입 연산자에 delete나 default를 지정해서 명싲거으로 삭제하거나 디폴트로 설정하는 방법을 소개했다. 이렇게 명싲거으로 삭제하거나 디폴트로 설정하는 기능은 생성자와 대입 연산자만 적용할 수 있는 것은 아니다. 예컨대 다음 코드처럼 operator new와 operator new[]도 명시적으로 삭제해서 new나 new[]로 이 클래스 객체를 동적으로 생성할 수 없게 만들 수 있다.

class MyClass
{
public:
void* operator new(size_t size) = delete;
void* operator new[](size_t size) = delete;
};

operator new와 operator delete에 매개변수를 추가하도록 오버로딩하기

operator new를 표준 형태 그대로 오버로딩할 수 있을 뿐만 아니라 매개변수를 원하는 형태로 추가해서 오버로딩할 수 있다. 이렇게 매개변수를 추가하면 자신이 정의한 메모리 할당 루틴에 다양한 플래그나 카운터를 전달할 수 있다. 예컨대 런타임 라이브러리는 이 기능을 디버그 모드에 활용한다. 다시 말해 추가된 매개변수로 객체가 할당된 지점의 파일 이름과 줄 번호를 받아서 메모리 누수가 발생하면 문제가 되는 문장을 알려준다.

예컨대 MemoryDemo 클래스에서 정수 매개변수를 추가한 버전의 operator new와 operator delete 프로토타입은 다음과 같다.

void* operator new(size_t size, int extra);
void operator delete(void* ptr, int extra) noexept;

이 연산자를 구현한 예는 다음과 같다.

void* MemoryDemo::operator new(size_t size, int extra)
{
cout << "operator new with extra int: " << extra << endl;
return ::operator new(size);
}

void MemoryDemo::operator delete(void* ptr, int extra) noexcept
{
cout << "operator delete with extra int: " << extra << endl;
::operator delete(ptr);
}

매개변수를 추가해서 operator new를 오버로딩하면 컴파일러는 이에 대응되는 new-표현식을 알아서 찾아준다. new에 추가한 매개변수는 함수 호출 문법에 따라 전달된다. (nothrow 버전도 마찬가지다) 이렇게 작성한 연산자를 활용하는 방법은 다음과 같다.

MemoryDemo* memp = new (5) MemoryDemo();
delete memp;

operator new에 매개변수를 추가해서 정의할 때 이에 대응되는 operator delete도 반드시 똑같이 매개변수를 추가해서 정의해야 한다. 단, 매개변수가 추가된 버전의 operator delete를 직접 호출할 수는 없고, 매개변수를 추가한 버전의 operator new를 호출할 때 그 객체의 생성자에서 익셉션을 던져야 호출된다.

operator delete에 메모리 크기를 매개변수로 전달하도록 오버로딩하기

operator delete를 오버로딩할 때 해제할 대상을 가리키는 포인터 뿐만 아니라 해제할 메모리 크기도 전달하게 정의할 수 있다. 문법은 간단하다. operator delete의 프로토타입에 메모리 크기에 대한 매개변수를 추가해서 선언하면 된다.

Caution) 클래스에 매개변수로 메모리 크기를 받는 operator delete와 이 매개변수를 받지 않는 operator delete를 동시에 선언함녀 항상 매개변수가 없는 버전이 먼저 호출된다. 따라서 크기에 대한 매개변수가 있는 버전을 사용하려면 그 버전만 정의한다.

여섯 가지 operator delete를 모두 메모리 크기에 대한 매개변수를 받는 버전으로 만들 수 있다. 다음 예는 첫 번째 버전의 operator delete를 삭제할 메모리의 크기를 매개변수로 받는 버전으로 정의한 클래스를 보여주고 있다.

class MemoryDemo
{
public:
// 코드 생략
void* operator new(size_t size);
void operator delete(void* ptr, size_t size) noexcept;
}

이렇게 수정한 operator delete를 구현할 때 다음과 같이 크기 매개변수를 받지 않는 전역 operator delete를 호출한다. 전역 operator delete 중에는 크기를 받는 버전이 없기 때문이다.

void MemoryDemo::operator delete(void* ptr, size_t size) noexcept
{
cout << "operator delete with size " << size << endl;
::operator delete(ptr);
}

이 기법은 복잡한 메모리 할당 및 해제 메커니즘을 클래스에 직접 정의할 때나 유용하다.

전문가를 위한 C++/ 에러 처리하기

(전체가 아니라 C#과 차이가 있는 부분을 중심으로 요약 정리)

에러와 예외

익셉션의 정체

익셉션이란 코드에서 발생한 ‘예외’ 상황이나 에러가 코드의 정상적인 실행 흐름에 퍼지지 않도록 알려주는 메커니즘이다. 익셉션 메커니즘을 적용하면 에러가 발생한 코드는 익셉션을 던지고(throw) 이를 처리하는 코드는 발생한 익셉션을 받아서 처리(catch) 하는 식으로 작동한다.

익셉션을 처리하는 과정은 기존 프로그램과 달리 순차적으로 실행되지 않는다. 어떤 코드가 익셉션을 던지면 프로그램의 정상 실행 흐름을 잠시 멈추고 익셉션 핸들러(exception handler, 예외처리기)로 제어권을 넘긴다.

이때 핸들러의 위치는 다양하다. 함수 바로 뒤에 나올 수도 있고, 연속된 함수 호출(스택 프레임)을 거슬러 올라가야 나올 수도 있다.

예컨대 아래 그림 14-1처럼 세 함수가 연달아 호출됐을 때 스택 상태를 살펴보자. 익셉션 핸들러가 있는 A()를 호출한 다음 B()를 호출하고, B()는 다시 C()를 호출했는데, C()에서 익셉션이 발생했다고 하자.

위 그림 14-2는 핸들러가 익셉션을 받는 상황을 보여준다. 이 상태를 보면 C()와 B()에 대한 스택 프레임은 삭제됐고 A()에 대한 스택 프레임만 남았다.

C++에서 익셉션이 필요한 이유

기존에 작성된 C 또는 C++ 프로그램을 보면 에러를 처리하는 방식이 제각각이고 체계가 없는 경우가 많다. 함수가 정수 코드를 리턴하거나, errno 매크로를 사용해서 에러를 표시하는 것처럼 C 프로그래밍에서 표준처럼 굳어진 방식이 있는데, 이를 C++에 그대로 적용한 사례도 많다. 또한 스레드를 다룰 때 스레드의 로컬 정수 변수인 errno를 하나씩 만들어주고, 각 스레드가 함수를 호출하 측에 에러를 알려주는데 이 변수를 활용하기도 한다.

하지만 이렇게 정수 타입 리턴 코드나 errno를 사용하는 방식으로 구현하면 에러 처리 과정의 일관성을 유지하기 어렵다.

(예시 생략)

이처럼 일관성 없이 나름대로 정한 관례대로 구현한 함수들이 뒤섞이면 문제가 발생할 수 있다. 호출한 함수가 예상과 다른 방식으로 코드를 리턴하기 때문이다.

(이하 내용 생략)

익셉션 메커니즘을 사용하면 에러를 쉽고 일관성 있고 안전하게 처리할 수 있다. 기존에 C나 C++에서 활용하던 비공식 에러 처리 기법에 비해 익셉션 메커니즘이 뛰어난 점은 다음과 같다.

  • 에러를 리턴값으로 표현하면 호출한 측에서 깜빡하고 리턴값을 검사하지 않거나 상위 함수로 전달하지 못할 수 있다. 반면 익셉션은 깜박 잊고 처리하지 않거나 무시할 수 없다. 발생한 익셉션을 처리하지 않으면 프로그램이 멈추기 때문이다.
  • 에러를 저웃 타입 리턴 코드로 표현하면 구체적인 정보를 담기 힘들다. 반면 익셉션은 에러를 처리하는데 필요한 정보를 마음껏 담을 수 있다.
  • 익셉션 메커니즘은 콜 스택의 중간 단계를 건너뛸 수 있다. 다시 말해 여러 함수가 연속적으로 호출 됐을 떄 중간에 호출된 함수에서 에러를 처리하지 않고 콜 스택의 최상위 함수에서 에러를 처리하게 만들 수 있다.

바람직한 에러 처리 방식

익셉션을 효과적인 에러 처리 기법으로 적극 활용하기 바란다.

익셉션 처리 과정

특히 파일 입출력 과정에서 익셉션이 발생하기 쉽다. 

(예시 코드 생략)

익셉션 던지고 받기

프로그램에 익셉션을 구현한느 코드는 두 부분으로 나뉜다. 하나는 발생한 익셉션을 처리하는 try/catch 문이고, 다른 하나는 익셉션을 던지는 throw 문이다. 둘 다 반드시 지정된 형식에 맞게 작성해야 한다. 하지만 throw 문이 실행되는 지점은 대부분 C++ 런타임과 같이 어떤 라이브러리의 깊숙한 곳에 있어서 프로그래머가 직접 볼 수 없을 때가 많다. 그렇다 하더라도 try/catch 구문으로 반드시 처리해줘야 한다.

try/catch 문은 다음과 같이 구성된다.

try
{
// 익셉션이 발생할 수 있는 코드
}
catch (익셉션_타입_1 익셉션_이름)
{
// 익셉션_타입_1 익셉션을 처리하는 코드
}
catch (익셉션_타입_2 익셉션_이름)
{
// 익셉션_타입_2 익셉션을 처리하는 코드

예외 상황이 발생할 수 있는 코드에 throw 문으로 익셉션을 직접 던져도 된다.

(이하 exception 관련 내용 생략. C#과 개념은 동일하다)

익셉션 타입

던질 수 있는 익셉션의 타입에는 제한이 없다. 앞의 예제에서는 std::exception 타입으로 던졌지만, 다음과 같이 간단히 int 타입 객체를 던져도 된다.

vector<int> readIntegerFile(string_view fileName)
{
ifstream inputStream(fileName.data());

if (inputStream.fail())
{
// 파일 열기 실패: 익셉션을 던진다.
throw 5;
}

// 나머지 코드 생략
}

그러면 catch 문도 다음과 같이 변경한다.

try
{
myInts = readIntegerFile(fileName);
}
catch (int e)
{
cerr << "Unable to open file " << fileName << " (" << e << ")" << endl;
return 1;
}

(이하 예시 코드 생략)

여러 가지 익셉션 던지고 받기

readIntegerFile()에서 파일 열기 실패 말고도 다른 문제가 발생할 수 있기 땜누에 readIntegerFile()을 다음과 같이 수정할 수 있다.

vector<int> readIntegerFile(string_view fileName)
{
ifstream inputStream(fileName.data());

if (inputStream.fail())
{
// 파일 열기 실패: 익셉션을 던진다.
throw runtime_error("Unable to open the file");
}

// 파일에서 정수를 하나씩 읽어 벡터에 추가한다.
vector<int> integers;
int temp;

while (inputStream >> temp)
{
integers.push_back(temp);
}

if (!inputStream.eof())
{
// 파일 끝에 도달하지 못했다면, 다시 말해 파일 읽는 도중 에러가 발생했다면 에러를 던진다.
throw runtime_error("Error reading the file");
}

return integers;
}

앞서 main() 함수를 작성할 때 catch 구문이 runtime_error의 베이스 클래스인 exception 타입을 받도록 지정했기 때문에 여기서는 변경할 필요 없다. 이렇게 하면 catch 문은 두 가지 상황을 모두 처리하게 된다.

try
{
myInts = readIntegerFile(fileName);
}
catch (const exception& e)
{
cerr << e.what() << endl;
return 1;
}

이렇게 하지 않고 readIntegerFile()에서 익셉션을 두 가지 타입으로 따로 나눠서 던져도 된다. 다음 코드를 보면 파일을 열수 없으면 invalid_argument 익셉션을 던지고, 정수를 읽을 수 없으면 runtime_error 익셉션을 던진다.

vector<int> readIntegerFile(string_view fileName)
{
ifstream inputStream(fileName.data());

if (inputStream.fail())
{
// 파일 열기 실패: 익셉션을 던진다.
throw invalid_argument("Unable to open the file");
}

// 파일에서 정수를 하나씩 읽어 벡터에 추가한다.
vector<int> integers;
int temp;

while (inputStream >> temp)
{
integers.push_back(temp);
}

if (!inputStream.eof())
{
// 파일 끝에 도달하지 못했다면, 다시 말해 파일 읽는 도중 에러가 발생했다면 에러를 던진다.
throw runtime_error("Error reading the file");
}

return integers;
}

이제 main()에 invalid_argument를 받는 catch 문과 runtime_error를 받는 catch문을 따로 만든다.

try
{
myInts = readIntegerFile(fileName);
}
catch (const invalid_argument& e)
{
cerr << e.what() << endl;
return 1;
}
catch (const runtime_error& e)
{
cerr << e.what() << endl;
return 2;
}

익셉션 타입 매칭과 const

처리하려는 익셉션 타입에 const가 지정됐는지 여부는 매칭 과정에 영향을 미치지 않는다. 다시 말해 다음 문장은 runtime_error 타입에 속하는 모든 익셉션을 매칭한다.

catch (const runtime_error& e)

다음 문장도 마찬가지로 runtime_error 타입에 속하는 모든 익셉션을 매칭한다.

catch (runtime_error& e)

모든 익셉션 매칭하기

catch 문에서 모든 종류의 익셉션에 매칭하려면 다음과 같이 특수한 문법으로 작성한다.

try
{
myInts = readIntegerFile(fileName);
}
catch (...)
{
cerr << "Error reading or opening file " << fileName << endl;
return 1;
}

점 세 개를 연달아 쓴 부분은 모든 익셉션 타입에 매칭하라는 와일드카드다. 하지만 발생할 익셉션을 확실히 알 수 있다면 이렇게 구현하지 않는 것이 좋다. 필요 없는 익셉션까지 처리하기 때문이다. 항상 익셉션 타입을 구체적으로 지정해서 꼭 필요한 익셉션만 받도록 작성하는 것이 바람직하다.

모든 종류 익셉션을 매칭하는 catch(…) 구문은 디폴트 catch 핸들러를 구현할 때도 유용하다. 익셉션이 발생하면 catch  핸들러가 코드에 나열된 순서대로 검색하면서 조건에 맞는 것을 실행한다. 다음 예는 invalid_argument와 runtime_error만 catch 문을 별도로 작성하고 나머지 익셉션은 디폴트 catch 핸들러로 처리하는 방법을 보여준다.

try
{
// 익셉션이 발생할 수 있는 코드
}
catch (const invalid_argument& e)
{
// invalid_argument 익셉션을 처리하는 핸들러 코드
}
catch (const runtime_error& e)
{
// runtime_error 익셉션을 처리하는 핸들러 코드
}
catch (...)
{
// 나머지 모든 익셉션을 처리하는 핸들러 코드
}

처리하지 못한 익셉션

catch 구문으로 처리하지 못한 익셉션이 남아 있다면 프로그램을 다르게 실행하도록 구현하는 방법도 있다. 예컨대 프로그램이 잡지 못한 익셉션을 만나면 terminate() 함수를 호출하게 만들 수 있다. 이 함수는 C++에서 기본으로 제공하며, 내부적으로 <cstdlib> 헤더에 정의된 abort() 함수를 호출해서 프로그램을 죽인다.

또는 set_terminate()에 인수를 받지 않고 리턴값도 없는 콜백 함수를 포인터로 지정하는 방식으로 terminate_hander를 직접 구현해도 된다. 사용법은 다음과 같다.

try
{
main(argc, argv);
}
catch (...)
{
if (terminate_handler != nullptr)
{
terminate_handler();
}
else
{
terminate();
}
}

// 정상 종료 코드

아쉽지만 여기서 지정한 콜백 함수도 결국 에러를 무시하지 못하고 프로그램을 종료시킨다. 그래도 최소한 종료 직전에 유용한 정보를 담은 에러 메시지를 출력할 기회는 있다.

예컨대 다음 코드를 보면 main()은 커스텀 콜백 함수인 myTerminate()를 terminate_handler로 지정했다. 이 핸들러는 readIntegerFile()이 던지는 익셉션을 제대로 처리하지 않고 그냥 에러 메시지만 출력한 뒤 exit()를 호출해서 프로그램을 종료시킨다. exit() 함수는 프로세스를 종료하는 방식을 표현하는 정숫값을 인수로 받는다. 이렇게 지정한 값은 OS로 전달된다.

void myTerminate()
{
cout << "Uncaught exception!" << endl;
exit(1);
}

int main()
{
set_terminate(myTerminate);

const string fileName = "IntegerFile.txt";
vector<int> myInts = readIntegerFile(fileName);

for (const auto& element : myInts)
{
cout << element << " ";
}
cout << endl;
return 0;
}

여기에는 나오지 않았지만 set_terminate() 함수로 새로운 terminate_handler를 지정하면 기존에 설정된 핸들러를 리턴한다. terminate_handler는 프로그램 전체에서 접근할 수 있기 때문에 처리할 일이 끝나면 이를 리셋하는 것이 바람직하다.

set_terminate()는 반드시 알아야 할 기능 중 하나지만 에러 처리에 가장 효과적인 수단은 아니다. 그보다는 처리할 익셉션을 try/catch 구문에 구체적으로 지정해서 꼭 필요한 익셉션만 제대로 처리하는 것이 바람직하다.

Note) 상용 소프트웨어를 구현할 때는 프로그램이 종료되기 전에 크래시 덤프(crash dump)를 생성하기 위해 terminate_handler를 설정하는 경우가 많다. 이렇게 생성된 크래시 덤프를 디버거에 입력하면 프로그램에서 놓친 익셉션이나 문제의 원인을 알아낼 수 있다.

noexcept

기본적으로 함수가 던질 수 있는 익셉션의 종류에는 제한이 없다. 하지만 함수에 noexcept 키워드를 지정해서 어떠한 익셉션도 던지지 않는다고 지정할 수 있다. 예컨대 앞서 본 readIntegerFile() 함수에 noexcept를 지정하면 익셉션을 하나도 던지지 않는다.

vector<int> readIntegerFile(string_view fileName) noexcept;

noexcept 키워드가 지정된 함수에 익셉션을 던지는 코드가 있으면 C++ 런타임은 terminate()를 호출해서 프로그램을 종료시킨다.

파생 클래스에서 virtual 메서드를 오버라이드할 떄 베이스 클래스에 정의된 메서드에 noexcept가 지정되지 않았더라도 오버라이드하는 메서드에 noexcept를 지정할 수 있다. 하지만 그 반대로는 할 수 없다.

throw 리스트(현재 지원 중단 및 삭제 됨)

이전 버전의 C++에서는 함수나 메서드에서 던질 수 있는 익셉션을 지정할 수 있다. 이를 throw 리스트 또는 익셉션 명세라 부른다.

C++ 17부터 익셉션 명세 기능이 완전히 삭제됐기 때문에 설명하지 않는다. 사실 이전 버전에서도 익셉션 명세 기능을 거의 사용하지 않았다.

(이하 레거시 내용 생략)

익셉션과 다형성

앞서 설명 했듯이 던질 수 있는 익셉션 타입에는 제한이 없지만 대부분 클래스로 정의한다. 익셉션 타입을 클래스로 정의하면 계층 구조를 형성할 수 있기 때문에 익셉션을 처리하는 코드에서 다형성을 활용할 수 있다.

표준 익셉션 클래스의 계층 구조

다음 그림은 표준 익셉션 클래스의 계층 구조를 나타낸다.

C++ 표준 라이브러리에서 던지는 익셉션 객체의 클래스는 모두 이 계층에 속한다. 여기 나온 클래스는 모두 what() 메서드를 갖고 있다. 이 메서드는 익셉션을 표현하는 const char* 타입의 스트링을 리턴하며 에러 메시지 출력에 활용할 수 있다.

익셉션 클래스는 대부분 what() 메서드가 리턴할 스트링을 생성자의 인수로 지정해야 한다. 베이스 클래스인 exception은 반드시 생성자에 이 값을 전달해야 한다.

(이하 설명 및 예시 코드 생략)

클래스 계층 구조에서 정확한 익셉션 타입 선택하기

익셉션 타입을 클래스 계층으로 구성하면 catch 구문에서 다형성을 활용할 수 있다. 

(이하 설명 및 예시 코드 생략)

이렇게 catch 구문이 인수를 exception 레퍼런스로 받으면 exception을 상속한 모든 파생 클래스 타입을 인수로 받을 수 있다. 단 익셉션 계층에서 베이스로 올라갈수록 에러를 구체적으로 처리하기 힘들어지기 때문에 catch 문에서 처리할 추상화 수준에 최대한 맞게 익셉션 타입을 지정하는 것이 바람직하다.

다형성을 이용한 catch 구문이 여러 개라면 코드에 나온 순서대로 매칭된다. 다시 말해 가장 먼저 매칭되는 구문으로 결정된다. 먼저 나온 catch 문이 뒤에 나온 catch 문보다 추상적이면 앞의 것을 먼저 선택한다. 따라서 구체적인 타입을 뒤에 적으면 한 번도 실행되지 않는다.

(이하 설명 및 코드 생략)

익셉션 클래스 직접 정의하기

익셉션을 직접 정의할 때는 반드시 표준 exception 클래스를 직접 또는 간접적으로 상속하는 것이 좋다. 프로젝트 구성원이 모두 이 원칙을 따르면 프로그램에서 발생하는 익셉션이 모두 exception을 상속하게 만들 수 있다. 이렇게 하면 에러 처리 코드에서 다형성을 이용하기 훨씬 쉽다.

(이하 설명 및 코드 생략)

중첩된 익셉션

익셉션을 처리하는 도중에 또 다른 에러가 발생해서 새로운 익셉션이 전달될 수 있다. 아쉽게도 이렇게 중간에 익셉션이 발생하면 현재 처리하고 있던 익셉션 정보가 사라진다. 이를 해결하기 위해 C++는 먼저 잡은 익셉션을 새로 발생한 익셉션의 문맥 안에 포함시키는 중첩된 익셉션(nested exception)이라는 기능을 제공한다.

이 기능은 다음 상황에서도 활용할 수 있다. 현재 구현하는 프로그램에서 A 타입 익셉션을 던지는 서드파티 라이브러리의 함수를 사용하는데, 현재 작성하는 프로그램에서는 B 타입 익셉션만 처리하게 만들고 싶을 수 있다. 이럴 때는 서드파티라이브러리에서 발생하는 익셉션을 모두 B 타입 안에 중첩 시키면 된다.

어떤 익셉션을 처리하는 catch 문에서 새로운 익셉션을 던지고 싶다면 std::throw_with_nested()를 사용하면 된다. 나중에 발생한 익셉션을 처리하는 catch 문에서 먼저 발생했던 익셉션에 접근할 때는 dynamic_cast()를 이용하면 된다. 이때 먼저 발생한 익셉션은 nested_exception으로 표현한다. 구체적인 예를 통해 살펴보자. 먼저 다음과 같이 exception을 상속하고 생성자에서 스트링을 인수로 받는 MyException 클래스를 정의한다.

class MyException : public std::exception
{
public:
MyException(string_veiw message) : mMessage(message) {}
virtual const char* what() const noexcept override
{
return mMessage.c_str();
}

private:
string mMessage;
};

먼저 발생한 익셉션을 처리하다가 새로운 익셉션을 던져야 하는데 그 안에 앞서 발생한 익셉션을 담아 던지려면 std::throw_with_nested() 함수를 호출해야 한다. 다음 코드에 나온 doSomething() 함수에서 runtime_error를 던지는데 바로 다음에 나온 catch 핸들러가 이를 잡아서 처리한다. 

이 핸들러는 몇 가지 메시지를 작성하고 나서 새로운 익셉션을 던지는데 이때 throw_with_nested() 함수를 이용하여 새로운 익셉션 안에 먼저 발생한 익셉션을 담아서 던진다. 익셉션을 중첩시키는 작업은 자동으로 처리된다.

void doSomething()
{
try
{
throw runtime_error("Throwing a runtime_error exception");
}
catch (const runtime_error& e)
{
cout << __func__ << " caught a runtime_error" << endl;
cout << __func__ << " throwing MyException" << endl;
throw_with_nested(MyException("MyException with nested runtime_error"));
}
}

다음 main() 함수는 중첩된 익셉션을 처리하는 방법을 보여준다. 여기서는 doSomething()을 호출하는 코드가 있고, 그 아래에 MyException 익셉션을 처리하는 catch 핸들러가 나온다. 이 핸들러가 익셉션을 잡으면 메시지를 작성한뒤 dynamic_cast()를 이용하여 현재 익셉션에 중첩된 익셉션에 접근한다. 그 안에 중첩된 익셉션이 없다면 널 포인터를 리턴한다. 중첩된 익셉션이 있다면 nested_exception의 rethrow_nested() 메서드를 호출해서 중첩된 익셉션을 다시 던진다. 그러면 다른 try/catch 구문에서 이 익셉션을 처리할 수 있다.

int main()
{
try
{
doSomething();
}
catch (const MyException& e)
{
cout << __func__ << " caught MyException: " << e.what() << endl;
const auto* pNested = dynamic_cast<const nested_exception*>(&e);

if (pNested)
{
try
{
pNested->rethrow_nested();
}
catch (const runtime_error& e)
{
// 중첩된 익셉션을 처리한다.
cout << " Nested exception: " << e.what() << endl;
}
}
}
return 0;
}

여기의 main() 함수는 dynamic_cast()를 이용하여 중첩된 익셉션이 있는지 확인했다. 이렇게 중첩된 익셉션을 확인하기 위해 dynamic_cast()를 호출할 일이 많기 때문에 이 작업을 수행하는 std::rethrow_if_nested()란 간단한 헬퍼 함수를 표준에 정의해뒀다. 이 헬퍼 함수의 사용법은 다음과 같다.

int main()
{
try
{
doSomething();
}
catch (const MyException& e)
{
cout << __func__ << " caught MyException: " << e.what() << endl;

try
{
rethrow_if_nested(e);
}
catch (const runtime_error& e)
{
// 중첩된 익셉션을 처리한다.
cout << " Nested exception: " << e.what() << endl;
}
}
return 0;
}

익셉션 다시 던지기

throw 키워드는 현재 발생한 익셉션을 다시 던질 때 사용한다. 예컨대 다음과 같다.

void g() { throw invalid_argument("Some exception"); }

void f()
{
try
{
g();
}
catch (const invalid_argument& e)
{
cout << "caught in f: " << e.what() << endl;
throw; // 다시 던지기
}
}

int main()
{
try
{
f();
}
catch (const invalid_argument& e)
{
cout << "caught in main: " << e.what() << endl;
}
return 0;
}

여기서 throw e; 와 같은 문장으로 익셉션을 다시 던지면 된다고 생각하기 쉽지만 그러면 안 된다. 익셉션 객체에 대한 슬라이싱이 발생하기 때문이다. 예컨대 f()에서 std::exception을 잡고, main()에서 exception과 invalid_argument 익셉션을 모두 잡으려면 다음과 같이 수정한다.

void g() { throw invalid_argument("Some exception"); }

void f()
{
try
{
g();
}
catch (const exception& e)
{
cout << "caught in f: " << e.what() << endl;
throw; // 다시 던지기
}
}

int main()
{
try
{
f();
}
catch (const invalid_argument& e)
{
cout << "invalid_argument caught in main: " << e.what() << endl;
}
catch (const exception& e)
{
cout << "exception caught in main: " << e.what() << endl;
}
return 0;
}

위 코드를 실행하면 다음과 같이 출력된다.

caught in f: Some exception
invalid_argument caught in main: Some exception

그런데 f()에서 throw; 문장을 throw e;로 바꾸면 실행 결과가 다음과 같다.

caught in f: Some exception
exception caught in main: Some exception

이렇게 하면 main()이 exception 객체를 잡긴 하는데 invalid_argument 객체는 아니다. throw e; 문장에서 슬라이싱이 발생해서 invalid_argument가 exception으로 돼버렸기 때문이다.

Caution) 익셉션을 다시 던질 때는 항상 throw;로 적어야 한다.

스택 풀기와 청소

어떤 코드가 익셉션을 던지면 이를 받아서 처리할 catch 핸들러를 스택에서 찾는다. 이때 catch 핸들러는 현재 스택 프레임에 바로 있을 수도 있고, 몇 단계의 함수 호출 스택을 거슬러 올라가야 나타날 수도 있다. 어떻게든 catch 핸들러를 발견하면 그 핸들러가 정의된 스택 단계로 되돌아가는데, 이 과정에서 중간 단계에 있던 스택 프레임을 모두 풀어버린다. 이를 스택 풀기(stack unwinding)이라 부르며, 스코프가 로컬인 소멸자를 모두 호출하고, 각 함수에서 미처 실행 못한 코드는 건너뛴다.

그런데 스택 풀기가 발생할 때 포인터 변수를 해제하고 리소스를 정리하는 작업은 실행되지 않는다. 때문에 다음과 같은 경우 문제가 발생할 수 있다.

int main()
{
try
{
funcOne(;
}
catch (const exception& e)
{
cerr << "Exception caught!" << endl;
return 1;
}
return 0;
}

void funcOne()
{
string str1;
string* str2 = new string();
funcTwo();
delete str2;
}

void funcTwo()
{
ifstream fileStream;
fileStream.open("fileName");
throw exception();
fileStream.close();
}

funcTwo()에서 익셉션을 던질 때 가장 가까운 핸들러는 main()에 있다. 그래서 실행 흐름은 즉시 funcTwo()에 있던 throw exception(); 문장에서 main()의 cerr << “Exception caught!” << endl; 로 건너뛴다.

funcTwo()의 실행 지점은 익셉션을 던진 문장에 여전히 머물러 있다. 따라서 그 뒤에 나온 다음 문장은 실행되지 않는다.

fileStream.close();

다행히 ifstream 소멸자는 호출된다. fileStream이 스택에 있는 로컬 변수이기 때문이다. ifstream 소멸자가 파일을 대신 닫아주므로 리소스 누수가 발생하지는 않는다. fileStream을 동적으로 할당했다면 제거되지 않기 때문에 파일은 닫히지 않고 그대로 남게 된다.

funcOne()에서 실행 지점이 funcTwo() 호출에 있으므로 그 뒤에 나온 다음 문장은 실행되지 않는다.

delete str2;

따라서 메모리 누수가 발생한다. 스택 풀기 과정에서 str2에 대해 delete를 자동으로 호출해주지 않기 때문이다. 그런데 str1은 제대로 해제된다. 이는 스택에 있는 로컬 변수이기 때문이다. 스택 풀기 과정에서 로컬에 있는 변수는 모두 제대로 해제된다.

바로 이 때문에 C 언어에서 사용하던 할당 모델과 익셉션 같은 최신 프로그래밍 기법을 섞어 쓰면 안 된다. C 방식에서 new를 호출해서 C++ 처럼 보이게 만들어도 마찬가지다. C++로 코드를 작성할 때는 반드시 스택 기반 할당 방식을 적용해야 한다. 그게 힘들다면 다음 두 절에 나온 기법 중 하나를 활용한다.

스마트 포인터 활용

스택 기반 할당 기법을 사용할 수 없다면 스마트 포인터를 활용한다. 그러면 익셉션 처리 과정에 메모리나 리소스 누수 방지 작업 등을 자동으로 처리할 수 있다. 스마트 포인터 객체가 제거될 때마다 그 포인터에 할당된 리소스도 해제된다. 

(이하 설명 및 코드 생략)

Note) 스마트 포인터나 28장에서 소개할 RAII 객체를 사용할 때는 리소스 해제하는 신경 쓰지 않아도 된다. 익셉션이 발생하든 아니면 함수가 정상 종료하든 상관없이 RAII 객체의 소멸자가 알아서 처리하기 때문이다.

익셉션 잡고, 리소스 정리한 뒤, 익셉션 다시 던지기

메모리 및 리소스 누수를 방지하기 위한 또 다른 기법은 각 함수마다 발생 가능한 익셉션을 모두 잡아서 리소스를 제대로 정리한 뒤 그 익셉션을 다시 스택의 상위 핸들러로 던지는 것이다.

(이하 설명 및 코드 생략)

Caution) 익셉션을 잡고 리소스를 정리한 뒤 익셉션을 다시 던지기보다 스마트 포인터나 RAII 클래스를 사용하는 방법이 더 좋다.

익셉션 처리 과정에서 흔히 발생하는 문제

메모리 할당 에러

지금까지 소개한 예제는 모두 메모리 할당 에러가 발생하지 않는다고 가정했다. 현재 흔히 사용하는 64비트 플랫폼에서 이런 일이 발생할 일은 거의 없지만, 모바일 시스템이나 레거시 시스템에서는 메모리 할당 에러가 드물지 않게 발생한다. 이런 시스템에서는 반드시 메모리 할당 에러에 대처하는 코드를 구현해야 한다. C++는 메모리 할당 에러를 처리하기 위한 다양한 기능을 제공한다.

new나 new[]에서 메모리를 할당할 수 없을 때 기본적으로 수행하는 동작은 <new> 헤더 파일에 정의된 bad_alloc 익셉션을 던지는 것이다. 따라서 이 익셉션을 적절히 처리하는 catch 구문을 작성한다.

new나 new[]를 호출할 때마다 try/catch 문으로 감싸도 되지만, 할당하려는 메모리 블록의 크기가 클 때만 이렇게 하는 것이 좋다. 메모리 할당 익셉션을 잡는 방법은 다음과 같다.

int* ptr = nullptr;
size_t integerCount = numeric_limits<size_t>::max();

try
{
ptr = new int[integerCount];
}
catch (const bad_alloc& e)
{
cerr << __FILE__ << "(" << __LINE__ << "): Unable to allocate memory: " << e.what() << endl;

// 메모리 할당 에러를 처리한다.
return;
}

// 메모리 할당에 성공했다면 함수를 정상적으로 진행한다.

위 코드는 미리 정의된 전처리 기호인 __FILE__과 __LINE__을 사용하고 있는데, 컴파일하면 이 기호 자리에 파일 이름과 현재 줄 번호가 들어간다. 이렇게 하면 디버깅이 편하다.

물론 구현하는 프로그램에 따라 실행 과정에서 발생할 수 있는 모든 에러를 프로그램의 최상위 코드에서 try/catch 블록 하나만으로 처리해도 된다.

한 가지 주의할 점은 에러 로깅 과정에 메모리 할당이 발생할 수 있는데, new 과정에 에러가 발생했다면 에러 메시지를 로깅하는데 필요한 메모리도 없을 가능성이 높다.

익셉션을 던지지 않는 new

익셉션 메커니즘을 사용하지 않고 예전 C 방식처럼 메모리 할당에 실패하면 널 포인터를 리턴하도록 작성해도 된다. C++은 익셉션을 던지지 않는 nothrow 버전의 new와 new[]도 제공한다. 이 버전은 메모리 할당에 실패하면 익셉션을 던지지 않고 nullptr을 리턴한다. 이렇게 하려면 new 대신 new (nothrow) 구문을 사용한다.

int* ptr = new (nothrow) int[intergerCount];

if (ptr == nullptr)
{
cerr << __FILE__ << "(" << __LINE__ << "): Unable to allocate memory: " << e.what() << endl;

// 메모리 할당 에러를 처리한다.
return;
}

// 메모리 할당에 성공했다면 함수를 정상적으로 진행한다.

메모리 할당 에러 처리 방식 커스터마이즈 하기

C++은 new 핸들러 콜백 함수를 커스터마이즈 하는 기능을 제공한다. 기본적으로 new나 new[]는 new 핸들러를 따로 사용하지 않고 bad_alloc 익셉션을 던지기만 한다. 그런데 new 핸들러를 정의하면 메모리 할당 루틴에서 에러가 발생했을 때 익셉션을 던지지 않고 저으이된 new 핸들러를 호출한다.

new 핸들러가 리턴하면 메모리 할당 루틴은 메모리를 다시 할당하려 시도하는데, 이때 실패해도 다시 new 핸들러를 호출한다. 따라서 new 핸들러에서 다음 세 가지 중 한 가지 방식으로 구현하지 않으면 무한 루프가 발생할 수 있다. 이 중 몇 가지 방식은 다른 방식보다 낫다.

  • 메모리 추가하기
    • 공간을 확보하기 위한 한 가지 방법은 프로그램 구동시 큰 덩어리의 메모리를 할당했다가 new 핸들러로 해제하게 만드는 것이다. 구체적인 활용 예로 메모리 할당 에러가 발생할 때 현재 사용자의 상태가 사라지지 않도록 저장해야 할 때가 있다. 여기서 핵심은 프로그램을 구동할 때 원하는 상태를 저장할 수 있을 정도로 충분한 양의 메모리를 할당하는데 있다. new 핸들러가 호출되면 이 블록을 해제한 뒤 상태를 저장하고 프로그램을 다시 구동해서 저장된 상태를 불러오면 된다.
  • 익셉션 던지기
    • C++ 표준에서는 new 핸들러에서 익셉션을 던질 때 반드시 bad_alloc이나 이를 상속한 익셉션을 던지도록 명시하고 있다.
  • 다른 new 핸들러 설정하기
    • 이론적으로 new 핸들러를 여러 개 만들어서 각각 메모리를 생성하고 문제가 발생하면 다른 new 핸들러를 설정할 수 있다. 하지만 실제 효과에 비해 코드가 복잡하다는 단점이 있다.

new 핸들러에서 위 세 가지 작업 중 어느 하나라도 하지 않으면 메모리 할당 에러가 발생할 때 무한 루프에 빠진다.

메모리 할당 에러가 발생할 때 new 핸들러를 호출하지 않게 하고 싶다면 new를 호출하기 전에 new 핸들러의 디폴트 값인 nullptr로 잠시 되돌려 준다.

new 핸들러는 <new> 헤더 파일에 선언된 set_new_handler()를 호출해서 설정한다. 예컨대 에러 메시지를 로그에 기록하고 익셉션을 던지도록 new 핸들러를 작성하면 다음과 같다.

class please_terminate_me : public bad_alloc { };

void myNewHandler()
{
cerr << "Unable to allocate memory" << endl;
throw please_terminate_me();
}

new 핸들러에는 반드시 인수와 리턴값이 없어야 한다. 이렇게 하면 앞서 설명한 세 가지 처리 방식 중 두 번째처럼 please_terminate_me가 발생한다.

이렇게 작성한 new 핸들러를 설정하는 방법은 다음과 같다.

int main()
{
try
{
// 새로 만든 new 핸들러를 설정하고 예전 것은 저장해둔다.
new_handler oldHandler = set_new_handler(myNewHandler);

// 할당 에러를 발생시킨다.
size_t numInts = numeric_limits<size_t>::max();
int* ptr = new int[numInts];

// 예전 new 핸들러로 되돌린다.
set_new_handler(oldHandler);
}
catch (const please_terminate_me& e)
{
cerr << __FILE__ << "(" << __LINE << "): Terminating program" << endl;
return 1;
}
return 0;
}

여기서 new 핸들러는 함수 포인터 타입에 대한 typedef이며 set_new_handler()는 이를 인수로 받는다.

생성자에서 발생하는 에러

생성자가 값을 리턴하지 못해도 익셉션을 던질 수는 있다. 익셉션을 활용하면 클라이언트가 객체의 정상 생성 여부를 쉽게 알 수 있다. 하지만 한 가지 심각한 문제가 있다. 익셉션이 발생해서 생성자가 정상 종료되지 않고 중간에 실행을 멈추고 빠져나와버리면 그 객체의 소멸자가 호출될 수 없다. 따라서 익셉션이 발생해서 생성자를 빠져나올 때는 반드시 생성자에서 할당했던 메모리와 리소스를 정리해야 한다. 

(예시 코드 생략)

생성자를 위한 함수 try 블록

지금까지 소개한 익셉션 기능만으로도 함수에서 발생한 익셉션을 처리하는데 충분하다. 그렇다면 생성자 이니셜라이저에서 발생한 익셉션은 어떻게 처리해야 할까? 여기서는 함수 try 블록이란 기능으로 이런 익셉션을 처리하는 방법을 소개한다. 

함수 try 블록은 일반 함수뿐만 아니라 생성자에 적용할 수도 있다. 이 기능이 추가된지 상당히 오래 됐음에도 숙련된 C++ 프로그래머조차 이 기능을 모르는 경우가 많다.

생성자 함수에 대한 함수 try 블록을 작성하는 방법을 의사코드로 표현하면 다음과 같다.

MyClass::MyClass()
try : <생성자 이니셜라이저>
{
/* 생성자 본문 */
}
catch (const exception& e)
{
/* ... */
}

여기서 try 키워드를 생성자 이니셜라이저 바로 앞에 적었다. catch 문은 반드시 생성자를 닫는 중괄호 뒤에 나와야 한다. 그러므로 실질적으로 생성자 밖에 놓인다. 함수 try 블록을 생성자에 적용할 때는 다음과 같은 점에 주의한다.

  • catch 문은 생성자 이니셜라이저나 생성자 본문에서 발생한 익셉션을 잡아서 처리한다.
  • catch 문은 반드시 현재 발생한 익셉션을 다시 던지거나 새 익셉션을 만들어 던져야 한다. catch 문에서 이렇게 처리하지 않으면 런타임이 자동으로 현재 익셉션을 다시 던진다.
  • catch 문은 생성자에 전달된 인수에 접근할 수 있다.
  • catch 문이 함수 try 블록에서 익셉션을 잡으면 생성자의 실행을 정상적으로 마친 베이스 클래스나 그 객체로 된 멤버는 catch 문을 싲가하기 전에 소멸된다.
  • catch 문 안에서는 객체로 된 멤버 변수에 접근하면 안 된다. 바로 앞서 설명한 것처럼 catch 문이 실행되기 전에 소멸되기 때문이다. 그런데 익셉션이 발생하기 전에 그 객체에 논 클래스 타입(예: 일반 포인터 타입) 데이터 멤버를 초기화했다면 여기에 접근할 수 있다. 단, 이런 리소스를 정리하는 작업은 catch 문에서 처리해야 한다. 뒤에 나온 코드가 이렇게 처리한다.
  • 함수 try 블록에 있는 catch 문은 그 안에 담긴 함수에서 값을 리턴할 때 return 키워드를 사용할 수 없다. 생성자는 원래 아무것도 리턴하지 않기 때문이다.

앞서 나열한 제약사항을 감안하면 생성자에 대한 함수 try 블록은 다음과 같은 제한된 상황에만 적합하다.

  • 생성자 이니셜라이저에서 던진 익셉션을 다른 익셉션으로 변환할 때
  • 메시지를 로그 파일에 기록할 때
  • 생성자 이니셜라이저에서 할당한, 소멸자로 자동 제거할 수 없는 리소스를 익셉션을 던지기 전에 해제할 때

다음 예는 함수 try 블록을 구현하는 방법을 보여준다. 여기서 SubObject 클래스는 runtime_error 익셉션을 던지는 생성자 하나만 갖고 있다.

class SubObject
{
public:
SubObject(int i);
};

SubObject::SubObject(int i)
{
throw std::runtime_error(error("Exception by SubObject ctor"));
}

MyClass는 다음과 같이 int* 타입의 멤버 변수 하나와 SubObject 타입의 멤버 변수 하나를 갖고 있다.

class MyClass
{
public:
MyClass();
private:
int* mData = nullptr;
SubObject mSubObject;
}

SubObject 클래스에는 디폴트 생성자가 없다. 다시 말해 mSubObject를 MyClass의 생성자 이니셜라이저로 초기화해야 한다. MyClass 생성자는 함수 try 블록을 이용하여 생성자 이니셜라이저에서 발생한 익셉션을 처리한다.

MyClass::MyClass()
try : mData(new int[42]{ 1, 2, 3 }), mSubObject(42)
{
/* 생성자 바디 */
}
catch (const std::exception& e)
{
// 메모리 정리
delete[] mData;
mData = nullptr;
cout << "function-try-block caught: '" << e.what() << "'" << endl;
}

여기서 명심할 점은 생성자에 대한 함수 try 블록 안에 있는 catch 문은 반드시 현재 익셉션을 다시 던지거나 새 익셉션을 생성해서 던져야 한다. 앞서 나온 catch 문을 보면 아무 익셉션도 던지지 않는데, 그러면 C++ 런타임이 현재 익셉션을 대신 던져준다. 앞서 정의한 클래스를 사용하는 예는 다음과 같다.

int main()
{
try
{
MyClass m;
}
catch (const std::exception& e)
{
cout << "main() caught: '" << e.what() << "'" << endl;
}
return 0;
}

이 코드를 실행하면 다음과 같이 출력된다.

function-try-block caught: 'Exception by SubObject ctor'
main() caught: 'Exception by SubObject ctor'

참고로 이 예제처럼 코드를 작성하면 위험하다. 초기화 순서에 따라 catch 문에 진입할 때 mData에 이상한 값이 할당될 수 있다. 이렇게 알 수 없는 값을 가진 포인터에 대해 delete를 호출하면 예상치 못한 동작이 나타날 수 있다. 그래서 위 예제에서 이런 문제를 방지하려면 mData 멤버를 std::unique_ptr과 같은 스마트 포인터로 선언하고 함수 try 블록을 제거하는 것이다.

Caution) 함수 try 블록은 사용하지 않는 것이 좋다.

함수 try 블록은 생성자 뿐만 아니라 일반 함수에도 적용할 수 있지만 일반 함수에서 이를 사용해서 나아질 것은 없다. 함수 본문 안에서 간단히 try/catch 문으로 표현해도 되기 때문이다. 단 함수 try 블록을 생성자에서 사용할 때와 달리 일반 함수에서 사용하면 현재 익셉션을 다시 던지거나 catch 문에서 새 익셉션을 던질 필요가 없고, C++ 런타임에서 대신 던져주지도 않는다.

소멸자에서 익셉션을 처리하는 방법

소멸자에서 발생하는 에러는 반드시 소멸자 안에서 처리해야 한다. 소멸자에서 익셉션을 다른 곳으로 던지면 안 된다. 그 이유는 다음과 같다.

  1. 소멸자를 명시적으로 noexcept(false)로 지정하지 않거나 그 클래스에 있는 객체 중 소멸자에 noexcept(false)가 지정된 것이 없다면 내부적으로 noexcept로 선언된 것으로 취급한다. noexcept 소멸자에서 익셉션을 던지면 C++ 런타임은 std::terminate()를 호출해서 프로그램을 종료한다.
  2. 소멸자는 이미 다른 익셉션이 발생해서 스택 풀기를 수행하는 과정에서도 실행될 수 있다. 스택 풀기를 하는 도중에 소멸자에서 익셉션을 던지면 C++ 런타임은 std::terminate()를 호출해서 애플리케이션을 종료한다. 
    • 실험 정신이 투철한 독자를 위해 부연 설명하면 C++는 소멸자가 호출되는 원인이 일반 함수의 정상적인 종료 때문인지 아니면 delete를 호출했기 때문인지 아니면 스택 풀기 때문인지 알아내는 기능을 제공한다.
    • <exception> 헤더 파일에 선언된 uncaught_exceptions() 함수를 호출하면 아직 잡지 않은 익셉션, 즉 이미 발생했지만(던져졌지만) 아직 catch 문에는 매칭되지 않은 익셉션 수를 리턴한다. uncaught_exceptions()의 리턴값이 0보다 크면 스택 풀기 과정에 있다는 뜻이다. 하지만 이 함수를 제대로 활용하기 힘들고 코드도 지저분해져서 사용하지 않는 것이 좋다.
    • 참고로 C++ 17 이전에는 이 함수의 이름이 단수형인 uncaught_exception()이었고 bool 타입의 값을 리턴했다. 즉, true를 리턴하면 현재 스택 풀기 중에 있다는 뜻이다.
  3. 그렇다면 클라이언트는 어떤 액셥을 취해야 할까? 클라이언트는 소멸자를 직접호출하지 않고 delete를 이용하여 간접적으로 소멸자를 호출한다. 그런데 소멸자에서 익셉션을 던지면 클라이언트를 어떻게 처리해야 할까? 이미 delete를 호출한 객체에 다시 delete를 호출할 수도 없고, 소멸자를 직접 호출할 수도 없다. 이처럼 클라이언트가 할 수 있는 ㅇ리이 없기 땜누에 굳이 익셉션 처리의 부담을 줄 이유가 없다.
  4. 소멸자는 객체에서 사용할 메모리나 리소스를 해제할 마지막 기회다. 함수 실행 도중에 익셉션을 던져 이 기회를 놓쳐버리면 다시 돌아가 메모리나 리소스를 해제할 수 없다.

모두 합치기

(앞선 내용들을 합한 예시 코드 생략)

 

 

 

 

 

 

 

전문가를 위한 C++/ C++ I/O 완전 분석

(전체가 아니라 C#과 차이가 있는 부분을 중심으로 요약 정리)

스트림 사용법

스트림의 정체

1장에서 cout 스트림을 소개할 때 공장의 컨베이어 벨트에 비유했다. 스트림에 변수를 올려 보내면 사용자의 화면인 콘솔에 표시된다. 이를 일반화해서 모든 종류의 스트림을 컨베이어 벨트로 표현할 수 있다.

스트림마다 방향과 소스(source) 또는 목적지(destination)을 지정할 수 있다. 예컨대 앞 장에서 본 cout 스트림은 출력 스트림이다. 그래서 나가는(out) 방향을 갖는다. cout은 데이터를 콘솔에 쓴다. 따라서 목적지는 ‘콘솔’이다. cout의 c는 console이 아니라 character를 의미한다. 즉, cout은 문자 기반 스트림이다. 이와 반대로 사용자의 입력을 받는 cin이란 스트림도 있다.

C++에서 기본으로 정의된 스트림을 간략히 정리하면 다음과 같다.

스트림 설명
cin 입력 스트림. ‘입력 콘솔’에 들어온 데이터를 읽는다.
cout 버퍼를 사용하는 출력 스트림. 데이터를 ‘출력 콘솔’에 쓴다.
cerr 버퍼를 사용하지 않는 출력 스트림. 데이터를 ‘에러 콘솔’에 쓴다. 에러 콘솔과 ‘출력 콘솔’이 같을 때가 많다.
clog 버퍼를 사용하는 cerr

여기서 버퍼를 사용하는 (buffered) 스트림은 받은 데이터를 버퍼에 저장했다가 블록 단위로 목적지를 보내고, 버퍼를 사용하지 않는 (unbuffered) 스트림은 데이터가 들어오자마자 목적지로 보낸다. 이렇게 버퍼에 잠시 저장(버퍼링, buffering) 하는 이유는 파일과 같은 대상에 입출력을 수행할 때는 블록 단위로 묶어서 보내는 것이 효율적이기 때문이다. 참고로 버퍼를 사용하는 스트림은 버퍼를 깨끗이 비우는 flush() 메서드로 현재 버퍼에 담긴 데이터를 목적지로 보낸다.

스트림에서 중요한 또 다른 사실은 데이터가 현재 가리키는 위치와 함께 담겨 있다는 것이다. 스트림에서 현재 위치란 다음번에 읽기 또는 쓰기 연산을 수행할 위치를 의미한다.

스트림의 출발지와 목적지

스트림이란 개념은 데이터를 입력 받거나 출력하는 객체라면 어떤 것에도 적용할 수 있다. 네트워크 관련 클래스를 스트림 기반으로 작성할 수도 있고, MIDI 장치에 접근하는 부분도 스트림으로 구현할 수 있다.

파일 스트림(file stream)은 파일 시스템에서 데이터를 읽고 쓰는 스트림이다. 스트링 스트림(string stream)은 스트링 타입에 스트림 개념을 적용한 것이다.

스트림을 이용한 출력

출력 스트림의 기초

출력 스트림은 <ostream> 헤더 파일에 정의돼 있다. 출력 스트림을 사용하는 가장 간편한 방법은 << 연산자를 이용하는 것이다.

(앞서 많이 다루었기 때문에 이하 cout에 대한 예시 생략)

출력 스트림에서 제공하는 메서드

출력 스트림에서 가장 대표적인 연산자는 << 다. 이 연산자는 단순히 출력하는 기능 외에도 여러 기능을 제공한다.

put()과 write()

put()과 write()는 저수준 출력 메서드에 속하며 출력 동작을 갖춘 객체나 변수가 아닌 문자 하나 (put()) 또는 문자 배열 하나 (write())을 인수로 받는다.

const char* test = "hello there\n";
cout.write(test, strlen(test));

cout.put('a');
flush()

출력 스트림에 데이터를 쓰는 즉시 목적지에 전달되지 않을 수 있다. 일반적으로 출력 스트림은 들어온 데이터를 곧바로 쓰지 않고 버퍼에 잠시 보관한다. 그렇게 하면 성능을 높일 수 있기 때문이다. 목적지가 파일과 같은 스트림일 떄는 한 문자씩 처리하기 보다 블록 단위로 묶어서 처리하는 것이 훨씬 효율적이다.

스트림은 다음과 같은 조건을 만족할 떄 그동안 쌓아둔 데이터를 모두 내보내고 버퍼를 비운다.

  • endl과 같은 경곗값에 도달할 떄
  • 스트림이 스코프를 벗어나 소멸될 때
  • 출력 스트림에 대응되는 입력 스트림으로부터 요청이 들어올 때(예컨대 cin으로 입력 받으면 cout의 버퍼를 비움)
  • 스트림 버퍼가 가득 찼을 때
  • 스트림 버퍼를 비우기 위해 명시적으로 flush()를 호출할 때

 flush() 메서드를 호출해서 스트림 버퍼를 명시적으로 비우려면 다음과 같이 작성한다.

cout << "abc";
cout.flush();
cout << "def";
cout.flush();

Note) 모든 출력 스트림이 버퍼를 사용하는 것은 아니다. cerr 스트림은 버퍼를 사용하지 않고 출력한다.

출력 에러 처리하기

good() 메서드는 스트림을 정상적으로 사용할 수 있는 상태인지 확인한다. 사용법은 다음과 같이 스트림에 대해 곧바로 호출하면 된다.

if (cout.good())
{
cout << "All good" << endl;
}

good() 메서드를 이용하면 스트림의 상태 정보를 조회할 수 있다. 하지만 사용할 수 없는 상태일 때는 그 원인을 구체적으로 알려주지 않는다. 이런 정보는 bad() 메서드로 자세히 볼 수 있다. bad() 메서드가 true를 리턴한다는 말은 심각한 에러가 발생했다는 뜻이다. (반면 파일의 끝에 도달했는지 확인하는 eof()가 true라는 것은 심각한 상태가 아니다) 

또한 fail() 메서드를 사용하면 최근 수행한 연산에 오류가 발생했는지 확인할 수 있다. 그러나 그 뒤에 일어날 연산의 상태는 알려주지 않기 때문에 fail()의 리턴값에 관계 없이 후속 연산이 성공적으로 수행할 수도 있고 아닐 수도 있다. 예컨대 출력 스트림에 대해 flush()를 호출할 뒤 fail()을 호출하면 바로 직전의 flush() 연산이 성공했는지 확인할 수 있다.

cout.flush();

if (cout.fail())
{
cerr << "Unable to flush to standard out" << endl;
}

스트림을 bool 타입으로 변환하는 연산자도 있다. 이 연산자는 !fail()을 호출할 떄와 똑같은 결과를 리턴한다. 따라서 앞에 나온 코드를 다음과 같이 작성해도 된다.

cout.flush();

if (!cout)
{
cerr << "Unable to flush to standard out" << endl;
}

여기서 주의할 점은 good()과 fail()은 스트림이 파일 끝에 도달할 때도 false를 리턴한다는 것이다. 이 관계를 코드로 표현하면 다음과 같다.

good() == (!fail() && !eof())

스트림에 문제가 있으면 익셉션을 발생하도록 만들 수 있다. ios_base::failure 익셉션을 처리하도록 catch 구문을 작성하면 된다. 이 익셉션에 대해 what() 메서드를 호출하면 발생한 에러에 대한 정보를 볼 수 있다. 또한 code()를 호출하면 에러 코드를 볼 수 있다. 하지만 이 정보가 얼마나 쓸모 있는지는 표준 라이브러리의 구현마다 다르다.

cout.exceptions(ios::failbit | ios::badbit | ios::eofbit);

try
{
cout << "Hello World" << endl;
}
catch (const ios_base::failure& ex)
{
cerr << "Caught exception: " ex.what() << ", error code = " << ex.code() << endl;
}

스트림의 에러 상태를 초기화하려면 clear() 메서드를 호출한다.

cout.clear();

출력 매니퓰레이터

C++의 스트림은 단순히 데이터를 전달하는데 그치지 않고, 매니퓰레이터(manipulator, 조종자)라는 객체를 받아서 스트림의 동작을 변경할 수도 있다. 이때 스트림의 동작을 변경하는 작업만 할 수도 있고, 스트림에 데이터를 전달하면서 동작도 변경할 수 있다.

앞서 본 endl이 바로 스트림 매니퓰레이터다. endl은 데이터와 동작을 모두 담고 있다. 그래서 스트림에 전달될 때 줄끝(end-of-line) 문자를 출력하고 버퍼를 비운다. 몇 가지 유용한 스트림 매니퓰레이터를 소개하면 다음과 같다. 대부분 <ios>나 <iomanip> 헤더 파일에 정의돼 있다.

  • boolalpha와 noboolalpha: 스트림에 bool 값을 true나 false로 출력하거나(boolalpha), 1이나 0으로 출력하도록(noboolalpha) 설정한다. 기본값은 noboolalpha다.
  • hex, oct, dec: 각각 숫자를 16진수, 8진수, 10진수로 출력한다.
  • setprecision: 분숫값을 표현할 때 적용할 소수점 자리수를 지정한다. 이를 위해 자릿수를 표현하는 인수를 받는다.
  • setw: 숫자 데이터를 출력할 필드의 너비를 지정한다. 이 매니퓰레이터도 인수를 받는다.
  • setfill: 지정된 너비보다 숫자가 작을 때 빈 공간을 채울 문자를 지정한다. 이 매니퓰레이터도 인수를 받는다.
  • showpoint와 noshowpoint: 소수점 아래의 수가 없는 부동소수점수를 스트림에서 표현할 때 소수점의 표시 여부를 설정한다.
  • put_money: 스트림에서 화폐 금액을 일정한 형식에 맞게 표현할 때 사용하는 매니퓰레이터로서 인수를 받는다.
  • put_time: 스트림에서 시간을 일정한 형식에 맞게 표현할 때 사용하는 매니퓰레이터로서 인수를 받는다.
  • quoted: 지정한 스트링을 인용부호(따옴표)로 감싸고 스트링 안에 있던 인용부호를 이스케이프 문자로 변환한다. 이 매니퓰레이터도 인수를 받는다.

(예시 코드 생략)

스트림을 이용한 입력

입력 스트림의 기초

입력 스트림으로부터 데이터를 읽는 두 가지 방법이 있다. 하나는 출력 연산자 <<로 데이터를 출력하는 방법과 비슷하며 << 대신 입력 연산자 >>를 사용한다. 이때 >> 연산자로 입력 스트림에서 읽은 데이터를 변수에 저장할 수 있다. 예컨대 사용자로부터 단어 하나를 받아서 스트링에 저장한 뒤 콘솔에 출력하려면 다음과 같이 작성한다.

string userInput;
cin >> userInput;
cout << "User input was " << userInput << endl;

>> 연산자의 기본 설정에 따르면 공백을 기준으로 입력된 값을 토큰 단위로 나눈다(토큰화 한다) 예컨대 앞서 나온 코드를 실행한 뒤 콘솔에서 ‘hello there’를 입력하면 첫 번쨰 공백 문자(스페이스) 이전의 문자들만 userInput 변수에 담긴다. 출력 결과는 다음과 같다.

User input was hello

또 다른 방법은 get()을 사용하는 것이다. 그러면 입력값에 공백을 담을 수 있다.

>> 연산자는 <<와 마찬가지로 다양한 타입을 지원한다. 예컨대 정숫값을 읽으려면 다음처럼 변수의 타입만 바꾸면 된다.

int userInput;
cin >> userInput;
cout << "User input was " << userInput << endl;

또한 타입이 다른 값을 동시에 받을 수 있다. 

(예시 코드 생략)

입력 에러 처리하기

입력 스트림은 비정상적인 상황을 감지하는 여러 메서드를 제공한다. 입력 스트림의 에러는 대부분 읽을 데이터가 없을 때 발생한다. 예컨대 스트림의 끝(파일 끝 end-of-file)에 도달할 때가 있다. 이에 대처하는 가장 흔한 방법은 입력 스트림에 접근하기 전에 조건문으로 스트림의 상태를 확인하는 것이다. 예컨대 다음 반복문은 cin이 정상 상태일 때만 진행한다.

while (cin) { ... }

이때 데이터 입력을 받아도 된다.

while (cin >> ch) { ... }

출력 스트림과 마찬가지로 입력 스트림에 대해서도 good(), bad(), fail() 메서드를 호출할 수 있다. 또한 스트림이 끝에 도달하면 true를 리턴하는 eof() 메서드도 사용할 수 있다. 

입력 스트림의 good()과 fail()은 출력 스트림과 마찬가지로 파일 끝에 도달하면 false를 리턴하며 출력 스트림처럼 다음과 같은 관계가 성립한다.

good() == (!fail() && !eof())

따라서 데이터를 읽을 때마다 항상 스트림 상태를 검사하는 습관을 들인다. 그래야 잘못된 값이 입력될 때 적절히 대처할 수 있다.

(이하 예시 생략)

입력 메서드

출력 스트림과 마찬가지로 입력 스트림도 >> 연산자보다 저수준으로 접근하는 메서드를 제공한다.

get()

get() 메서드는 스트림 데이터를 저수준으로 읽는다. get()의 가장 간단한 버전은 스트림의 다음 문자를 리턴한다. 물론 여러 문자를 한 번에 읽는 버전도 있다. get()은 주로 >> 연산자를 사용할 떄 자동으로 토큰 단위로 잘리는 문제를 피하는 용도로 사용한다. 예컨대 다음 함수는 입력 스트림에서 이름 하나를 받는다. 이때 이름이 여러 단어로 구성될 수 있으므로 스트림의 끝에 도다할 때까지 이름을 계속 읽는다.

string readName(istream& stream)
{
string name;

while(stream) // 또는 while(!stream.fail())
{
int next = stream.get();
if (!stream || next == std::char_traits<char>::eof())
{
break;
}
name += static_cast<char>(next);
}

return name;
}

readName() 함수를 구현하는 과정에서 몇 가지 주목할 점이 있다.

  • 매개변수의 타입은 non-const istream 레퍼런스다. 스트림에서 데이터를 읽는 메서드는 실제 스트림을 (그중에서도 특히 위치를) 변경하기 때문에 const로 지정하지 않았다. 따라서 const 레퍼런스에 대해 호출할 수 없다.
  • get()의 리턴값은 char가 아닌 int 타입 변수에 저장했다. get()은 EOF에 해당하는 std::char_traits<char>::eof()를 비롯한 문자가 아닌 특수한 값을 리턴할 수 있기 떄문이다.

여기 나온 readName() 코드는 반복문을 끝내는 방법이 두 가지라는 점이 특이하다. 하나는 스트림이 오류 상태에 빠질 떄고, 다른 하나는 스트림의 끝에 도달할 때다. 일반적으로 스트림에서 데이터를 읽는 부분을 구현할 때는 여기 나온 방식보다는 문자에 대한 레퍼런스를 받아서 스트림에 대한 레퍼런스를 리턴하는 버전의 get()을 이용하는 방식을 많이 사용한다. 이렇게 작성하면 입력 스트림이 에러 상태가 아닐 때만 조건문에서 true를 리턴한다는 점을 활용할 수 있다. 즉, 스트림에 에러가 발생하면 조건문으로 적은 표현식의 결과는 false가 된다. 이렇게 하면 코드를 다음과 같이 훨씬 간결하게 작성할 수 있다.

string readName(istream& stream)
{
string name;
char next;

while(stream.get(next))
{
name += next;
}

return name;
}
unget()

일반적으로 입력 스트림은 한 방향으로만 진행하는 컨베이어 벨트와 같다. 여기에 올린 데이터는 변수로 전달된다. 그런데 unget() 메서드는 데이터를 다시 입력 소스 방향으로 보낼 수 있다는 점에서 이 모델을 따르지 않는다.

unget()을 호출하면 스트림이 한 칸 앞으로 거슬러 올라간다. 그래서 이전에 읽은 문자를 스트림으로 되돌린다. unget() 연산의 성공 여부는 fail() 메서드로 확인한다. 예컨대 현재 위치가 스트림의 시작점이면 unget()에 대한 fail()의 리턴값은 false다.

앞서 예시 함수는 공백이 담긴 이름을 입력 받을 수 없었다. unget()을 이용하면 공백을 담을 수 있다.

(예시 코드 생략)

putback()

putback() 메서드도 unget()과 마찬가지로 입력 스트림을 한 문자만큼 되돌린다. unget()과 달리 putback()은 스트림에 되돌릴 문자를 인수로 받는다.

char ch1;
cin >> ch1;
cin.putback('e');
// 이 스트림에서 다음 번에 읽어올 문자는 'e'가 된다.
peek()

peek()은 ‘힐끗 본다’는 의미대로 get()을 호출할 떄 리턴될 값을 미리 보여준다.

(예시 코드 생략)

getline()

프로그램을 작성하다 보면 입력 스트림에서 데이터를 한 줄씩 읽을 일이 많다. 이를 위해 getline()이란 메서드를 별도로 제공한다. 이 메서드는 미리 설정한 버퍼가 가득 채워질 때까지 문자 한 줄을 읽는다. 이때 한 줄의 끝을 나타내는 \0(EOL, end-of-line) 문자도 버퍼의 크기에 포함된다.

다음 코드는 cin으로부터 kbufferSize-1개의 문자를 읽거나 EOL 문자가 나올 때까지 읽기 연산을 수행한다.

char buffer[kBufferSize = { 0 };
cin.getline(buffer, kBufferSize);

getline()이 호출되면 입력 스트림에서 EOL이 나올 때까지 문자 한 줄을 읽는다. EOL 문자는 스트링에 담기지 않는다. 참고로 EOL 문자는 플랫폼마다 다를 수 있는데, 어떤 것은 \r\n을 사용하고 어떤 것은 \n이나 \n\r을 사용한다.

get() 함수 중에서 getline()과 똑같이 작동하는 버전도 있다. 단, 이 함수는 입력 스트림에서 줄바꿈 문자를 가져오지 않는다.

C++의 string에서 사용할 수 있는 std::getline()이란 함수도 있다. 이 함수는 <string> 헤더파일의 std 네임스페이스 아래에 정의돼 있다. 이 함수는 스트림 레퍼런스와 string 레퍼런스를 받고, 옵션으로 구분다(delimiter)도 받는다. std::getline()을 사용하면 버퍼의 크기를 지정하지 않아도 된다는 장점이 있다.

string myString;
std::getline(cin, myString);

입력 매니퓰레이터

C++는 다음과 같은 입력 매니퓰레이터를 기본으로 제공한다. 이를 입력 스트림에 적절히 지정하면 데이터를 읽는 방식을 원하는대로 설정할 수 있다.

  • boolalpha와 noboolalpha: boolalpha를 지정하면 ‘false’란 스트링값을 부울 타입인 false로 해석하고 나머지 스트링을 true로 처리한다. noboolalpha를 지정하면 0을 부울값 false로 해석하고, 0이 아닌 나머지 값을 true로 처리한다. 기본적으로 noboolalpha로 설정돼 있다.
  • hex, oct, dec: 각각 숫자를 16진수, 8진수, 10진수로 읽도록 지정한다.
  • skipws와 noskipws: skipws를 지정하면 토큰화할 떄 공백을 건너뛰고, noskpws를 지정하면 공백을 하나의 토큰으로 취급한다. 기본적으로 skipws로 지정되어 있다.
  • ws: 스트림의 현재 위치부터 연달아 나온 공백 문자를 건너뛴다.
  • get_money: 스트림에서 화폐 금액을 표현한 값을 읽는 매개변수 방식의 매니퓰레이터
  • get_time: 스트림에서 일정한 형식으로 표현된 시각 정보를 읽는 매개변수 방식의 매니퓰레이터다.
  • quoted: 인용부호(따옴표)로 묶은 스트링을 읽는 매니퓰레이터로서 인수를 받는다. 이스케이프 문자로 입력된 따옴표는 스트링에 포함된다.

입력은 로케일 설정에 영향을 받는다.

(이하 설명 생략)

객체에 대한 입력과 출력

string은 C++ 언어의 기본 타입은 아니지만 << 연산자로 출력할 수 있다. C++에서는 객체가 입력되거나 출력되는 방식을 정의할 수 있다. << 나 >> 를 오버로딩하면 이 연산자가 특정한 타입이나 클래스를 처리하게 만들 수 있다.

(예시 생략)

스트링 스트림

스트링 스트림이란 string에 스트림 개념을 추가한 것이다. 이렇게 하면 텍스트 데이터를 메모리에서 스트림 형태로 표현하는 인메모리 스트림(in-memory stream)을 만들 수 있다.

예컨대 GUI 애플리케이션에서 콘솔이나 파일이 아닌 스트림으로부터 텍스트 데이터를 구성한 뒤 이를 메시지 박스나 편집 컨트롤과 같은 GUI 요소로 결과를 출력할 수 있다. 또 다른 예로 스트링 스트림을 현재 위치에 대한 정보와 함께 여러 함수에 전달해서 다양한 작업을 연속적으로 처리할 수 있다. 스트링 스트림은 기본적으로 토큰화(tokenizing) 기능을 제공하기 때문에 텍스트 구문 분석(파싱, parsing) 작업에 활용해도 편하다.

string에 데이터를 쓸 때는 std::ostringstream 클래스를, 반대로 string에서 데이터를 읽을 때는 std::istringstream 클래스를 사용한다. 둘 다 <sstream> 헤더 파일에 정의돼 있다.

다음 코드는 사용자로부터 받은 단어들을 탭 문자로 구분해서 ostringstream에 쓴다. 다 쓰고 나면 str() 메서드를 이용하여 스트림 전체를 string 객체로 변환한 뒤 콘솔에 쓴다. 입력값 ‘done’이란 단어를 입력할 때까지 토큰 단위로 입력 받거나, 유닉스라면 Ctrl+D, 윈도우라면 Ctrl+Z를 입력해서 입력 스트림을 닫기 전까지 입력 받는다.

cout << "Enter tokens. Control+D (Unix) or Control+Z (Windows) to end" << endl;
ostringstream outStream;

while(cin)
{
string nextToken;
cout << "Next token: ";
cin >> nextToken;

if (!cin || nextToken == "done")
{
break;
}

outStream << nextToken << "\t";
}

cout << "The end result is: " << outStream.str();

스트링 스트림에서 데이터를 읽는 방법도 비슷하다. 다음 함수는 스트링 입력 스트림으로부터 Muffin 객체를 생성한 뒤 속성을 설정한다. 이때 받은 스트림 데이터는 일정한 포맷을 따르기 떄문에 이 함수는 Muffin 세터를 호출하는 방식으로 입력된 값을 간단히 변환할 수 있다.

Muffin createMuffin(istringstream& stream)
{
Muffin muffin;

// 데이터가 다음과 같은 형식에 맞게 들어온다고 가정한다. Description, size, chips

string description;
int size;
bool hasChips;

// 세 값 모두 읽는다. 이때 chips는 'true'나 'false'란 스트링으로 표현한다.
stream >> descrption >> size >> boolalpha >> hasChips;

if (stream)
{
muffin.setSize(size);
muffin.setDescription(description);
muffin.setHasChocolateChips(hasChips);
}
return muffin;
}

Note) 객체를 스트링처럼 일렬로 나열하는 것을 마셜링(marshalling)이라 부른다. 마셜링은 객체를 디스크에 저장하거나 네트워크로 전송할 때 유용하다.

파일 스트림

파일은 스트림 개념과 정확히 일치한다. 파일을 읽고 쓸 때 항상 현재 위치를 추적하기 때문이다. C++는 파일 출력과 입력을 위해 std::ofstream과 std::ifstream 클래스를 제공한다. 둘 다 <fstream> 헤더 파일에 정의돼 있다.

파일시스템을 다룰 때는 에러 처리가 특히 중요하다. 네트워크로 연결된 저장소에 있던 파일을 다루던 중 갑자기 네트워크 연결이 끊길 수 있고, 로컬 디스크에 파일을 쓰다가 디스크가 가득 찰 수도 있다. 또는 현재 사용자에게 권한이 없는 파일을 열 수도 있다. 이런 에러 상황을 제때 감지해서 적절히 처리하려면 표준 에러 처리 메커니즘을 이용하면 된다.

파일 출력 스트림과 다른 출력 스트림의 가장 큰 차이점은 파일 스트림 생성자는 파일의 이름과 파일을 열 때 적용할 모드에 대한 인수를 받는다는 점이다. 출력 스트림의 디폴트 모드는 파일을 시작 지점부터 쓰는 ios_base::out이다. 이때 기존 데이터가 있으면 덮어쓴다. 또는 파일 스트림 생성자의 두 번째 인수로 ios_base::app(추가모드)를 지정하면 파일 스트림을 기존 데이터 뒤에 추가할 수 있다. 파일 스트림의 모드로 지정할 수 있는 값은 다음과 같다.

상수 설명
ios_base::app 파일을 열고, 쓰기 연산을 수행하기 전에 파일 끝으로 간다.
ios_base::ate 파일을 열고 즉시 파일 끝으로 간다.
ios_base::binary 입력 또는 출력을 텍스트가 아닌 바이너리 모드로 처리한다.
ios_base::in 입력할 파일을 열고 시작 지점부터 읽는다.
ios_base::out 출력할 파일을 열고 시작 지점부터 쓴다. 기존 데이터를 덮어쓴다.
ios_base::trunc 출력할 파일을 열고 기존 데이터를 모두 삭제한다.(truncate)

여기 나온 모드를 조합해서 지정할 수도 있다.

(이하 설명 생략)

텍스트 모드와 바이너리 모드

파일 스트림은 기본적으로 텍스트 모드로 연다. 파일 스트림을 생성할 때 ios_base::binary 플래그를 지정하면 파일을 바이너리 모드로 연다.

바이너리 모드로 열면 정확히 바이트 단위로 지정한 만큼만 파일에 쓴다. 파일을 읽을 때는 파일에서 읽은 바이트 수를 리턴한다.

텍스트 모드로 열면 파일에서 \n이 나올 때마다 한 줄씩 읽거나 쓴다. 이떄 파일에서 줄 끝(EOL)을 나타내는 문자는 OS마다 다르다.

seek()과 tell() 메서드로 랜덤 액세스하기

입력과 출력 스트림은 모두 seek()와 tell() 메서드를 갖고 있다.

seek() 메서드는 입력 또는 출력 스트림에서 혀냊 위치를 원하는 지점으로 옮긴다. seek()은 여러 버전이 있다. 입력 스트림에 대한 seek() 메서드를 seekg() 라 부른다. 여기서 g는 ‘get’을 의미한다. 출력 스트림에 대한 seek()는 seekp()라 부르는데, 여기서 p는 ‘put’을 의미한다. 

seek()를 하나로 표현하지 않고 seekg(), seekp()로 구분한 이유는 파일 스트림처럼 입력과 출력을 모두 가질 때가 있기 때문이다. 이럴 때는 읽는 위치와 쓰는 위치를 별도로 관리해야 한다. 이를 양방향(bidrectional) I/O라 부른다.

seekg()와 seekp()는 각각 두 가지 버전이 있다. 하나는 절대 위치를 나타내는 인수 하나만 받아서 그 위치로 이동한다. 다른 하나는 오프셋(offset)과 위치에 대한 인수를 받아서 지정한 위치를 기준으로 떨어진 거리(오프셋)로 이동한다. 이때 위치는 std::streampos로 오프셋은 std::streamoff로 표현한다. C++ 에 미리 정의된 위치는 다음과 같다.

위치 설명
ios_base::beg 스트림의 시작점
ios_base::end 스트림의 끝점
ios_base::cur 스트림의 현재 위치

예컨대 다음과 같이 매개변수가 하나인 seekp()에 ios_base::beg 상수를 지정하면 출력 스트림의 위치를 절대 위치로 지정할 수 있다.

outStream.seekp(ios_base::beg);

입력 스트림의 위치를 지정하는 방법도 seekp()가 아닌 seekg()라는 점만 빼면 같다.

inStream.seekg(ios_base::beg);

인수가 두 개인 버전은 스트림의 위치를 상대적으로 지정한다. 첫 번째 인수는 이동할 위치의 양을 지정하고, 두 번째 인수는 시작점을 지정한다. 파일의 시작점을 기준으로 위치를 이동하려면 ios_base::beg 상수를 지정한다. ios_base::end를 사용하면 파일의 끝점을 기준으로 위치를 이동할 수 있다. 또한 현재 위치를 기준으로 이동하고 싶다면 ios_base::cur를 사용한다. 예컨대 다음 코드는 스트림의 시작점에서 두 바이트만큼 이동한다. 여기서 인수로 지정한 정숫값은 자동으로 streampos나 streamoff로 변환된다.

outStream.seekp(2, ios_base::beg);

다음 코드는 입력 스트림의 끝에서 세 번째 바이트로 이동한다.

inStream.seekg(-3, ios_base::end);

tell() 메서드를 이용하면 스트림의 현재 위치를 알아낼 수 있다. 이 메서드는 현재 위치를 streampos 타입의 값으로 리턴한다. seek()을 호출하거나 tell()을 다시 호출하기 전에 현재 위치를 기억하고 싶다면 앞서 tell()에서 리턴한 값을 저장해 둔다. seek()와 마찬가지로 tell()도 입력과 출력에 대해 서로 다른 버전(tellg(), tellp())를 제공한다.

다음 코드는 입력 스트림의 위치가 스트림의 시작점인지 확인한다.

std::streampos curPos = inStream.tellg();

if (ios_base::beg == curPos)
{
cout << "We're at the beginning" << endl;
}

(예시 코드 생략)

스트림끼리 서로 연결하기

입력 스트림과 출력 스트림은 언제든지 접근할 떄 내보내기(flush-on-access) 방식으로 서로 연결될 수 있다. 다시 말해 입력 스트림을 출력 스트림에 연결한 뒤 입력 스트림에서 데이터를 읽으면 즉시 출력 스트림으로 내보낸다. 이러한 동작은 모든 종류의 스트림에서 가능하며 파일 스트림끼리 연결할 때 특히 유용하다.

스트림을 연결하는 작업은 tie() 메서드로 처리한다. 출력 스트림을 입력 스트림에 연결하려면 입력 스트림에 대해 tie()를 호출한다. 이때 연결할 출력 스트림의 주소를 인수로 전달한다. 연결을 끊으려면 tie()에 nullptr를 전달해서 호출한다.

다음 코드는 한 파일에 대한 입력 스트림을 전혀 다른 파일에 대한 출력 스트림에 연결하는 예를 보여준다. 이때 같은 파일에 대한 출력 스트림을 연결해도 되지만, 이렇게 같은 파일에 읽고 쓸 때는 양방향 I/O를 이용하는 것이 낫다.

ifstream inFile("input.txt");
ofstream outFile("output.txt");

// inFile과 outFile을 연결한다.
inFile.tie(&outFile);

// outFile에 텍스트를 쓴다. std::endl이 입력되기 전까지는 내보내지 않는다.
outFile << "Hello there!";

// outFile을 아직 내보내지 않은 상태다.
// inFile에서 텍스트를 읽는다. 그러면 outFile에 대해 flush()가 호출된다.
string nextToken;
inFile >> nextToken;

// 이제 outFile이 내보내졌다.

여기서 사용한 flush() 메서드는 ostream 베이스 클래스에 정의돼 있다. 따라서 다음과 같이 출력 스트림을 다른 출력 스트림에 연결할 수도 있다.

outFile.tie(&anotherOutputFile);

이렇게 하면 한 파일에 뭔가 쓸 때마다 버퍼에 저장된 데이터를 다른 파일에 내보낸다. 이렇게 하면 서로 관련된 두 파일을 동기화시킬 수 있다.

스트림 연결의 대표적인 예로 cout과 cin을 연결해서 cin에 데이터를 입력할 때마다 cout을 자동으로 내보내게 만드는 경우가 있다. cerr와 cout도 서로 연결할 수 있다. 반면 clog 스트림은 cout에 연결될 수 없다. 와이드 문자 버전의 스트림도 같은 방식으로 연결한다.

양방향 I/O

지금까지 살펴본 입력과 출력 스트림은 기능상 서로 관련이 있지만 별도의 클래스로 존재한다. 이와 달리 입력과 출력을 모두 처리하는 스트림도 있는데, 이를 양방향 스트림(bidirectional stream)이라 한다.

양방향 스트림은 iostream을 상속한다. 다시 말해 istream과 ostream을 동시에 상속하기 때문에 다중 상속의 대표적인 예이기도 하다. 양방향 스트림은 입력과 출력 스트림의 메서드 뿐만 아니라 >>와 << 연산자를 동시에 제공한다.

fstream 클래스는 양방향 파일시스템을 표현한다. fstream은 파일 안에서 데이터를 교체할 때 유용하다. 정확한 위치를 발견할 때까지 데이터를 읽다가 필요한 시점에 즉시 쓰기 모드로 전환할 수 있기 때문이다. 예컨대 ID와 전화번호 매핑 정보를 관리하는 프로그램을 보자. 이때 데이터는 다음과 같은 포맷으로 파일에 저장된다고 가정한다.

123 408-555-0394
124 415-555-3422
263 585-555-3490
100 650-555-3434

파일을 열고 데이터 전체를 읽고 나서 적절히 내용을 수정한 뒤 프로그램을 종료하기 전에 파일 전체를 다시 쓰는 방식으로 구현하는 경우가 많다. 그런데 데이터 양이 엄청나게 많다면 모든 내용을 메모리에 담을 수 없다. iostream을 이용하면 이런 문제를 피할 수 있다. 팡리에서 데이터를 검색하다가 적절한 지점을 발견하면 추가 모드(append mode)로 열고 원하는 내용을 추가하면 된다. 

다음 예는 특정한 ID에 대한 전화번호를 변경하는데, 이렇게 기존 데이터를 수정할 떄는 양방향 스트림을 활용한다.

bool changeNumberForID(string_view filename, int id, string_view newNumber)
{
fstream ioData(filename.data());

if (!ioData)
{
cerr << "Error while opening file " << filename << endl;
return false;
}

// 파일 끝까지 반복한다.
while(ioData)
{
int idRead;
string number;

// 다음 ID를 읽는다.
ioData >> idRead;

if (!ioData)
{
break;
}

// 현재 레코드가 수정할 대상인지 확인한다.
if (idRead == id)
{
// 쓰기 위치를 현재 읽기 위치로 이동한다.
ioData.seekp(ioData.tellg());

// 한 칸 띄운 뒤 새 번호를 쓴다.
ioData << " " << newNumber;
break;
}

// 현재 위치에서 숫자를 읽어서 스트림의 위치를 다음 레코드로 이동한다.
ioData >> number;
}
return true;
}

물론 이 방법은 데이터의 크기가 일정할 때만 적용할 수 있다. 앞의 예제에서 읽기 모드를 쓰기 모드로 전환하는 순간 기존 파일에 있던 데이터를 덮어쓴다. 파일 포맷을 그대로 유지하면서 다음 레코드를 덮어쓰지 않게 하려면 데이터(레코드)의 크기가 모두 같아야 한다.

stringstream 클래스를 이용하면 스트링 스트림도 양방향 스트림처럼 다룰 수 있다.

Note) 양방향 스트림은 읽기 위치와 쓰기 위치에 대한 포인터를 별도로 사용한다. 읽기와 쓰기 모드를 전환할 때마다 seek() 메서드로 각각의 위치를 적절히 설정해야 한다.