C++ std::copy·copy_if·copy_backward: 목적지 크기, 겹치는 범위, back_inserter

이 글의 핵심

std::copy는 목적지 공간을 확보해 주지 않기 때문에 빈 벡터에 그대로 복사하면 정의되지 않은 동작이 됩니다. 겹치는 범위에서 copy_backward가 필요한 이유, std::copy와 memcpy의 관계, reserve로 재할당을 줄이는 방법과 C++17 병렬 복사까지 다뤄 상황에 맞는 복사 알고리즘을 고를 수 있게 합니다.

복사 알고리즘이란?

<algorithm>의 복사 알고리즘은 한 범위의 원소를 다른 곳으로 복사하거나, 조건에 맞는 원소만 골라 복사하는 함수들입니다. 손으로 쓴 루프와 하는 일은 같지만, 의도가 함수 이름에 드러나고 경계 실수가 줄어듭니다.

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

// 손으로 쓴 루프
for (std::size_t i = 0; i < src.size(); ++i) dst.push_back(src[i]);

// 같은 일을 하는 알고리즘
std::copy(src.begin(), src.end(), std::back_inserter(dst));

모든 복사 알고리즘은 목적지로 출력 반복자를 받고, 그 반복자에 값을 대입하며 전진할 뿐입니다. 목적지 컨테이너의 크기를 늘려 주지 않는다는 점이 이 글에서 다룰 함정 대부분의 출발점입니다.

알고리즘하는 일
copy(first, last, out)범위 전체 복사
copy_if(first, last, out, pred)조건이 참인 원소만 복사
copy_n(first, n, out)앞에서부터 n개 복사
copy_backward(first, last, d_last)끝에서부터 거꾸로 복사 (오른쪽으로 겹치는 경우)
move(first, last, out)원소를 이동
move_backward(first, last, d_last)끝에서부터 이동
remove_copy(first, last, out, value)특정 값을 뺀 나머지 복사
remove_copy_if(first, last, out, pred)조건이 참인 원소를 뺀 나머지 복사

반환값은 모두 마지막으로 쓴 위치의 다음을 가리키는 출력 반복자입니다(copy_backward와 move_backward는 마지막으로 쓴 위치, 즉 목적지 구간의 시작). 이 반환값으로 실제로 몇 개가 쓰였는지 알 수 있습니다.


copy, copy_if, copy_n

세 함수는 입력 범위를 어떻게 정하느냐가 다릅니다.

std::vector<int> src = {1, 2, 3, 4, 5, 6};
std::vector<int> a, b, c;

std::copy(src.begin(), src.end(), std::back_inserter(a));        // 1 2 3 4 5 6
std::copy_if(src.begin(), src.end(), std::back_inserter(b),
             [](int x) { return x % 2 == 0; });                    // 2 4 6
std::copy_n(src.begin(), 3, std::back_inserter(c));               // 1 2 3

copy_if는 원본을 바꾸지 않고 조건에 맞는 원소만 새 시퀀스로 모읍니다. 같은 원본에서 합격자 목록과 불합격자 목록을 따로 만드는 식으로 여러 번 쓸 수 있습니다. 두 목록을 한 번에 나누고 싶다면 std::partition_copy가 원소마다 조건을 한 번만 평가합니다.

struct LogEntry {
    std::string level;
    std::string message;
};

std::vector<LogEntry> logs = /* ... */;
std::vector<LogEntry> errors;
std::copy_if(logs.begin(), logs.end(), std::back_inserter(errors),
             [](const LogEntry& e) { return e.level == "ERROR"; });

copy_n은 끝 반복자 없이 개수만 지정하므로, 끝을 미리 알 수 없는 입력 반복자에서 앞부분만 가져올 때 씁니다. 다만 입력에 실제로 n개가 있어야 하고, 모자라면 정의되지 않은 동작입니다. 파일에서 헤더 512바이트를 읽는 경우처럼 입력 길이를 보장할 수 없다면, copy_n보다 read로 읽고 실제로 읽은 바이트 수를 확인하는 편이 안전합니다.

