C++20 std::span: 배열·vector·array를 하나의 비소유 뷰로 받기

이 글의 핵심

함수 인자로 T*와 size_t를 따로 받으면 길이를 잘못 넘기는 실수가 쉽게 생깁니다. std::span은 이를 하나로 묶은 가벼운 뷰지만 데이터를 소유하지 않으므로 원본보다 오래 살면 안 됩니다. const span과 span<const T>의 차이, 경계 검사, 슬라이딩 윈도우·버퍼 래퍼 패턴까지 기초 수준에서 정리합니다.

span이란?

std::span 은 C++20에서 도입된 연속 메모리 영역에 대한 경량 뷰입니다. 배열, 벡터, 또는 연속된 메모리를 가리키는 포인터와 크기를 함께 제공하여, 안전하고 통합된 인터페이스를 제공합니다.

#include <span>

void process(std::span<int> data) {
    for (int x : data) {
        std::cout << x << " ";
    }
}

int arr[] = {1, 2, 3};
std::vector<int> vec = {4, 5, 6};

process(arr);  // 배열
process(vec);  // 벡터

왜 필요한가?:

  • 통합 인터페이스: 배열, 벡터, C 배열을 하나의 타입으로 처리
  • 안전성: 크기 정보를 포함하여 경계 검사 가능
  • 성능: 복사 없이 참조만 (포인터 + 크기)
  • 간결성: 포인터와 크기를 별도로 전달할 필요 없음
// ❌ 전통적 방식: 포인터 + 크기 (불편, 오류 가능)
void process(int* data, size_t size) {
    for (size_t i = 0; i < size; ++i) {
        std::cout << data[i] << " ";
    }
}

int arr[] = {1, 2, 3};
process(arr, 3);  // 크기를 수동으로 전달

// ✅ span: 통합되고 안전함
void process(std::span<int> data) {
    for (int x : data) {  // 범위 기반 for 사용 가능
        std::cout << x << " ";
    }
}

process(arr);  // 크기 자동 추론

span의 특성:

  • 비소유(Non-owning): 메모리를 소유하지 않고 참조만 함
  • 경량: 동적 크기 span은 포인터와 크기만 저장 (64비트에서 보통 16바이트), 고정 크기 span은 크기가 타입에 들어 있어 포인터 하나(8바이트)
  • 복사 가능: 복사 비용이 매우 낮음
  • 뷰: 원본 데이터를 수정 가능 (const span은 읽기 전용)

span이 “뷰”라는 점이 실무에서 가장 헷갈리는 부분입니다. std::string_view처럼 읽기 전용만 지원하는 것이 아니라, std::span<T>(T가 const가 아닌 경우)는 원본 데이터에 대한 쓰기 접근도 그대로 허용합니다. 즉 span을 통해 원소 값을 바꾸면 그 변경은 원본 컨테이너에도 그대로 반영됩니다. 이는 span이 데이터를 복사하지 않고 순수하게 포인터와 크기만 들고 있기 때문인데, 아래 예제처럼 벡터로부터 만든 span의 원소를 수정하면 원본 벡터 값도 함께 바뀌는 것을 확인할 수 있습니다.

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

std::span<int> sp{vec};
sp[0] = 10;  // 원본 vec 수정

std::cout << vec[0];  // 10 (수정됨)

span의 구조:

실제 표준 라이브러리 구현은 훨씬 복잡하지만(고정 크기 특수화, 반복자 카테고리 등), 핵심 아이디어는 아주 단순합니다. span은 그저 데이터를 가리키는 포인터 하나와 원소 개수 하나, 이 두 값만 멤버로 가지고 있는 얇은 래퍼입니다. 아래는 그 핵심 아이디어를 단순화한 개념적 구현으로, span이 왜 “복사 비용이 거의 0에 가깝다”고 하는지, 그리고 왜 원본 메모리의 수명을 별도로 관리해야 하는지를 코드 수준에서 이해하는 데 도움이 됩니다.

// 개념적 구현
template<typename T>
class span {
    T* data_;
    size_t size_;
    
public:
    span(T* data, size_t size) : data_(data), size_(size) {}
    
    size_t size() const { return size_; }
    T* data() const { return data_; }
    T& operator[](size_t index) const { return data_[index]; }
    
    // 반복자
    T* begin() const { return data_; }
    T* end() const { return data_ + size_; }
};

