[모던 C++] std::span, 왜 쓰는 걸까?

std::span

std::span 이란

C++의 데이터 컨테이너를 메모리 상의 저장 방식의 차이로 나눠보자면, 크게 연속적인 컨테이너(Sequential Type)과 비연속적인 컨테이너(Non sequential Type)로 나눌 수 있습니다. 연속적인 컨테이너는 C-스타일 배열, std::array, std::vector, std::string 등이 그 예시입니다. 반면 비연속적인 컨테이너로는 std::list, std::map, std::unordered_map, std::set, std::unordered_set, std::stack 등이 있습니다. 이러한 컨테이너는 주로 내부적으로 포인터를 통해 연결되어 있으며, 그렇기 때문에 메모리 구조상에서 서로 떨어져 있습니다.

std::span 연속적인 데이터 컨테이너를 참조하는 STL입니다. C++17부터 도입됐습니다.

std::span 은 컴파일 단계에서 크기가 결정된 경우 정적 길이를 가지며, 실행 시간에 그 크기가 결정된다면 동적 길이(dynamic_extent)를 가집니다.

std::span 의 사용

std::span은 다음과 같이 사용할 수 있습니다.

template <typename T>
void print(const std::span<T> &span) {
    for (const auto &i : span) {
        std::cout << i << " ";
    }
    std::cout << std::endl;
}

int main(void) {
    std::vector<int> vecNums{1,2,3};
    std::array<int, 3> arrStrs{4,5,6};
    int arrNums[] = {7,8,9};
    std::string str = "Hello World!";

    print(vecNums);
    print(arrStrs);
    print(arrNums);
    print(str);

    return 0;
}

출력:

1 2 3 
4 5 6 
7 8 9 
H e l l o   W o r l d ! 

std::span, 왜 사용하는걸까?

std::span은 얼핏 보기에 왜 사용하는 지 그 이유를 알기 어렵습니다. 하나의 함수에 벡터, 스트링, 배열 등을 동시에 담을 수 있게 하려는 것일까요? 그것도 맞는 말이지만, 이미 C++에는 템플릿이라는 강력한 기능이 있고, template concept라는 아주 강력한 기능에 힘을 얻어 C++20부터 더 안전한 템플릿의 사용이 가능해졌습니다. 하지만 std::span은 다음과 같은 주요한 기능이 있습니다.

  • 안전성(Safety): std::span은 범위 확인을 제공하고 버퍼 오버플로우를 방지합니다. 즉 일반 C-array나 포인터를 사용하는 것보다 더 안전합니다.
  • 쉬운 사용성(Easy to use): std::span은 배열 등 여러 연속적인 메모리 컨테이너를 다루는데 사용하기 편리한 메소드들과 인터페이스를 제공합니다.
  • 상호 운용성(Interoperability): std::span은 여러 연속적인 메모리 컨테이너와 배열과 함께 사용될 수 있습니다. 그렇기 때문에 여러 STL의 알고리즘들과 함께 사용될 수 있습니다.
  • 성능(Performance): std::span은 일반 어레이를 사용하는 것보다 살짝 더 빠르고 안전하게 메모리 블럭을 참조할 수 있게 설계됐습니다.

각각의 예시들에 대해서 간단히 설명해보죠.

안전성(Safety)

std::span은 위에 예시에서 범위 기반 for-loop에서 알 수 있듯이 범위를 제공합니다. 그 size() 메소드를 제공하기에 그 크기를 알 수 있고, C++20부터 begin(), end()와 같은 iterator들도 제공합니다.

따라서 다음과 같은 C 스타일 배열이나 포인터의 사용을 개선할 수 있습니다.

void print(const int* arr, const int size)
{
    for (int i = 0; i < size; i++) {
        std::cout << arr[i] << " ";
    }
    std::cout << std::endl;
}

int main(void)
{
    int arr[] = { 1, 2, 3, 4, 5 };

    print(arr, sizeof(arr) / sizeof(int));

    return 0;
}

위 함수는 반드시 배열의 크기를 인자로 받아야 했지만, std::span을 사용한다면 그럴 필요가 없습니다.

쉬운 사용성(Easy to use)

std::span은 여러 편리한 인터페이스를 제공합니다. 몇 가지를 소개하자면

  • size: span하는 대상의 사이즈를 반환합니다.
  • begin, end: span하는 메모리의 각각 첫 번째, 마지막 + 1 번 째의 iterator를 반환합니다. (C++20)
  • rbegin, rend: begin, end과 반대(reverse)로 각각 마지막, 첫 번째 - 1 번 째의 iterator를 반환합니다. (C++20)
  • front, back: 각각 첫 번째, 마지막 번 째 데이터를 참조합니다. front()*begin()과 결과가 똑같습니다. (C++20)
  • subspan: span의 하위 범위를 가져옵니다.
    subspan의 사용 예시는 다음과 같습니다(출처: Microsoft Learn | span ):
#include <algorithm>
#include <cstdio>
#include <numeric>
#include <ranges>
#include <span>

void display(std::span<const char> abc)
{
    const auto columns{ 20U };
    const auto rows{ abc.size() - columns + 1 };

    for (auto offset{ 0U }; offset < rows; ++offset) {
        std::ranges::for_each(
            abc.subspan(offset, columns), 
            std::putchar
        );
        std::putchar('\n');
    }
}

int main()
{
    char abc[26];
    std::iota(std::begin(abc), std::end(abc), 'A');
    display(abc);
}

출력:

mySpan.subspan(1,2): 12  
mySpan.subspan<1,2>: 12  
mySpan.subspan<1>: 12

또는 다음과 같이 쓸 수도 있습니다(출처: std::span cppreference)

#include  
#include  
#include  
#include  
#include

void display(std::span abc)  
{  
const auto columns{ 20U };  
const auto rows{ abc.size() - columns + 1 };

for (auto offset{ 0U }; offset < rows; ++offset) {
    std::ranges::for_each(
        abc.subspan(offset, columns), 
        std::putchar
    );
    std::putchar('\n');
}

}

int main()  
{  
char abc[26];  
std::iota(std::begin(abc), std::end(abc), 'A');  
display(abc);  
}

결과:

ABCDEFGHIJKLMNOPQRST  
BCDEFGHIJKLMNOPQRSTU  
CDEFGHIJKLMNOPQRSTUV  
DEFGHIJKLMNOPQRSTUVW  
EFGHIJKLMNOPQRSTUVWX  
FGHIJKLMNOPQRSTUVWXY  
GHIJKLMNOPQRSTUVWXYZ
  • 그 외에 C++20부터 dynamic_extent를 통해 동적 길이를 가지는 span을 생성할 수 있습니다.
  • 또한 C++23부터 std::mdspan을 통해 다차원 배열에 대한 span을 생성할 수 있습니다.

더 많은 기능들과 예시를 살펴보고 싶다면, std::span cppreferenceMicrosoft Learn을 참고하십시오. 공식 문서를 읽는 것은 언제나 큰 도움이 됩니다.

상호 운용성(Interoperability)

std::span은 여러 연속적인 메모리 컨테이너와 함께 사용될 수 있기에, 코드의 재사용성을 줄이는 한편 여러 STL의 알고리즘들 혹은 함수들과 같이 사용될 수 있습니다.

심지어 직접 제작한 컨테이너에도 std::span을 적용할 수 있는데, 다음 조건만 만족하면 됩니다.

  1. 데이터가 연속된 메모리 공간에 저장될 것
  2. 데이터 포인터와 길이를 가질 것

예시:

template <typename T>
class MyContainer {
public:
    MyContainer(std::vector<T> data) : data_(data) {}

    T* data() { return data_.data(); }
    std::size_t size() { return data_.size(); }

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

int main() {
    MyContainer<int> c = {{1, 2, 3, 4, 5}};
    std::span<int> s(c.data(), c.size());

    for (const auto &i : s) {
        std::cout << i << " ";
    }
    std::cout << std::endl;

    return 0;
}

출력:

1 2 3 4 5

결론

std::span은 매우 유용한 컨테이너 도구입니다. C++17부터 소개되었고, 소개될 때도 그렇게까지 강한 파급력은 없었지만, 몇몇 상황에서는 유용하게 쓰일 수 있는 기능입니다.