std::ifstream file("data.bin", std::ios::binary);
std::vector<char> header(512);
file.read(header.data(), header.size());
if (file.gcount() != static_cast<std::streamsize>(header.size())) {
    // 파일이 512바이트보다 짧음
}

목적지 크기

std::vector<int> src = {1, 2, 3, 4, 5};
std::vector<int> dst(3);

// dst에는 원소가 3개뿐: 4번째부터 범위 밖에 씀 (정의되지 않은 동작)
// std::copy(src.begin(), src.end(), dst.begin());

// 1: 목적지를 먼저 같은 크기로
dst.resize(src.size());
std::copy(src.begin(), src.end(), dst.begin());

// 2: back_inserter로 넣을 때마다 push_back
std::vector<int> dst2;
dst2.reserve(src.size());   // 재할당 방지
std::copy(src.begin(), src.end(), std::back_inserter(dst2));

dst.begin()은 이미 존재하는 원소를 가리키는 반복자이므로, 그 뒤로 원소가 모자라면 할당되지 않은 메모리에 씁니다. 빈 벡터에 std::copy(..., dst.begin())을 하는 실수가 가장 흔하고, 이때는 크래시 없이 조용히 메모리를 덮어쓸 수도 있어 찾기 어렵습니다. 디버그 빌드에서 MSVC나 libstdc++의 디버그 모드(-D_GLIBCXX_DEBUG)를 켜면 이런 범위 초과를 대부분 바로 잡아 줍니다.

reserve는 용량만 늘리고 원소 수(size)는 그대로입니다. reserve 후 dst.begin()에 copy하는 것은 여전히 정의되지 않은 동작이므로, reserve는 back_inserter와 함께 쓰고 resize는 begin()과 함께 씁니다. 크기를 알고 같은 타입이라면 사실 std::vector<int> dst(src.begin(), src.end());나 dst.assign(src.begin(), src.end());가 가장 간단합니다.

같은 벡터를 자기 자신 뒤에 이어 붙이려고 std::copy(v.begin(), v.end(), std::back_inserter(v))를 쓰면, 복사 도중 push_back이 재할당을 일으켜 읽고 있던 v.begin()~v.end() 반복자가 무효화됩니다. back_inserter 방식이라면 먼저 reserve(2 * v.size())로 재할당이 일어나지 않게 해 두면 원래 끝까지만 정확히 복사됩니다. 반면 v.insert(v.end(), v.begin(), v.end())는 reserve와 상관없이 표준이 금지하는 호출입니다(삽입할 범위가 자기 자신의 반복자이면 안 된다는 전제 조건). 이 경우는 복사본을 만들어 붙이는 것이 안전합니다.


겹치는 범위와 copy_backward

std::copy는 앞에서부터 차례로 대입합니다. 원본과 목적지가 같은 버퍼에서 겹치는데 목적지가 오른쪽으로 밀려 있으면, 아직 읽지 않은 원본을 먼저 덮어쓸 수 있습니다. 그래서 표준은 목적지 시작 위치가 원본 범위 [first, last) 안에 있으면 copy의 동작을 정의하지 않습니다.

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

// 목적지 v.begin()+1이 원본 [begin, begin+3) 안에 있음: 정의되지 않은 동작
// std::copy(v.begin(), v.begin() + 3, v.begin() + 1);

// 오른쪽으로 밀 때는 copy_backward: 세 번째 인자는 목적지 구간의 끝
std::copy_backward(v.begin(), v.begin() + 3, v.begin() + 4);
// v: 1 1 2 3 5

// 왼쪽으로 당길 때는 copy로 충분
std::vector<int> w = {1, 2, 3, 4, 5};
std::copy(w.begin() + 2, w.end(), w.begin());
// w: 3 4 5 4 5

잘못된 copy가 실제로 어떤 결과를 내는지는 구현에 따라 다릅니다. 원소를 하나씩 대입하는 구현이라면 {1, 1, 1, 1, 5}처럼 같은 값이 번져 나가고, int처럼 트리비얼한 타입에 대해 memmove로 바꾸는 구현이라면 우연히 올바른 결과가 나옵니다. 어느 쪽이든 표준이 보장하지 않으므로, 컴파일러나 최적화 옵션을 바꿨을 때 결과가 달라질 수 있습니다.

