CodeGym /행동 /C++ SELF /std::span — 연속 데이터에 대한 view

std::span — 연속 데이터에 대한 view

C++ SELF
레벨 18, 레슨 2
사용 가능

1. 기초: std::span — 메모리, view 그리고 const-계약

학습용 코드를 작성할 때 처음에는 모든 것이 단순해 보입니다: 예를 들어 std::vector<int>가 있고 함수를 호출하면 잘 동작합니다. 그러나 코드에서 갑자기 일반 C 배열이 등장하거나 std::array가 나오고, 어떤 경우에는 전체가 아니라 예를 들어 처음 10개 요소만 처리하고 싶어집니다. 그러면 전형적인 문제가 시작됩니다: 같은 함수를 세 버전 작성하거나 '포인터 + 크기' 스타일로 바꾸어 매개변수를 헷갈리지 않게 조심하며 살아야 합니다.

std::span은 '시작 포인터 + 길이' 아이디어를 깔끔하게 표준화한 타입으로, 메서드와 명확한 계약을 가진 정상적인 타입입니다. 표준 라이브러리의 어휘형 타입(vocabulary type)으로서 인터페이스에 사용되어 다른 사람들이(그리고 1주일 뒤의 당신 자신이) 의미를 바로 이해하게 해 줍니다. span이 표준 문서에서 활발히 논의되고 수정되는 사실 자체가 언어에서의 '공식적' 위치를 잘 보여줍니다.

연속 데이터란 무엇인가: 간단한 메모리 모델

std::span을 사용하기 전에, 그것이 무엇을 바라볼 수 있는지 감을 잡아야 합니다. '연속 데이터'는 요소들이 메모리에서 구멍 없이 연속적으로 놓여 있다는 뜻입니다: 하나 다음에 하나, 영화관 좌석처럼(다만 영화관 좌석은 가끔 고장나고, 메모리는 여러분이 직접 UB를 만들지 않는 한 고장나지 않습니다).

연속적인 컨테이너로는 C 배열 T arr[N], std::array<T, N>, std::vector<T> (요소들은 벡터가 성장해서 재할당되기 전까지 연속적으로 놓입니다), 그리고 std::string이 있습니다(문자들도 연속적이며 data()와 size()가 있습니다). 반면에 std::list는 연속적이지 않습니다: 각 요소가 따로따로 존재합니다, 마치 함께 눕기 싫어하는 고양이들처럼요.

감각을 확실히 하기 위해 '연속'이 무엇을 의미하는지 간단한 도식입니다:

flowchart LR
    A["arr[0]"] --> B["arr[1]"] --> C["arr[2]"] --> D["arr[3]"]
    subgraph Memory["메모리: 연속, 빈틈 없음"]
      A
      B
      C
      D
    end

바로 이런 '조밀한 시퀀스'를 std::span이 대상으로 합니다.

std::span<T>: '포인터 + 크기', 사람이 쓰기 좋은 형태

이제 주인공을 소개할 차례입니다. std::span<T>는 연속적으로 놓인 요소 T에 대한 비소유적 표현(view)입니다. 내부적으로는 실제로 (T*, size)쌍과 매우 비슷하지만, size(), empty(), operator[], range-for 등 직관적인 인터페이스를 제공합니다.

철학을 혼동하지 않는 것이 중요합니다. std::vector<int>는 숫자를 담고 자체 저장소(메모리)를 가진 상자입니다. std::span<int>는 상자에 붙어 있는 라벨과 같습니다: '여기서부터 저만큼의 요소를 봐라'. 상자가 사라지면 라벨이 갑자기 숫자를 주머니에 넣지는 않습니다.

간단한 'span 생성 후 순회' 예제:

#include <iostream>
#include <span>

int main() {
    int a[] = {10, 20, 30};

    std::span<int> s(a); // view on the whole array
    std::cout << s.size() << '\n';   // 3
    std::cout << s[1] << '\n';       // 20
}

스타일에 주목하세요: span은 주소와 길이, 두 개의 작은 값과 같아서 보통 값으로 전달합니다. const std::span<int>&로 참조해서 전달할 필요는 거의 없습니다: '무거운 무엇'을 복사 비용으로 아끼는 상황이 아니기 때문입니다.