기본 사용

span을 만드는 가장 흔한 방법은 컨테이너 전체를 그대로 넘기는 것이지만, 포인터와 개수를 직접 지정해 일부 구간만 가리키는 span을 만들 수도 있습니다. 아래 예제에서 sp2는 v의 두 번째 원소부터 세 개만 가리키는데, 이때도 실제 데이터는 전혀 복사되지 않고 v가 이미 가진 메모리를 그대로 참조합니다. size()는 이렇게 만들어진 뷰가 몇 개의 원소를 포함하는지 알려줍니다.

#include <span>
#include <vector>

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

// 전체 span
std::span<int> sp1{v};

// 부분 span
std::span<int> sp2{v.data() + 1, 3};  // {2, 3, 4}

// 크기
std::cout << sp1.size() << std::endl;  // 5

실전 예시

아래 네 가지 예시는 함수 인터페이스 통합, 데이터 슬라이싱, 안전한 경계 검사, 2차원 데이터 표현이라는 span의 대표적인 활용 사례를 보여줍니다.

함수 매개변수

span이 등장하기 전에는 배열, vector, std::array를 모두 받는 함수를 만들려면 오버로드를 여러 개 만들거나 템플릿으로 일반화해야 했습니다. std::span<const int>를 매개변수 타입으로 쓰면 이 세 가지 컨테이너 모두 암시적으로 span으로 변환되기 때문에, 함수를 딱 하나만 정의해도 모든 연속 메모리 컨테이너를 받아들일 수 있습니다. 읽기만 하는 함수라면 const int처럼 요소 타입에 const를 붙여 실수로 데이터를 수정하는 일을 컴파일 타임에 막는 것이 좋은 습관입니다.

템플릿으로 template<class C> void printData(const C&)를 만들어도 같은 일을 할 수 있지만, 그러면 함수 본문이 헤더에 있어야 하고 컨테이너 타입마다 인스턴스가 따로 생깁니다. span을 받는 함수는 평범한 비템플릿 함수라서 .cpp 파일에 구현을 숨기고 한 번만 컴파일할 수 있다는 점이 큰 차이입니다. 반대로 std::list나 std::deque처럼 메모리가 연속되지 않은 컨테이너는 span으로 받을 수 없으므로, 그런 입력까지 받아야 한다면 C++20 ranges 기반 템플릿이 맞습니다.

const int 요소 span의 또 다른 이점은 변환 방향입니다. std::vector<int>는 span<int>와 span<const int> 모두로 변환되지만, const std::vector<int>&나 임시 vector는 span<const int>로만 변환됩니다. 매개변수를 span<int>로 선언해 두면 호출하는 쪽에서 const 객체를 넘길 때 no matching function for call 오류를 만나게 됩니다.

중괄호 목록을 바로 넘기는 printData({1, 2, 3})은 C++20·C++23에서는 컴파일되지 않는다는 점도 알아 두면 좋습니다. span에 initializer_list 생성자가 없기 때문이며, C++26에서야 span<const T>에 한해 추가되었습니다.

#include <span>
#include <vector>
#include <array>

// 통합 인터페이스
void printData(std::span<const int> data) {
    for (int x : data) {
        std::cout << x << " ";
    }
    std::cout << std::endl;
}

int main() {
    int arr[] = {1, 2, 3};
    std::vector<int> vec = {4, 5, 6};
    std::array<int, 3> stdArr = {7, 8, 9};
    
    printData(arr);     // 1 2 3
    printData(vec);     // 4 5 6
    printData(stdArr);  // 7 8 9
}

부분 범위

큰 데이터의 일부만 다뤄야 할 때, span은 새로운 컨테이너를 만들어 값을 복사하는 대신 원본을 가리키는 또 다른 뷰를 만드는 방식으로 처리합니다. subspan(offset, count)는 시작 위치와 길이를 지정해 부분 범위를 얻는 가장 표준적인 방법이고, 포인터 연산(v.data() + 5)으로 직접 만드는 방식도 동일한 결과를 냅니다. 이런 슬라이싱 연산이 전혀 메모리를 할당하거나 복사하지 않는다는 점이 span을 대용량 데이터 처리에 적합하게 만드는 핵심 이유입니다.