copy_backward는 끝 원소부터 거꾸로 복사하므로, 오른쪽으로 밀 때 원본을 덮어쓰기 전에 읽습니다. 세 번째 인자가 목적지의 시작이 아니라 끝(마지막 원소 다음)이라는 점이 헷갈리기 쉽습니다. 이동 버전인 move_backward도 같은 규칙입니다. 방향이 헷갈리면 std::rotate처럼 겹침을 알아서 처리하는 알고리즘을 쓰거나 임시 버퍼를 거치는 것도 방법입니다.


move 알고리즘

<algorithm>의 std::move(first, last, out)는 원소를 하나씩 이동 대입합니다. 인자 하나를 받는 <utility>의 std::move(캐스팅)와 이름은 같지만 다른 함수입니다.

std::vector<std::string> src = {"a fairly long string that does not fit SSO", "another long string here"};
std::vector<std::string> dst;
dst.reserve(src.size());

std::move(src.begin(), src.end(), std::back_inserter(dst));
// dst에 두 문자열이 옮겨짐. src는 여전히 원소 2개를 갖지만 각 값은 지정되지 않음

std::string을 복사하면 원소마다 새 버퍼를 할당하고 내용을 복사하지만, 이동하면 버퍼 포인터와 길이만 넘기고 원본은 보통 빈 문자열이 됩니다. 그래서 차이는 바이트 수보다 힙 할당 횟수에서 나옵니다. 짧은 문자열은 SSO(Small String Optimization)로 객체 안에 저장되어 원래 힙 할당이 없으므로 복사와 이동의 차이가 거의 없습니다.

타입copymove
int, double값 복사값 복사 (같음)
짧은 std::string (SSO)객체 내부 버퍼 복사거의 같음
긴 std::string힙 할당 + 내용 복사포인터·길이만 이동
std::vector<T>힙 할당 + 원소 전체 복사포인터 3개 이동

이동 후 원본 원소는 “유효하지만 값이 지정되지 않은 상태”입니다. 소멸시키거나 새 값을 대입하는 것은 안전하지만, 값을 읽어 어떤 값일 거라고 가정하면 안 됩니다. 원본 컨테이너의 원소 수도 그대로이므로, 다 옮긴 뒤 필요 없다면 src.clear()로 비웁니다.

원소가 const이면 std::move 알고리즘은 컴파일 에러 없이 조용히 복사합니다. const std::string&&는 이동 생성자가 아니라 복사 생성자에 바인딩되기 때문입니다. const std::vector<std::string>&로 받은 범위를 옮기려고 할 때 성능이 전혀 나아지지 않는다면 이 경우입니다.

함수에서 지역 벡터를 반환할 때는 알고리즘을 쓸 필요가 없습니다. return temp;는 컴파일러가 복사를 생략(NRVO)하거나, 생략하지 못해도 자동으로 이동합니다.


remove_copy와 remove_copy_if

remove_copy는 특정 값을 뺀 나머지를 목적지에 복사하고, 원본은 바꾸지 않습니다. remove_copy_if는 값 대신 조건을 받습니다.

std::vector<int> src = {1, 2, 3, 2, 4, 2, 5};
std::vector<int> dst;
std::remove_copy(src.begin(), src.end(), std::back_inserter(dst), 2);
// dst: 1 3 4 5, src는 그대로

std::vector<std::string> lines = {"hello", "", "world", "", "C++"};
std::vector<std::string> non_empty;
std::remove_copy_if(lines.begin(), lines.end(), std::back_inserter(non_empty),
                    [](const std::string& s) { return s.empty(); });
// non_empty: hello world C++

remove_copy_if(pred)는 copy_if(!pred)와 같은 결과입니다. 어느 쪽을 쓸지는 조건을 “남길 것”으로 표현하는 게 자연스러운지, “뺄 것”으로 표현하는 게 자연스러운지로 고르면 됩니다.