std::span<const T> vs std::span<T>: 읽기 전용과 수정 가능

초보자가 span을 처음 보면 자주 범하는 실수가 있습니다: 함수가 단지 읽기만 하는데도 모든 곳에서 std::span<int>처럼 쓰는 것입니다. 마치 빵 자르는데 전기톱을 쓰는 셈이죠: 작동은 하겠지만 주변 사람들은 불안해합니다.

함수가 요소를 변경해서는 안 된다면 올바른 선택은 std::span<const T>입니다. 이것은 매개변수 타입에 '읽기 전용' 약속을 타입 수준에서 박아 넣습니다.

두 함수 예제를 비교해보세요(코드는 거의 같고, 차이는 계약입니다):

#include <span>

int sum(std::span<const int> xs) {
    int total = 0;
    for (int x : xs) total += x;
    return total;
}
#include <span>

void negate_all(std::span<int> xs) {
    for (int& x : xs) x = -x;
}

의미는 간단합니다: std::span<int>는 요소를 변경할 수 있지만, 여전히 크기를 변경할 수는 없습니다 (span에 요소를 추가할 수는 없습니다 — 추가는 소유자, 예를 들어 vector만 할 수 있습니다).

3. 어디서 std::span을 얻을까

여기부터는 기분 좋은 부분입니다: span은 코드를 더 범용적으로 만들어 주지만, 마법이나 복잡한 템플릿 없이 동작합니다. 연속 데이터를 가져와서 단순히 view를 만들면 됩니다.

C 배열 T arr[N]로부터

먼저 중요한 점: C 배열은 정직한 연속 블록입니다. std::span은 배열에서 편리하게 생성됩니다:

#include <iostream>
#include <span>

void print_first(std::span<const int> xs) {
    if (xs.empty()) return;
    std::cout << xs[0] << '\n';
}

int main() {
    int a[] = {5, 6, 7};
    print_first(a); // span is created implicitly
}

여기서 유용한 점은: print_first는 '배열'이라는 구체적 저장 방식에 대해 알 필요가 없다는 것입니다. 함수는 단지 '정수 시퀀스'를 받았다고만 알고 있습니다.

std::array<T, N>로부터

std::array는 '문명화된' 배열로서, 크기가 타입에 저장되고 메서드가 있으며 알고리즘과 잘 어울립니다. span에게도 좋은 소스입니다:

#include <array>
#include <iostream>
#include <span>

int main() {
    std::array<int, 4> a{1, 2, 3, 4};
    std::span<const int> s(a);

    std::cout << s.size() << '\n'; // 4
}

std::vector<T>로부터

벡터도 완벽하게 잘 맞습니다. 단 주의할 점은 벡터의 메모리가 성장 과정에서 재할당될 수 있다는 것입니다. 오늘은 그 사실만 인지해 두면 충분합니다; '언제 view가 깨지는가'는 다음 강의에서 더 자세히 다룹니다. 당분간은 span을 호출 주변의 단명 객체로 사용하세요.

#include <iostream>
#include <span>
#include <vector>

int sum(std::span<const int> xs) {
    int total = 0;
    for (int x : xs) total += x;
    return total;
}

int main() {
    std::vector<int> v{10, 20, 30};
    std::cout << sum(v) << '\n'; // 60
}

가독성 측면에서 아주 좋은 점은: 함수가 '벡터'가 아니라 '요소의 범위'를 받는다는 것입니다. 그래서 배열이나 std::array, std::vector 모두를 넘길 수 있습니다.

문자열용 std::span: 문자열은 연속적이지만 이것은 string_view와는 다릅니다

미묘한 부분으로 넘어갑니다: '문자열에 대한 span'입니다. std::string의 문자는 연속적이므로 std::span<const char>로 문자열을 문자 배열처럼 처리할 수 있습니다. 이는 문자열 관련 메서드의 특성 없이 '바이트'나 '문자' 표현으로 작업할 때 유용합니다.

하지만 역할을 혼동하지 마세요. std::string_view는 특히 문자열을 위한 view로, find, substr, remove_prefix 등이 편리합니다. 반면 std::span<const char>는 배열 요소로서의 문자에 대한 view입니다. 문자열 작업을 '이해'하지 못하지만, 범용적인 문자 범위로서 유용합니다.