주의할 점은 subspan, first, last가 범위를 검사하지 않는다는 것입니다. sp.subspan(8, 5)처럼 원소가 10개인 span에서 끝을 넘는 구간을 요청하면 std::out_of_range 같은 예외가 나는 것이 아니라 미정의 동작입니다. std::string::substr이 범위를 넘으면 예외를 던지는 것과 대조적이라, 문자열 API에 익숙한 사람이 가장 많이 틀리는 부분입니다. GCC/libstdc++는 -D_GLIBCXX_ASSERTIONS, MSVC는 디버그 빌드에서 이런 사전 조건 위반을 assertion으로 잡아 주므로 테스트 빌드에서는 켜 두는 편이 좋습니다.

#include <span>

std::vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};

// 처음 5개
std::span<int> first5{v.data(), 5};

// 마지막 5개
std::span<int> last5{v.data() + 5, 5};

// subspan
std::span<int> sp{v};
auto middle = sp.subspan(3, 4);  // {4, 5, 6, 7}

경계 검사

span의 operator[] 자체는 vector와 마찬가지로 범위를 검사하지 않지만, size()가 항상 정확한 원소 개수를 알려주기 때문에 호출하는 쪽에서 손쉽게 방어 코드를 작성할 수 있습니다. 이는 순수 포인터와 별도의 길이 변수를 따로 전달하는 C 스타일 API에 비해 큰 장점인데, 포인터만 받는 함수는 호출자가 길이를 잘못 넘겨도 알아챌 방법이 없지만, span은 포인터와 길이가 하나의 값으로 항상 함께 붙어 다니므로 이런 실수 자체가 원천적으로 줄어듭니다.

#include <span>

void safeAccess(std::span<int> data, size_t index) {
    // 경계 검사
    if (index < data.size()) {
        std::cout << data[index] << std::endl;
    } else {
        std::cout << "범위 초과" << std::endl;
    }
}

int main() {
    std::vector<int> v = {1, 2, 3};
    safeAccess(v, 1);   // 2
    safeAccess(v, 10);  // 범위 초과
}

2D 배열

span은 항상 1차원 연속 메모리만 표현하므로, 2차원 데이터를 다루려면 행과 열의 개수를 별도 인자로 함께 전달하고 인덱스 계산(row * cols + col)을 직접 해줘야 합니다. 이 방식은 vector<vector<int>>와 달리 메모리가 하나의 연속된 블록으로 유지되므로 캐시 지역성이 좋고, span 하나로 그 블록 전체를 안전하게 감쌀 수 있다는 이점이 있습니다. vector<vector<int>>는 행마다 별도 힙 블록이라 전체를 span 하나로 감쌀 수 없고, 행 단위(std::span<int>{m[i]})로만 뷰를 만들 수 있습니다.

단점은 rows * cols가 data.size()와 일치하는지를 함수가 믿어야 한다는 것입니다. 호출자가 행과 열을 바꿔 넘겨도 컴파일러는 알 수 없으므로, 함수 시작 부분에서 assert(data.size() == rows * cols)를 두는 편이 안전합니다. C++23에는 이런 다차원 인덱싱을 타입으로 표현하는 std::mdspan이 추가되었으니, 컴파일러가 지원한다면 수동 인덱스 계산 대신 고려해 볼 만합니다.

#include <span>

void processMatrix(std::span<int> data, size_t rows, size_t cols) {
    for (size_t i = 0; i < rows; ++i) {
        for (size_t j = 0; j < cols; ++j) {
            std::cout << data[i * cols + j] << " ";
        }
        std::cout << std::endl;
    }
}

int main() {
    std::vector<int> matrix = {
        1, 2, 3,
        4, 5, 6,
        7, 8, 9
    };
    
    processMatrix(matrix, 3, 3);
}

span 연산

span은 크기 조회, 원소 접근, 부분 범위 추출을 위한 멤버 함수를 두루 제공합니다. size()가 원소 개수를 반환하는 반면 size_bytes()는 실제 바이트 수(size() * sizeof(T))를 반환해 저수준 메모리 연산(예: memcpy)에 바로 활용할 수 있습니다. first(n)과 last(n)은 각각 앞·뒤에서 n개를 잘라낸 새 span을, subspan(offset, count)는 임의 위치에서 시작하는 부분 범위를 반환하며, 이 함수들은 모두 원본 데이터를 복사하지 않고 새로운 뷰만 만들어냅니다.

std::span<int> sp{v};

// 크기
auto size = sp.size();
auto bytes = sp.size_bytes();

// 접근
int first = sp.front();
int last = sp.back();
int* ptr = sp.data();

// 부분 범위
auto sub1 = sp.first(3);      // 처음 3개
auto sub2 = sp.last(3);       // 마지막 3개
auto sub3 = sp.subspan(2, 3); // [2]부터 3개

자주 발생하는 문제

수명

span은 메모리를 소유하지 않기 때문에, 가리키는 원본 데이터보다 span이 더 오래 살아있으면 안 됩니다. getDanglingSpan처럼 함수 안에서 지역 변수로 만든 vector를 가리키는 span을 반환하면, 함수가 끝나는 순간 그 vector가 소멸되면서 반환된 span은 이미 해제된 메모리를 가리키는 댕글링 상태가 됩니다. 이런 문제를 피하려면 span이 가리킬 원본 컨테이너를 참조 매개변수로 받아, 그 수명이 호출자 쪽에서 관리되도록 설계해야 합니다.

// ❌ 댕글링
std::span<int> getDanglingSpan() {
    std::vector<int> v = {1, 2, 3};
    return std::span{v};
    // v 소멸
}

// ✅ 참조 명확화
std::span<int> getSpan(std::vector<int>& v) {
    return std::span{v};
}

실무에서 더 자주 만나는 댕글링은 위처럼 눈에 띄는 경우보다 재할당입니다. std::span<int> sp{vec};를 만든 뒤 vec.push_back(x)를 호출하면, 용량이 부족할 때 vector가 새 버퍼를 할당하고 원소를 옮긴 다음 옛 버퍼를 해제합니다. sp는 여전히 옛 버퍼를 가리키므로 이후 접근은 해제된 메모리 읽기입니다. 이 버그는 용량이 남아 있는 동안에는 멀쩡히 동작하다가 원소 수가 특정 경계를 넘을 때만 터지기 때문에 테스트에서 놓치기 쉽습니다. 저는 span을 클래스 멤버로 저장하는 코드를 리뷰할 때마다 “원본 컨테이너가 이 객체보다 오래 살고, 그동안 크기가 바뀌지 않는가”를 먼저 확인합니다. 둘 중 하나라도 보장할 수 없다면 span 대신 인덱스 범위(offset, length)를 저장하는 편이 안전합니다. AddressSanitizer(-fsanitize=address)를 켜면 이런 접근을 heap-use-after-free로 바로 보고해 줍니다.

임시 객체도 같은 문제를 만듭니다. std::span<const int> sp = makeVector();는 컴파일되지만, 임시 vector가 그 문장 끝에서 소멸하므로 sp는 곧바로 댕글링입니다. 함수 인자로 임시를 넘기는 printData(makeVector())는 임시가 함수 호출이 끝날 때까지 살아 있으므로 안전합니다. 차이는 “span이 문장 끝을 넘어 살아남는가”입니다.

const

span<const T>와 const span<T>는 이름이 비슷해 보이지만 완전히 다른 의미입니다. span<const T>는 가리키는 데이터가 읽기 전용이라는 뜻으로, 함수 매개변수에서 “이 데이터를 수정하지 않겠다”는 의도를 명확히 표현할 때 사용합니다. 이 구분을 헷갈리면 원치 않게 함수 내부에서 원본 데이터를 실수로 수정해버리는 버그, 혹은 반대로 수정이 필요한 곳에 잘못된 타입을 써서 컴파일 에러를 만나는 상황이 생길 수 있습니다.

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

// 읽기 전용
std::span<const int> sp{v};

// ❌ 수정 불가
// sp[0] = 10;  // 에러

// ✅ 수정 가능
std::span<int> sp2{v};
sp2[0] = 10;

크기

span은 두 번째 템플릿 인자로 크기를 지정할 수 있는데, 생략하면 std::dynamic_extent가 적용되어 런타임에 크기가 결정되는 동적 span이 됩니다. 반면 std::span<int, 3>처럼 정수를 명시하면 컴파일 타임에 크기가 고정된 span이 되어, 원본 배열의 크기와 일치하지 않으면 컴파일 에러로 즉시 잡아낼 수 있습니다. 크기가 컴파일 타임에 이미 알려진 상황(고정 크기 배열을 다루는 함수 등)이라면 고정 크기 span을 쓰는 것이 런타임 검사 없이도 안전성을 얻는 방법입니다.

동적 span이나 vector에서 고정 크기 span을 만들 때는 생성자가 explicit이라 std::span<int, 3> s = vec;처럼 암시적 변환은 되지 않고 std::span<int, 3>{vec.data(), 3}처럼 명시해야 합니다. 이때 실제 크기가 3이 아니면 역시 미정의 동작이므로, 컴파일 타임 검증의 이점은 C 배열과 std::array처럼 크기가 타입에 들어 있는 원본에서만 얻을 수 있습니다. 동적 span에서 고정 크기 조각을 얻으려면 sp.first<3>(), sp.subspan<2, 3>()처럼 템플릿 인자 버전을 쓰면 됩니다.

// 고정 크기 span
std::span<int, 3> fixedSpan{arr};

// 동적 크기 span
std::span<int> dynamicSpan{vec};

// ❌ 크기 불일치
// std::span<int, 5> sp{arr};  // arr 크기가 3이면 에러

포인터 변환

레거시 C API나 저수준 버퍼 처리 코드는 여전히 포인터와 크기를 별도 인자로 받는 경우가 많습니다. 이런 인터페이스를 감쌀 때 span을 중간 계층으로 두면, C API가 반환한 원시 포인터와 크기를 즉시 span으로 묶어 이후 코드에서는 안전한 반복자 기반 접근(범위 기반 for, size() 검사 등)을 쓸 수 있습니다. 이렇게 하면 포인터 산술을 직접 하는 코드의 범위를 최소화해서 버그가 발생할 여지를 줄일 수 있습니다.

int* ptr = getData();
size_t size = getSize();

// ✅ span으로 래핑
std::span<int> sp{ptr, size};

// 안전한 접근
for (int x : sp) {
    std::cout << x << " ";
}

span vs 포인터

포인터+크기 조합과 span은 표현할 수 있는 정보 자체는 동일하지만, 그 정보를 다루는 방식에 결정적인 차이가 있습니다. 포인터 방식은 두 값(data, size)이 별도의 매개변수로 분리되어 있어, 호출할 때 순서를 바꾸거나 잘못된 크기를 넘기는 실수가 컴파일러에게 전혀 감지되지 않습니다. span은 이 두 값을 하나의 타입 안에 묶어두기 때문에 애초에 그런 실수 자체가 발생할 여지가 줄어들고, 범위 기반 for 같은 현대적인 C++ 문법도 그대로 사용할 수 있습니다.

// ❌ 포인터 (크기 정보 없음)
void process(int* data, size_t size) {
    for (size_t i = 0; i < size; ++i) {
        std::cout << data[i] << " ";
    }
}

// ✅ span (크기 포함)
void process(std::span<int> data) {
    for (int x : data) {
        std::cout << x << " ";
    }
}

실무 패턴

아래 세 가지 패턴은 읽기 전용 계산, 슬라이딩 윈도우 알고리즘, 저수준 버퍼 래핑처럼 span이 실무에서 자주 쓰이는 방식을 보여줍니다.

읽기 전용 뷰

calculateAverage처럼 데이터를 읽기만 하고 수정하지 않는 함수는 매개변수를 std::span<const double>로 선언하는 것이 좋은 관례입니다. 이렇게 하면 호출자가 어떤 컨테이너(벡터, 배열, 다른 span)를 넘기든 자동으로 받아들일 수 있으면서도, 함수 시그니처만 보고도 “이 함수는 데이터를 바꾸지 않는다”는 계약을 명확히 전달할 수 있습니다.

class DataProcessor {
public:
    // const span: 읽기 전용
    double calculateAverage(std::span<const double> data) const {
        if (data.empty()) return 0.0;
        
        double sum = 0.0;
        for (double value : data) {
            sum += value;
        }
        return sum / data.size();
    }
};

// 사용
std::vector<double> values = {1.0, 2.0, 3.0, 4.0, 5.0};
DataProcessor processor;
double avg = processor.calculateAverage(values);

슬라이딩 윈도우

시계열 데이터 분석이나 이동 평균 계산처럼, 데이터를 일정 크기의 겹치는 구간(윈도우)으로 나눠서 처리해야 하는 경우가 많습니다. subspan을 반복문 안에서 호출하면 매 반복마다 새로운 메모리 할당 없이 원본 데이터의 다른 부분을 가리키는 가벼운 뷰만 얻을 수 있어, 윈도우마다 벡터를 복사해서 만드는 방식보다 훨씬 효율적입니다.

template<typename T>
void processWindows(std::span<T> data, size_t windowSize) {
    if (data.size() < windowSize) return;
    
    for (size_t i = 0; i <= data.size() - windowSize; ++i) {
        auto window = data.subspan(i, windowSize);
        
        // 윈도우 처리
        std::cout << "Window [" << i << "]: ";
        for (const auto& value : window) {
            std::cout << value << " ";
        }
        std::cout << '\n';
    }
}

// 사용
std::vector<int> data = {1, 2, 3, 4, 5, 6, 7, 8};
processWindows(std::span{data}, 3);

버퍼 래퍼

네트워크 패킷이나 파일 I/O처럼 바이트 버퍼를 다루는 클래스는 내부 저장소(vector<uint8_t>)를 캡슐화하면서도, 외부에는 span을 통해 안전한 접근 창구를 열어주는 방식으로 설계하면 편리합니다. const 오버로드를 함께 제공하면 const Buffer에서는 자동으로 읽기 전용 span<const uint8_t>가, 일반 Buffer에서는 쓰기 가능한 span<uint8_t>가 반환되어 C++의 const-정확성을 자연스럽게 유지할 수 있습니다.

class Buffer {
    std::vector<uint8_t> data_;
    
public:
    Buffer(size_t size) : data_(size) {}
    
    // 전체 버퍼 뷰
    std::span<uint8_t> asSpan() {
        return std::span{data_};
    }
    
    // 읽기 전용 뷰
    std::span<const uint8_t> asSpan() const {
        return std::span{data_};
    }
    
    // 부분 뷰
    std::span<uint8_t> slice(size_t offset, size_t length) {
        return std::span{data_}.subspan(offset, length);
    }
};

// 사용
Buffer buffer(1024);
auto view = buffer.asSpan();
view[0] = 0xFF;

자주 헷갈리는 질문

Q1: span은 데이터를 복사하나요?

A: 아니요. span은 비소유 뷰로, 원본 데이터를 참조만 합니다. 복사 비용이 낮습니다 (포인터 + 크기만 복사).

std::vector<int> vec(1000000);  // 큰 벡터

// span 생성: 복사 없음 (포인터 + 크기만)
std::span<int> sp{vec};

// span 복사: 매우 빠름 (16바이트만 복사)
std::span<int> sp2 = sp;

Q2: const span과 span의 차이는?

A:

  • const std::span: span 자체가 const (다른 메모리를 가리킬 수 없음)
  • std::span: 가리키는 데이터가 const (데이터 수정 불가)
std::vector<int> vec = {1, 2, 3};

// span<const int>: 데이터 읽기 전용
std::span<const int> sp1{vec};
// sp1[0] = 10;  // 에러: 데이터 수정 불가
sp1 = std::span<const int>{};  // OK: span 자체는 수정 가능

// const span<int>: span 자체가 const
const std::span<int> sp2{vec};
sp2[0] = 10;  // OK: 데이터 수정 가능
// sp2 = std::span<int>{};  // 에러: span 자체 수정 불가

Q3: span을 포인터로 변환할 수 있나요?

A: 가능합니다. data() 메서드로 포인터를 얻을 수 있습니다.

std::span<int> sp{vec};

// 포인터 얻기
int* ptr = sp.data();

// 레거시 API 호출
legacyFunction(ptr, sp.size());

Q4: 언제 span을 쓰지 말아야 하나요?

A: 세 가지 경우입니다. 첫째, 함수가 데이터를 보관해야 한다면(멤버로 저장, 다른 스레드로 넘김) 소유 타입(vector, unique_ptr<T[]>)을 받는 편이 수명 문제를 원천적으로 없앱니다. 둘째, 원소를 추가·삭제해야 한다면 span은 크기를 바꿀 수 없으므로 컨테이너 참조를 받아야 합니다. 셋째, 문자열이라면 std::string_view가 find, substr, 문자열 비교 같은 전용 연산을 제공하므로 span<const char>보다 적합합니다.

std::span은 연속 메모리 영역에 대한 경량 비소유 뷰이며, 함수 인자를 통합하는 데는 탁월하지만 원본의 수명과 재할당은 여전히 호출하는 쪽이 책임져야 합니다.

관련 글: string과 string_view 비교, vector 정리.


같이 보면 좋은 글