출력 반복자

목적지로는 컨테이너 반복자 외에 삽입 반복자와 스트림 반복자를 쓸 수 있습니다.

#include <algorithm>
#include <iostream>
#include <iterator>
#include <list>
#include <set>
#include <vector>

std::vector<int> src = {1, 2, 3};

std::vector<int> v1;
std::copy(src.begin(), src.end(), std::back_inserter(v1));          // 1 2 3 (push_back)

std::vector<int> v2 = {10, 20};
std::copy(src.begin(), src.end(), std::inserter(v2, v2.begin()));   // 1 2 3 10 20 (insert)

std::list<int> l;
std::copy(src.begin(), src.end(), std::front_inserter(l));          // 3 2 1 (push_front, 역순)

std::set<int> s;
std::copy(src.begin(), src.end(), std::inserter(s, s.end()));       // set은 insert로만 넣을 수 있음

std::copy(src.begin(), src.end(), std::ostream_iterator<int>(std::cout, " "));  // 1 2 3
반복자호출하는 멤버쓸 수 있는 컨테이너
back_inserterpush_backvector, deque, list, string
front_inserterpush_frontdeque, list, forward_list
inserterinsert(pos, value)대부분의 컨테이너 (set, map 포함)
ostream_iteratoroperator<<스트림

inserter는 넣을 때마다 위치 반복자를 넣은 원소 다음으로 옮기므로 원래 순서대로 들어갑니다. vector 중간에 inserter로 n개를 넣으면 매번 뒤 원소를 미는 O(n·m) 작업이 되므로, 이 경우는 v.insert(pos, first, last)로 한 번에 넣는 편이 훨씬 빠릅니다.


성능

std::copy와 memcpy

libstdc++, libc++, MSVC STL은 원소 타입이 트리비얼하게 복사 가능하고(int, double, 트리비얼한 구조체) 반복자가 연속 메모리를 가리키면 std::copy를 내부적으로 memmove로 바꿉니다. 그래서 std::vector<int> 복사에서 std::copy를 std::memcpy로 바꿔도 대개 이득이 없습니다. 표준이 이 최적화를 요구하지는 않으므로 핫패스라면 생성된 코드나 프로파일로 확인하는 것이 맞습니다.

반대로 std::string, std::vector처럼 트리비얼하게 복사할 수 없는 타입에 memcpy를 쓰면 정의되지 않은 동작입니다. 내부 포인터가 그대로 복사되어 두 객체가 같은 버퍼를 소유하게 되고, 둘 다 소멸할 때 이중 해제가 일어납니다. std::is_trivially_copyable_v<T>로 컴파일 시점에 확인할 수 있습니다.

reserve

back_inserter로 n개를 넣으면 용량이 찰 때마다 재할당과 원소 이동이 일어납니다. 분할상환으로 보면 여전히 O(n)이지만 할당 횟수와 이동 비용이 붙으므로, 개수를 알면 reserve로 한 번에 확보합니다.

병렬 복사 (C++17)

#include <execution>
std::vector<int> dst(src.size());
std::copy(std::execution::par, src.begin(), src.end(), dst.begin());

실행 정책을 받는 오버로드는 순방향 반복자 이상을 요구하므로 back_inserter와 함께 쓸 수 없고, 목적지를 미리 resize해야 합니다. 단순 복사는 계산이 아니라 메모리 대역폭에 묶이는 작업이라 스레드를 늘려도 빨라지는 폭이 작고, 작은 범위에서는 스레드 관리 비용 때문에 오히려 느려집니다. GCC의 libstdc++에서 병렬 정책을 쓰려면 Intel TBB를 링크해야 한다는 점도 확인해야 합니다.

C++20 ranges

std::ranges::copy(src, dst.begin())처럼 범위 버전을 쓰면 반복자 쌍을 따로 넘기지 않아 begin과 end를 다른 컨테이너에서 가져오는 실수가 사라집니다. 반환값은 입력과 출력의 끝 위치를 함께 담은 구조체(in_out_result)입니다.

참고 자료


같이 보면 좋은 글