예: 텍스트에서 숫자 문자의 개수를 세기:

#include <iostream>
#include <span>
#include <string>

int count_digits(std::span<const char> chars) {
    int c = 0;
    for (char ch : chars) {
        if (ch >= '0' && ch <= '9') ++c;
    }
    return c;
}

int main() {
    std::string s = "Room 101";
    std::cout << count_digits(std::span<const char>(s.data(), s.size())) << '\n'; // 3
}

왜 그냥 count_digits(s)라고 호출할 수 없을까요? std::string이 암묵적으로 span으로 변환된다는 보장이 없기 때문입니다. 여기서는 data()와 size()로 명시적으로 span을 만들고 있고, 이는 의도치 않은 view 생성의 가능성을 줄여줘서 오히려 안전합니다.

4. 일상적인 span 연산

이제 현실적인 수준으로 내려갑니다: 대부분의 경우 span에서 필요한 것은 아주 단순합니다. empty()를 확인하고, size()를 얻고, range-for로 순회하고, 가끔 인덱스로 요소를 가져오는 정도입니다. 본질적으로 이미 std::vector나 std::string에서 하던 것과 동일하지만, 이제 '타인의 데이터'에 대해서도 작동합니다.

'범위를 보기 좋게 출력'하는 짧은 예제:

#include <iostream>
#include <span>

void print(std::span<const int> xs) {
    std::cout << '[';
    for (std::size_t i = 0; i < xs.size(); ++i) {
        std::cout << xs[i] << (i + 1 == xs.size() ? "" : ", ");
    }
    std::cout << "]\n";
}

int main() {
    int a[] = {3, 1, 4};
    print(a); // [3, 1, 4]
}

주의할 점: span의 인덱싱은 배열이나 vector의 operator[]와 마찬가지로 경계 검사를 하지 않습니다. 따라서 인덱스가 외부에서 오거나 계산된 값이면 먼저 검사하는 것은 당신의 책임입니다.

subspan(): 복사 없는 부분 범위 '창'

이제 가장 재미있는 부분입니다: std::span은 요소를 복사하지 않고 '부분 범위'를 만들 수 있습니다. 이는 std::string_view::substr()와 비슷한 개념으로, 새로운 배열을 만들지 않고 기존 배열의 일부에 대한 새로운 view를 생성합니다.

실무에서 매우 자주 쓰입니다. 예를 들어 '첫 요소를 제외한 모든 요소'나 '처음 N개', '중간 부분' 등을 처리할 때, 새로운 std::vector로 복사하는 대신 subspan()을 사용합니다.

예: 가운데 두 요소를 얻기:

#include <iostream>
#include <span>

int main() {
    int a[] = {10, 20, 30, 40};

    std::span<int> all(a);
    std::span<int> mid = all.subspan(1, 2); // {20, 30}

    std::cout << mid[0] << ' ' << mid[1] << '\n'; // 20 30
}

중요한 점: subspan()은 소유권을 변경하거나 데이터를 이동하지 않습니다. 단지 'offset 위치에서 count만큼 보라'고 말할 뿐입니다. 잘못된 경계를 지정하면 논리적 오류(때로는 UB)가 발생할 수 있으니 주의하세요.

5. 실전 예제: 'MiniStats'에 span 추가하기

std::span이 단지 이론에 머물지 않도록, 강의에서 점차 발전시키는 작은 콘솔 앱 조각에 적용해 보겠습니다. 'MiniStats' 모듈은 수열의 합과 평균을 계산하며, 데이터 소스는 배열이든 std::array든 std::vector든 상관없습니다.

span<const int>으로 합과 평균 계산

두 개의 최대한 단순하고 읽기 쉬운 함수를 만듭니다. 복잡한 데이터 모델은 다루지 않고, 숫자와 출력에만 집중합니다.

#include <iostream>
#include <span>

int sum(std::span<const int> xs) {
    int total = 0;
    for (int x : xs) total += x;
    return total;
}

double average(std::span<const int> xs) {
    if (xs.empty()) return 0.0;
    return static_cast<double>(sum(xs)) / xs.size();
}

