C++20 std::span 심화: 정적·동적 extent, subspan, 읽기 전용 뷰
이 글의 핵심
포인터와 길이를 따로 넘기던 함수를 span 하나로 바꾸면 인터페이스는 깔끔해지지만, span은 데이터를 소유하지 않아 임시 vector를 가리키는 순간 댕글링이 됩니다. 이 글은 span 기초를 아는 독자를 대상으로 정적 extent, subspan 기반 분할, 크기 변경이 불가능한 이유를 행렬·이미지 처리 예제로 설명합니다.
들어가며
std::span은 C++20에서 도입된 배열 뷰입니다. 배열이나 vector의 연속 메모리를 복사 없이 참조하며, string_view와 유사한 개념입니다. C++ span 기초에서 span의 기본 개념과 소유권 모델을 다뤘다면, 이 글에서는 한 단계 더 들어가 동적/정적 크기의 차이, subspan을 이용한 슬라이싱, 행렬·이미지 데이터처럼 실제 실무에서 마주치는 다차원 데이터를 span으로 다루는 방법을 살펴봅니다.
span을 쓰면서 배운 것
span을 실제 코드에 도입하다 보면 문서만 봐서는 잘 드러나지 않는 함정들을 마주치게 됩니다. 특히 흔한 실수는 임시로 만든 vector나 함수 지역 변수를 가리키는 span을 그대로 반환하거나 저장해버리는 경우인데, 컴파일러가 경고를 주지 않는 경우가 많아 런타임에서야 문제가 드러납니다. 또한 span이 원본 컨테이너의 크기 변경을 자동으로 반영해주지 않는다는 점도 처음 접할 때 헷갈리기 쉬운 부분입니다. 이 글에서는 이런 실수들을 실제 코드로 짚어보고, 행렬 연산·슬라이딩 윈도우·이미지 픽셀 처리 같은 실전 예제를 통해 span을 안전하게 활용하는 방법을 정리합니다.
span 기본: 동적 크기와 정적 크기
기본 사용
span을 함수 매개변수로 받으면, 배열이든 vector든 호출하는 쪽에서 별도 변환 코드 없이 자연스럽게 넘길 수 있습니다. 이는 span이 배열이나 vector로부터 암시적으로 생성될 수 있도록 설계되어 있기 때문이며, 이때 실제 데이터는 전혀 복사되지 않고 포인터와 크기 정보만 전달됩니다. 배열을 함수 인자로 넘기면 포인터로 decay되어 크기 정보가 사라지는 C 스타일의 고질적인 문제도, span을 매개변수 타입으로 쓰면 자동으로 해결됩니다.
#include <span>
#include <vector>
#include <iostream>
// std::span<int>: int 배열의 뷰 (복사 없이 참조)
// 배열, vector, 다른 연속 메모리 컨테이너 모두 받을 수 있음
void print(std::span<int> s) {
// span은 범위 기반 for문 지원
// 내부적으로 포인터와 크기를 가짐
for (int x : s) {
std::cout << x << " ";
}
std::cout << std::endl;
}
int main() {
// 배열: 자동으로 span으로 변환
// 크기 정보가 자동으로 전달됨 (배열 decay 방지)
int arr[] = {1, 2, 3, 4, 5};
print(arr);
// vector: 자동으로 span으로 변환
// vector의 연속 메모리를 참조 (복사 없음)
std::vector<int> v = {6, 7, 8, 9, 10};
print(v);
// 부분 배열: 명시적으로 span 생성
// std::span(포인터, 크기): 배열의 일부만 뷰로 전달
print(std::span(arr, 3)); // 처음 3개만: 1 2 3
return 0;
}
출력:
1 2 3 4 5
6 7 8 9 10
1 2 3
동적 vs 정적 크기
span은 두 번째 템플릿 인자로 크기를 받을 수 있는데, 생략하면 std::dynamic_extent가 적용되어 런타임에 크기를 저장하는 동적 span이 되고, 정수를 명시하면 정적 span이 되어 컴파일 타임에 크기가 고정됩니다. 동적 span은 크기가 서로 다른 여러 배열을 하나의 타입으로 다룰 수 있어 유연하지만, 정적 span은 배열 크기가 이미 알려진 상황에서 잘못된 크기의 배열을 넘기는 실수를 컴파일 타임에 즉시 잡아낼 수 있다는 안전성 이점이 있습니다.
#include <span>
#include <iostream>
int main() {
int arr[] = {1, 2, 3, 4, 5};
// 동적 크기 (런타임): std::span<int>
// 크기가 템플릿 인자에 없음 → 런타임에 크기 저장
// 장점: 유연함 (다양한 크기 배열 받음)
// 단점: 크기 정보가 런타임에 저장됨 (약간의 오버헤드)
std::span<int> dynamicSpan(arr);
std::cout << "동적 크기: " << dynamicSpan.size() << std::endl; // 5
// 정적 크기 (컴파일 타임): std::span<int, 5>
// 크기가 템플릿 인자로 명시 → 컴파일 타임에 크기 확정
// 장점: 크기 검증 (컴파일 타임), 최적화 가능
// 단점: 정확한 크기만 받을 수 있음
std::span<int, 5> staticSpan(arr);
std::cout << "정적 크기: " << staticSpan.size() << std::endl; // 5
// 크기 불일치 시 컴파일 에러
// std::span<int, 10> wrongSpan(arr); // ❌ 에러: 배열은 5개인데 10개 요구
return 0;
}
정적 extent의 안전성은 소스의 크기가 컴파일 타임에 알려질 때만 성립합니다. 내장 배열이나 std::array에서 만들면 크기 불일치가 컴파일 에러가 되지만, vector나 포인터+길이로 std::span<int, 5> s(v);를 만들면 크기를 컴파일러가 알 수 없으므로 그냥 컴파일되고, v.size()가 5가 아니면 미정의 동작입니다. 이 생성자가 explicit인 이유도 여기에 있습니다. 그래서 정적 span은 “함수가 정확히 N개를 요구한다”는 계약을 인터페이스에 드러내는 용도(예: void set_color(std::span<const uint8_t, 3> rgb))에 쓰고, 런타임 크기 데이터에서 만들 때는 먼저 v.size() >= 5를 확인한 뒤 std::span(v).first<5>()처럼 동적 span에서 정적 span을 잘라 내는 편이 의도가 분명합니다.
동적 span은 포인터와 크기 두 개를 저장하므로 64비트에서 16바이트, 정적 span은 포인터만 저장해 8바이트입니다. 크기 차이 자체는 작지만, 정적 extent는 컴파일러가 루프 횟수를 알게 되어 루프 전개나 벡터화를 더 적극적으로 할 수 있다는 부수 효과가 있습니다. 동적 span은 정적 span으로 암시적 변환되지 않고, 반대 방향(정적 → 동적)은 암시적으로 변환됩니다.
subspan·first·last로 부분 범위 잘라내기
부분 범위
subspan은 원본 데이터를 전혀 복사하지 않고, 그중 일부 구간만 가리키는 새로운 span을 만들어내는 연산입니다. 첫 번째 인자로 시작 오프셋을, 두 번째 인자로 원소 개수를 지정하며, 두 번째 인자를 생략하면 그 오프셋부터 끝까지를 의미합니다. 아래 예제에서 볼 수 있듯, first3·from3·middle은 모두 같은 원본 배열 arr을 공유하는 서로 다른 뷰일 뿐이므로, 세 개의 subspan을 만드는 데 드는 비용은 사실상 0에 가깝습니다.
#include <span>
#include <iostream>
int main() {
int arr[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
std::span<int> s(arr); // 전체 배열의 뷰
// subspan(offset, count): 부분 범위 추출
// offset: 시작 인덱스 (0부터)
// count: 요소 개수
// 처음 3개: 인덱스 0부터 3개
auto first3 = s.subspan(0, 3); // [1, 2, 3]
std::cout << "처음 3개: ";
for (int x : first3) std::cout << x << " ";
std::cout << std::endl;
// 3번째부터 끝까지: count 생략 시 끝까지
auto from3 = s.subspan(3); // [4, 5, 6, 7, 8, 9, 10]
std::cout << "3번째부터: ";
for (int x : from3) std::cout << x << " ";
std::cout << std::endl;
// 중간 범위: 인덱스 3부터 4개
auto middle = s.subspan(3, 4); // [4, 5, 6, 7]
std::cout << "중간 4개: ";
for (int x : middle) std::cout << x << " ";
std::cout << std::endl;
// 모든 subspan은 원본 배열을 참조 (복사 없음)
return 0;
}
출력:
처음 3개: 1 2 3
3번째부터: 4 5 6 7 8 9 10
중간 4개: 4 5 6 7
subspan, first, last, operator[]는 모두 범위를 검사하지 않습니다. s.subspan(8, 5)처럼 끝을 넘는 구간을 요청하면 예외가 아니라 미정의 동작이고, 대개는 옆 메모리를 그대로 읽습니다. 표준 라이브러리가 vector::at() 같은 검사 버전을 span에 넣지 않은 것은 “포인터만큼 가벼운 뷰”라는 설계 목표 때문이며, at()은 C++26에서야 추가됩니다. 외부 입력으로 오프셋을 계산한다면 offset <= s.size() && count <= s.size() - offset을 먼저 검사하십시오. 개발 중에는 GCC의 -D_GLIBCXX_ASSERTIONS나 MSVC 디버그 빌드가 이런 위반을 assertion으로 잡아 줍니다.
first, last
first(n)과 last(n)은 subspan의 특수한 경우를 더 읽기 쉽게 표현한 편의 함수입니다. “앞에서 n개”나 “뒤에서 n개”처럼 흔히 쓰이는 패턴을 매번 subspan(0, n)이나 subspan(size() - n)으로 계산하는 대신, 의도를 그대로 드러내는 이름의 함수로 바로 표현할 수 있어 코드의 가독성이 좋아집니다.
#include <span>
#include <iostream>
int main() {
int arr[] = {1, 2, 3, 4, 5};
std::span<int> s(arr);
// 처음 N개
auto first2 = s.first(2);
std::cout << "처음 2개: ";
for (int x : first2) std::cout << x << " ";
std::cout << std::endl;
// 마지막 N개
auto last2 = s.last(2);
std::cout << "마지막 2개: ";
for (int x : last2) std::cout << x << " ";
std::cout << std::endl;
return 0;
}
출력:
처음 2개: 1 2
마지막 2개: 4 5
const span으로 읽기 전용 뷰 만들기
읽기 전용
함수가 데이터를 읽기만 하고 수정하지 않는다는 것을 타입 시스템으로 강제하고 싶다면, 매개변수를 std::span<const int>처럼 요소 타입에 const를 붙여 선언합니다. 이렇게 하면 함수 본문 안에서 실수로 원소를 수정하는 코드를 작성해도 컴파일 에러로 즉시 잡히며, 호출하는 쪽에서는 일반 vector든 const vector든 상관없이 안전하게 넘길 수 있습니다. 아래 예제의 read는 const int span을 받아 수정이 원천 차단되어 있고, write는 일반 int span을 받아 원본 데이터를 실제로 두 배로 만듭니다.
#include <span>
#include <vector>
#include <iostream>
void read(std::span<const int> s) {
for (int x : s) {
std::cout << x << " ";
}
std::cout << std::endl;
// s[0] = 10; // 컴파일 에러
}
void write(std::span<int> s) {
for (int& x : s) {
x *= 2;
}
}
int main() {
std::vector<int> v = {1, 2, 3};
read(v); // OK
write(v); // OK
std::cout << "수정 후: ";
read(v); // 2 4 6
const std::vector<int> cv = {4, 5, 6};
read(cv); // OK
// write(cv); // 컴파일 에러
return 0;
}
여기서 헷갈리기 쉬운 것이 const std::span<int>와 std::span<const int>의 차이입니다. 앞의 것은 span 변수 자체를 다른 범위로 바꾸지 못하게 할 뿐, s[0] = 10;처럼 원소를 고치는 것은 여전히 허용합니다. span은 포인터처럼 “얕은 const”이기 때문입니다. 읽기 전용을 의도했다면 항상 원소 타입에 const를 붙여야 합니다. 함수 매개변수에서 std::span<int> 대신 std::span<const int>를 기본으로 쓰면 const 컨테이너와 임시 객체도 받을 수 있어 호출 쪽이 훨씬 편해집니다.
행렬 연산과 슬라이딩 윈도우 예제
아래 두 예제는 다차원 데이터를 다루는 Matrix 클래스와, 시계열 데이터를 처리하는 슬라이딩 윈도우 알고리즘에서 span이 실제로 어떤 역할을 하는지 보여줍니다.
행렬 연산
Matrix는 내부적으로 2차원 데이터를 하나의 연속된 vector<double>에 저장하고, 행 인덱스로부터 오프셋(row * cols)을 계산해 접근합니다. getRow는 그 특정 행에 해당하는 구간만 가리키는 span을 반환하는데, 이렇게 하면 행 하나를 복사해서 새 벡터로 만들지 않고도 그 행의 원소들을 마치 독립된 배열처럼 순회하거나 수정할 수 있습니다. const 오버로드를 함께 제공한 덕분에, 상수 Matrix에서는 자동으로 읽기 전용 span<const double>이 반환되어 실수로 원본을 수정하는 것을 막아줍니다.
#include <span>
#include <vector>
#include <iostream>
class Matrix {
std::vector<double> data;
size_t rows, cols;
public:
Matrix(size_t r, size_t c) : data(r * c), rows(r), cols(c) {}
// 행 접근 (수정 가능)
std::span<double> getRow(size_t row) {
return std::span(data.data() + row * cols, cols);
}
// 행 접근 (읽기 전용)
std::span<const double> getRow(size_t row) const {
return std::span(data.data() + row * cols, cols);
}
// 요소 접근
double& operator()(size_t i, size_t j) {
return data[i * cols + j];
}
void print() const {
for (size_t i = 0; i < rows; ++i) {
auto row = getRow(i);
for (double x : row) {
std::cout << x << " ";
}
std::cout << std::endl;
}
}
};
int main() {
Matrix m(3, 3);
// 행 설정
auto row0 = m.getRow(0);
for (size_t i = 0; i < row0.size(); ++i) {
row0[i] = i + 1;
}
auto row1 = m.getRow(1);
for (size_t i = 0; i < row1.size(); ++i) {
row1[i] = (i + 1) * 10;
}
auto row2 = m.getRow(2);
for (size_t i = 0; i < row2.size(); ++i) {
row2[i] = (i + 1) * 100;
}
// 출력
m.print();
return 0;
}
출력:
1 2 3
10 20 30
100 200 300
getRow가 반환한 span은 Matrix 내부 vector의 메모리를 가리키므로, Matrix가 소멸하거나 내부 vector가 재할당되는 연산(크기 변경 메서드를 나중에 추가한다면)이 일어나면 그 span은 무효가 됩니다. 행 뷰를 멤버 변수나 컨테이너에 오래 보관하지 말고, 필요할 때마다 다시 얻어 쓰는 것이 안전합니다. 또 getRow(row)는 row < rows를 검사하지 않으므로, 잘못된 행 번호는 곧바로 범위 밖 접근이 됩니다. 행이 아니라 열이 필요하다면 span으로는 표현할 수 없다는 점도 한계입니다. 열 원소는 cols 간격으로 떨어져 있어 연속 메모리가 아니기 때문입니다. C++23의 std::mdspan은 이런 다차원·보폭(stride) 접근을 위해 만들어진 뷰로, m[i, j] 같은 다차원 인덱싱과 행/열 우선 배치를 지원합니다.
슬라이딩 윈도우
이동 평균처럼 데이터를 일정 크기의 겹치는 구간으로 나눠 처리해야 할 때, 매 반복마다 새로운 벡터를 만들어 값을 복사하는 대신 span으로 원본 데이터의 다른 부분을 가리키기만 하면 훨씬 효율적입니다. 이 예제는 반복문을 돌며 시작 위치를 한 칸씩 옮긴 span을 만들고, average 함수는 그 span이 실제로 원본 벡터의 어느 부분에서 왔는지 전혀 신경 쓰지 않고 그저 주어진 범위의 평균만 계산합니다. 이렇게 알고리즘(평균 계산)과 데이터 접근(어느 구간을 볼 것인가)을 분리할 수 있다는 것이 span을 함수 인터페이스에 사용하는 큰 장점입니다.
#include <span>
#include <vector>
#include <iostream>
double average(std::span<const int> window) {
double sum = 0;
for (int x : window) {
sum += x;
}
return sum / window.size();
}
int main() {
std::vector<int> data = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
const int windowSize = 3;
std::cout << "슬라이딩 윈도우 평균:" << std::endl;
for (size_t i = 0; i <= data.size() - windowSize; ++i) {
std::span<const int> window(data.data() + i, windowSize);
std::cout << "윈도우 [" << i << "-" << (i + windowSize - 1)
<< "]: " << average(window) << std::endl;
}
return 0;
}
출력:
윈도우가 한 칸씩 이동할 때마다 평균값이 어떻게 부드럽게 변하는지 확인할 수 있습니다. 원본 데이터가 1부터 10까지 순차적으로 증가하므로, 각 윈도우의 평균도 정확히 그 구간의 중앙값과 일치합니다.
슬라이딩 윈도우 평균:
윈도우 [0-2]: 2
윈도우 [1-3]: 3
윈도우 [2-4]: 4
윈도우 [3-5]: 5
윈도우 [4-6]: 6
윈도우 [5-7]: 7
윈도우 [6-8]: 8
윈도우 [7-9]: 9
루프 조건 i <= data.size() - windowSize에는 이 예제에서는 드러나지 않는 함정이 있습니다. data.size()는 부호 없는 size_t라서, 데이터가 윈도우보다 짧으면(예: 원소 2개) 2 - 3이 음수가 아니라 거대한 양수가 되어 루프가 범위 밖까지 돌다가 크래시합니다. if (data.size() < windowSize) return;으로 먼저 걸러 내거나, 조건을 i + windowSize <= data.size()처럼 뺄셈 없이 쓰는 것이 안전합니다. 같은 이유로 std::span(data).subspan(i, windowSize)를 쓰면 포인터 산술을 직접 하지 않아 의도가 더 분명해집니다. 또 average가 빈 span을 받으면 0 / 0으로 NaN이 나오므로, 빈 입력의 처리 방식도 함수 계약에 정해 두어야 합니다.
댕글링 span·임시 객체·크기 변경 불가 문제
댕글링 span
span은 원본 데이터를 소유하지 않으므로, 원본이 소멸된 뒤에도 span이 살아남으면 그 span은 이미 해제된 메모리를 가리키는 댕글링 상태가 됩니다. dangling() 함수는 지역 변수 v를 만들고 그것을 가리키는 span을 반환하는데, 함수가 끝나는 순간 v가 소멸되므로 반환된 span은 즉시 무효화됩니다. 이런 함수를 작성하지 않는 가장 확실한 방법은 애초에 컨테이너 자체(vector)를 반환하거나, span은 오직 이미 존재하는 컨테이너를 매개변수로 받아 그 자리에서만 사용하는 용도로 제한하는 것입니다.
#include <span>
#include <vector>
#include <iostream>
// ❌ 위험: 지역 변수 반환
std::span<int> dangling() {
std::vector<int> v = {1, 2, 3};
return v; // v 소멸, span은 댕글링!
}
// ✅ vector 반환
std::vector<int> safe() {
return {1, 2, 3};
}
// ✅ span 매개변수로만 사용
void process(std::span<int> s) {
for (int x : s) {
std::cout << x << " ";
}
}
int main() {
// ❌ 위험
// auto s = dangling();
// for (int x : s) {} // 정의되지 않은 동작
// ✅ 안전
std::vector<int> v = safe();
process(v);
return 0;
}
임시 객체
함수 호출 인자 자리에서 즉석으로 만든 임시 vector는 함수 호출문이 끝나는 시점(세미콜론을 만나는 순간)에 소멸합니다. 그래서 함수 안에서만 span을 쓰고 끝낸다면 임시 객체를 넘겨도 안전하고, 함수가 받은 span을 반환하거나 멤버에 저장해 호출이 끝난 뒤에도 쓸 때만 댕글링이 됩니다.
흥미로운 점은 아래 예제의 func(std::vector<int>{1, 2, 3})가 애초에 컴파일되지 않는다는 것입니다. std::span<int>처럼 원소가 const가 아닌 span은 우측값(임시) 컨테이너에서 만들 수 없도록 제약이 걸려 있습니다(그 컨테이너가 borrowed_range가 아닌 경우). 곧 사라질 임시 객체를 “수정 가능한 뷰”로 잡는 것은 거의 항상 실수라고 보기 때문입니다. 반대로 매개변수가 std::span<const int>이면 컴파일되고, 이때는 위 규칙대로 함수 안에서만 쓰면 안전합니다. 즉 표준은 가장 흔한 실수 하나는 컴파일 단계에서 막아 주지만, “span<const T>를 받아서 저장해 두는” 실수는 막지 못하므로 span을 멤버로 보관하는 클래스는 설계 단계에서 수명 규칙을 문서화해야 합니다.
#include <span>
#include <vector>
#include <iostream>
void func(std::span<int> s) {
for (int x : s) {
std::cout << x << " ";
}
}
int main() {
// ❌ span<int>는 임시 vector를 받지 않음 → 컴파일 에러
// func(std::vector<int>{1, 2, 3});
// (매개변수가 span<const int>라면 컴파일되며, 함수 안에서만 쓰면 안전)
// ✅ 변수 저장
std::vector<int> v = {1, 2, 3};
func(v);
return 0;
}
크기 변경 불가
span은 원소 개수를 늘리거나 줄이는 연산(push_back, resize 등)을 전혀 제공하지 않습니다. 이는 span이 애초에 “이미 존재하는 연속 메모리 구간을 그대로 바라보기만 하는” 뷰이기 때문에 당연한 제약입니다. 게다가 원본 vector가 push_back으로 재할당되어 메모리 주소 자체가 바뀌면, 기존에 만들어둔 span은 여전히 옛 주소를 가리키고 있어 자동으로 갱신되지 않습니다. 원본이 변경된 뒤 최신 상태를 반영하려면 아래처럼 span을 그 자리에서 다시 만들어야 합니다.
#include <span>
#include <vector>
#include <iostream>
int main() {
std::vector<int> v = {1, 2, 3};
std::span<int> s(v);
// ❌ 크기 변경 불가
// s.push_back(4); // 컴파일 에러
// s.resize(10); // 컴파일 에러
// ✅ vector로 크기 변경
v.push_back(4); // 용량이 부족하면 재할당 → 기존 s는 해제된 메모리를 가리킴
// span은 자동 업데이트 안 됨 (재생성 필요)
s = std::span<int>(v);
for (int x : s) std::cout << x << " "; // 1 2 3 4
std::cout << std::endl;
return 0;
}
재할당이 일어나지 않았더라도(reserve로 용량을 미리 확보한 경우) 기존 span의 size()는 3 그대로이므로 새로 추가된 원소는 보이지 않습니다. 반대로 v.pop_back()이나 v.clear()로 원소를 줄이면 메모리는 그대로라 크래시는 나지 않지만, span은 이미 파괴된 원소를 계속 가리킵니다. 이런 버그는 Valgrind나 AddressSanitizer(heap-use-after-free)로 재할당 케이스는 잡히지만 크기 축소 케이스는 잡히지 않는 경우가 많아, “컨테이너를 수정하는 코드 뒤에는 span을 다시 만든다”는 규칙을 습관으로 두는 편이 낫습니다.
포인터 vs span
포인터와 크기를 별도 인자로 받는 구식 방식은 두 값이 항상 정확한 쌍으로 전달된다는 보장이 전혀 없습니다. 호출자가 실수로 잘못된 크기를 넘기거나 인자 순서를 헷갈려도 컴파일러는 이를 감지하지 못합니다. span으로 매개변수를 바꾸면 포인터와 크기가 하나의 값으로 항상 함께 다니므로 이런 실수 자체가 줄어들고, 범위 기반 for처럼 현대적인 C++ 문법도 그대로 사용할 수 있어 코드가 더 간결해집니다.
#include <span>
#include <iostream>
// ❌ 구식: 포인터 + 크기
void oldStyle(int* data, size_t size) {
for (size_t i = 0; i < size; ++i) {
std::cout << data[i] << " ";
}
}
// ✅ 현대적: span
void modernStyle(std::span<int> data) {
for (int x : data) {
std::cout << x << " ";
}
}
int main() {
int arr[] = {1, 2, 3, 4, 5};
oldStyle(arr, 5);
std::cout << std::endl;
modernStyle(arr);
std::cout << std::endl;
return 0;
}
span으로 이미지 행 단위 처리하기
이미지 데이터는 흔히 픽셀별로 RGB 세 바이트가 연속으로 저장된 하나의 거대한 바이트 배열로 표현됩니다. Image 구조체는 이 원시 바이트 배열에서 특정 픽셀(3바이트) 또는 특정 행(width×3바이트) 구간만 가리키는 span을 반환해, 좌표 계산과 실제 데이터 접근 로직을 깔끔하게 분리합니다. toGrayscale처럼 모든 픽셀을 순회하며 값을 읽고 쓰는 연산도, getPixel이 반환한 span<uint8_t>을 통해 마치 독립된 3바이트 배열을 다루듯 자연스럽게 구현할 수 있습니다. 이는 원본 바이트 배열을 직접 인덱스 계산하며 다루는 것보다 훨씬 안전하고 읽기 쉬운 코드입니다.
#include <span>
#include <vector>
#include <iostream>
struct Image {
std::vector<uint8_t> pixels;
int width, height;
Image(int w, int h) : pixels(w * h * 3), width(w), height(h) {}
// 픽셀 접근 (RGB)
std::span<uint8_t> getPixel(int x, int y) {
int offset = (y * width + x) * 3;
return std::span(pixels.data() + offset, 3);
}
// 행 접근
std::span<uint8_t> getRow(int y) {
int offset = y * width * 3;
return std::span(pixels.data() + offset, width * 3);
}
// 그레이스케일 변환
void toGrayscale() {
for (int y = 0; y < height; ++y) {
for (int x = 0; x < width; ++x) {
auto pixel = getPixel(x, y);
uint8_t gray = static_cast<uint8_t>(
0.299 * pixel[0] + 0.587 * pixel[1] + 0.114 * pixel[2]
);
pixel[0] = pixel[1] = pixel[2] = gray;
}
}
}
};
int main() {
Image img(100, 100);
// 픽셀 설정 (빨강)
auto pixel = img.getPixel(50, 50);
pixel[0] = 255; // R
pixel[1] = 0; // G
pixel[2] = 0; // B
std::cout << "픽셀 (50, 50): RGB("
<< static_cast<int>(pixel[0]) << ", "
<< static_cast<int>(pixel[1]) << ", "
<< static_cast<int>(pixel[2]) << ")" << std::endl;
// 그레이스케일 변환
img.toGrayscale();
auto grayPixel = img.getPixel(50, 50);
std::cout << "그레이스케일: RGB("
<< static_cast<int>(grayPixel[0]) << ", "
<< static_cast<int>(grayPixel[1]) << ", "
<< static_cast<int>(grayPixel[2]) << ")" << std::endl;
return 0;
}
출력:
픽셀 (50, 50): RGB(255, 0, 0)
그레이스케일: RGB(76, 76, 76)
span 사용 요약
핵심 요약
- span: C++20 배열 뷰
- 복사 없음: 참조만 (소유 안함)
- 동적/정적 크기: 런타임/컴파일 타임
- subspan: 부분 범위 추출
- const span: 읽기 전용
- 수명 주의: 원본 배열 유효 동안만
span vs 다른 타입
| 타입 | 소유권 | 크기 변경 | 타입 | 용도 |
|---|---|---|---|---|
| span | 없음 | 불가 | 모든 타입 | 배열 뷰 |
| vector | 있음 | 가능 | 모든 타입 | 동적 배열 |
| string_view | 없음 | 불가 | 문자열 | 문자열 뷰 |
| 포인터+크기 | 없음 | 불가 | 모든 타입 | 구식 |
언제 span을 쓰지 않는가
span은 “연속 메모리를 읽거나 고치기만 하는 함수의 매개변수”에 가장 잘 맞습니다. 반대로 함수가 데이터를 보관해야 하면 소유하는 타입(vector)을 받거나 복사해야 하고, 원소를 추가·삭제해야 하면 컨테이너 참조(std::vector<int>&)를 받아야 합니다. std::list, std::deque, std::map처럼 연속 메모리가 아닌 컨테이너는 span으로 만들 수 없으므로, 여러 컨테이너를 범용적으로 받고 싶다면 C++20 ranges 콘셉트(std::ranges::input_range auto&&)를 쓰는 템플릿이 대안입니다. 그리고 span은 크기가 작은 값 타입이므로 const std::span<T>&가 아니라 값으로 넘기는 것이 관례입니다.
다음 단계
같이 보면 좋은 글
- C++ span 기초
- C++20 Ranges | begin/end 반복 탈출하고 ranges 알고리즘 쓰기
- C++ Ranges Views와 파이프라인 | 지연 연산으로 효율적으로 다루기 [#25-2]
- 배열과 연결 리스트
- C++ Barrier & Latch
- C++ Views | ‘뷰’ 가이드
- C++ Chrono Literals | ‘시간 리터럴’ 가이드
- C++ nullptr vs NULL: NULL이 정수라서 생기는 오버로드·템플릿 버그
자주 묻는 질문 (FAQ)
Q. 임시 vector를 span 인자로 넘겨도 괜찮은가요?
A. 함수 안에서만 span을 쓰고 끝낸다면 임시 객체는 호출문이 끝날 때까지 살아 있으므로 문제가 없습니다. 하지만 함수가 받은 span을 반환하거나 멤버에 저장해 호출이 끝난 뒤에도 사용하면, 이미 소멸한 임시 vector를 가리키는 댕글링 span이 됩니다. span은 데이터를 소유하지 않는 뷰이므로, 보관이 필요한 경우에는 데이터를 이름 있는 변수에 담아 수명을 명확히 하거나 vector 자체를 복사해 소유해야 합니다.