좋은 점은 average가 어떤 연속적인 정수 시퀀스든 받아들일 수 있다는 것입니다: vector일 필요가 없습니다. 또한 빈 범위를 정직하게 처리합니다.

배열과 vector 양쪽에서 함수 호출하기

이제 '이게 왜 유용한가'를 보여줍니다:

#include <iostream>
#include <span>
#include <vector>

int sum(std::span<const int> xs);
double average(std::span<const int> xs);

int main() {
    int a[] = {1, 2, 3};
    std::vector<int> v{10, 20, 30, 40};

    std::cout << sum(a) << '\n';        // 6
    std::cout << average(v) << '\n';    // 25
}

포인트는 동일한 함수들이 여러 데이터 소스에서 동작한다는 것입니다. 함수 시그니처는 거짓말을 하지 않습니다: sum과 average는 숫자를 소유하지도 않고 변경하지도 않습니다 — 단지 읽을 뿐입니다.

subspan()로 데이터의 일부만 처리하기

실제 프로그램에서 매우 흔한 시나리오: '모든 숫자가 아니라 꼬리나 접두사에 대해서 통계를 내라'. 예를 보여봅니다:

#include <iostream>
#include <span>
#include <vector>

int sum(std::span<const int> xs);

int main() {
    std::vector<int> v{5, 100, 100, 100};

    std::span<const int> all(v);
    std::span<const int> tail = all.subspan(1); // everything except the first

    std::cout << sum(tail) << '\n'; // 300
}

여기서는 {100, 100, 100}을 새 컨테이너로 복사하지 않았습니다. 필요한 범위에 대한 '창'만 만들었을 뿐입니다.

6. std::span 사용 시 흔한 실수

실수 1: span을 컨테이너로 생각하고 '요소 추가'를 시도하는 것.
std::span은 소유자가 아니므로 push_back이 없고 resize도 없습니다. '여기에 요소 하나 더 추가하고 싶다'는 생각이 들면 소유하는 컨테이너(std::vector)가 필요하거나 소유권을 변경해야 한다는 신호입니다.

실수 2: 함수가 아무 것도 변경하지 않는데 std::span<T>를 받는 것.
이 코드는 컴파일되지만 인터페이스가 더 위험해집니다: 호출자는 함수가 데이터를 변경할 수 있다고 생각할 수 있습니다. 기본 습관은 가능한 한 std::span<const T>를 받는 것이고, 요소 변경이 계약의 일부일 때만 std::span<T>를 사용하세요.

실수 3: span을 만들고 '어딘가 오래' 보관하면서 소유자를 생각하지 않는 것.
span은 데이터의 수명을 연장하지 않습니다. 소유자가 사라지면(예: 함수 내부의 로컬 std::vector) span은 무의미한 주소만 가진 종이 쪼가리가 됩니다. 또한 소유자가 '이사'할 수 있다는 점(특히 std::vector의 성장 시)이 불쾌한 결과를 만들 수 있습니다. 규칙으로 기억하세요: span은 사용 지점 근처에서 단기간만 살아야 합니다.

실수 4: operator[]가 경계 검사를 한다고 잊는 것.
span의 인덱싱은 배열과 vector의 []와 동일하게 안전장치가 없습니다. 인덱스가 계산되거나 외부 입력이라면 먼저 i  <  xs.size()를 확인하세요. 그렇지 않으면 쓰레기, 충돌, 또는(가장 '재미있는') '가끔 동작함'을 얻게 됩니다.

실수 5: std::string에 대해 span을 만들고 '문자열' 연산을 기대하는 것.
std::span<const char>는 char의 범위이지 문자열 자체가 아닙니다. find, starts_with 같은 문자열 연산을 기대한다면 보통은 std::string_view가 더 적절합니다. 반대로 바이트/문자 배열 관점이라면 span이 맞습니다.

1
과제
C++ SELF, 레벨 18, 레슨 2
잠금
장바구니 합계
장바구니 합계
1
과제
C++ SELF, 레벨 18, 레슨 2
잠금
구간 부호 반전
구간 부호 반전
1
과제
C++ SELF, 레벨 18, 레슨 2
잠금
처음 요소들의 평균
처음 요소들의 평균
1
과제
C++ SELF, 레벨 18, 레슨 2
잠금
문자 분석
문자 분석
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION