반응형

[PART6.배열과 문자열 기본(4/14)] 범위·인덱스 연산자 — ^11..3 한 글자에 숨은 GC 비용

arr[^1]은 단순 인덱싱으로 컴파일되지만 arr[1..3]은 새 배열을 만든다 / System.Index·System.Range라는 진짜 타입의 정체 / RuntimeHelpers.GetSubArray가 호출되는 순간 GC alloc / Span<T>로 받으면 zero-alloc / C# 13 객체 초기화자 안에서 ^ 신규 허용


1. 한 글자 차이의 진짜 비용

C# 8.0이 도입한 인덱스 연산자(^)와 범위 연산자(..)는 코드를 짧게 만드는 가장 만족스러운 신문법 중 하나입니다.

C#
char last = name[^1];                 // 마지막 글자
string head = path[..separator];      // 구분자 앞부분
int[] mid = scores[1..^1];            // 처음·끝 한 개씩 빼기

name.Length - 1을 매번 적던 옛 코드와 비교하면 한눈에 가독성이 좋아집니다. 그런데 신입 개발자가 거의 모르는 사실 — 이 둘은 비용이 다릅니다. ^1 같은 인덱싱은 거의 무료지만, 1..^1 같은 범위 슬라이싱은 새 배열을 만들어 복사합니다. Update 안에 arr[1..^1] 한 줄을 박아 두면 매 프레임 GC alloc이 발생하는 것입니다.

C#
void Update()
{
    int[] mid = inputs[1..^1];   // ❌ 매 프레임 new int[] + 복사
    ProcessMiddle(mid);
}

이 차이가 어디서 오는지, 어떻게 zero-alloc으로 같은 동작을 얻는지 — 그게 이 글의 주제입니다.


2. IndexRange라는 진짜 타입

^System.Index 생성자 호출이다

^N은 컴파일러가 new Index(N, fromEnd: true) 로 변환합니다. 정수 N을 그냥 인덱스 자리에 쓰면 new Index(N, fromEnd: false)가 됩니다.

C#
Index i1 = ^1;                       // = new Index(1, fromEnd: true)
Index i2 = 3;                        // = new Index(3, fromEnd: false) (정수 → Index 암시적 변환)

Index(int value, bool fromEnd) 두 필드만 가진 가벼운 값 타입(struct)입니다. 변수에 담아 두면 그 위에 GetOffset(length) 메서드를 호출해서 실제 인덱스를 계산하는 흐름이 됩니다.

..System.Range 생성자 호출이다

s..e는 컴파일러가 new Range(start: Index s, end: Index e) 로 변환합니다.

C#
Range r1 = 1..3;     // = new Range(1, 3)
Range r2 = ..3;      // = Range.EndAt(3)        ← 시작이 0이면 정적 팩토리 메서드
Range r3 = 2..;      // = Range.StartAt(2)      ← 끝이 ^0이면 정적 팩토리 메서드
Range r4 = ..;       // = Range.All             ← 둘 다 비면 미리 만들어진 정적 인스턴스

Range(Index Start, Index End) 두 필드의 값 타입입니다. 한쪽이 비어 있으면 컴파일러가 더 가벼운 정적 팩토리 메서드를 호출해 줍니다 — 의외로 new Range(...)보다 Range.StartAt(...)이 IL이 더 짧습니다.

컴파일러의 직접 인덱싱 최적화

좋은 소식 — arr[^1]처럼 상수 ^N을 배열에 직접 인덱싱하는 패턴은 컴파일러가 Index 객체를 만들지 않고 곧장 arr[arr.Length - N]으로 풀어 줍니다.

C#
public static int Last(int[] xs) => xs[^1];
public static int FromEndN(int[] xs, int n) => xs[^n];
IL
// xs[^1] — Index 객체 생성 없이 단순 인덱싱
IL_0000: ldarg.0           // xs
IL_0001: dup
IL_0002: ldlen             // xs.Length
IL_0003: conv.i4
IL_0004: ldc.i4.1
IL_0005: sub               // Length - 1
IL_0006: ldelem.i4         // xs[Length-1]

// xs[^n] — 변수도 마찬가지로 단순 빼기
IL_0001: dup
IL_0002: ldlen
IL_0003: conv.i4
IL_0004: ldarg.1           // n
IL_0005: sub               // Length - n
IL_0006: ldelem.i4

xs[^1] 한 줄이 IL 4~5개 명령으로 끝나고, Index 객체는 어디에도 없습니다. 컴파일러가 알아서 xs[xs.Length - 1]로 풀어준 결과입니다 — 무료라고 봐도 됩니다.

다만 Index를 변수에 담아 쓰면 이야기가 달라집니다.

C#
public static int IndexedDirect(int[] xs)
{
    Index i = ^2;
    return xs[i];
}
IL
IL_0001: ldloca.s 0                                                  // Index 변수 주소
IL_0003: ldc.i4.2
IL_0004: ldc.i4.1
IL_0005: call instance void System.Index::.ctor(int32, bool)         // ← Index 객체(struct) 초기화
IL_000d: ldloca.s 0
IL_0010: ldlen
IL_0011: conv.i4
IL_0012: call instance int32 System.Index::GetOffset(int32)          // ← 실제 인덱스 계산
IL_0017: ldelem.i4

변수에 담은 IndexGetOffset(length) 메서드 호출이 끼어듭니다. 상수 ^N을 인라인으로 쓰면 무료, 변수로 받으면 메서드 호출. 이 차이를 알면 핫패스에서 어떻게 써야 할지 자연스럽게 결정됩니다.


3. 슬라이싱은 새 배열을 만든다

여기가 이 글의 핵심입니다.

배열의 arr[1..3]은 alloc이다

C#
public static int[] Slice13(int[] xs) => xs[1..3];
IL
IL_0001: ldc.i4.1
IL_0002: call valuetype System.Index System.Index::op_Implicit(int32)    // 정수 → Index 변환
IL_0007: ldc.i4.3
IL_0008: call valuetype System.Index System.Index::op_Implicit(int32)    // 정수 → Index 변환
IL_000d: newobj instance void System.Range::.ctor(Index, Index)          // Range 객체 생성
IL_0012: call !!0[] System.Runtime.CompilerServices.RuntimeHelpers
              ::GetSubArray<int32>(!!0[], System.Range)                  // ← 새 배열 + 복사!

GetSubArray<T>가 호출됐다는 사실이 결정적입니다 — 이 메서드는 내부적으로 new T[length]새 배열을 잡고 원본 데이터를 복사합니다. 다음 그림이 그 차이를 보여줍니다.

arr[1..3] 슬라이스 — 두 가지 받는 방식

Span<T>로 받으면 alloc 0

C#
public static int SumSliceSpan(int[] xs)
{
    ReadOnlySpan<int> mid = xs.AsSpan(1..^1);   // ← 원본을 가리키는 뷰만 만든다
    int sum = 0;
    for (int i = 0; i < mid.Length; i++) sum += mid[i];
    return sum;
}

AsSpan(Range)은 새 배열을 만들지 않고 시작 포인터 + 길이만 든 가벼운 struct를 돌려줍니다. 같은 1..^1 슬라이스를 int[]로 받느냐 ReadOnlySpan<int>으로 받느냐에 따라 GC 부담이 천지차이입니다.

받는 형태 IL 추가 alloc 핫패스 적합?
int[] mid = arr[1..^1]; RuntimeHelpers.GetSubArray<int> 1 (새 배열) ❌ 매 호출 alloc
ReadOnlySpan<int> mid = arr.AsSpan(1..^1); MemoryExtensions.AsSpan 0 (struct 슬롯뿐)
Span<int> mid = arr.AsSpan(1..^1); 위와 동일, 쓰기 가능 0

[..]은 더 단순하지만 여전히 새 배열

C#
public static int[] All(int[] xs) => xs[..];
IL
IL_0001: call valuetype System.Range System.Range::get_All()             // 정적 인스턴스 (alloc 없음)
IL_0006: call !!0[] RuntimeHelpers::GetSubArray<int32>(...)              // ← 그래도 새 배열!

Range.All은 정적 인스턴스라 객체 생성이 없지만, 결과로 받은 배열은 어차피 새 배열입니다. arr[..]은 사실상 (int[])arr.Clone()과 같은 동작 — 원본 보존이 목적이라면 OK이지만, 단순히 "전체를 읽고 싶다"가 의도라면 그냥 arr을 그대로 쓰면 됩니다.


4. 자주 쓰는 패턴

C#
// 마지막 요소
arr[^1]                      // 단순 인덱싱 — 무료

// 마지막 N개 (끝부터 N개)
arr[^N..]                    // 새 배열 → Span으로 받으면 zero-alloc
arr.AsSpan(^N..)             // 권장

// 처음 N개 빼고
arr[N..]                     // 새 배열 → Span 권장
arr.AsSpan(N..)

// 처음·끝 한 개씩 빼고 ('가운데')
arr[1..^1]                   // 새 배열 → Span 권장
arr.AsSpan(1..^1)

string도 같은 규칙이지만 항상 새 객체

C#
string s = "Hello";
char last = s[^1];           // 'o' — 단순 인덱싱
string head = s[..3];        // "Hel" — 새 string!

string은 불변 타입이라 슬라이싱이 항상 새 string 객체를 만듭니다 — Span으로 우회해도 zero-alloc이 됩니다.

C#
ReadOnlySpan<char> head = s.AsSpan(..3);   // alloc 0 — 원본 string 위의 뷰

핫패스에서 부분 문자열만 살피면 되는 경우 ReadOnlySpan<char>으로 받으면 매번 새 string을 만들지 않을 수 있습니다.

List<T>는 인덱서만 지원, 슬라이스 안 됨

C#
List<int> list = new() { 1, 2, 3, 4, 5 };
int last = list[^1];          // ✅ List<T>의 인덱서가 Index를 받음
List<int> mid = list[1..3];   // ❌ 컴파일 오류 — List는 Range 인덱서가 없다

List<T>Index를 받는 인덱서만 있고 Range 인덱서는 없습니다. 슬라이스가 필요하면 CollectionsMarshal.AsSpan(list)[1..3] 또는 list.GetRange(1, 2)(새 List 생성).


5. C# 13 신규 — 객체 초기화자 안에서 ^ 사용

C# 12 이전에는 객체 초기화자({ ... = ... }) 안에서 ^를 쓸 수 없었습니다. C# 13부터 가능해졌습니다.

C#
// ❌ C# 12 이전 — 컴파일 오류
public class Buffer8
{
    public int[] data = new int[8];
}

var b = new Buffer8
{
    data = { [^1] = 99, [^2] = 88, [0] = 1 }
};

// ✅ C# 13부터 위 코드가 정상 컴파일

객체 초기화자 안에서 끝에서부터 차곡차곡 채우는 패턴 — 예를 들어 큐의 마지막 자리에 sentinel을 넣어 두는 패턴 — 이 한 줄로 표현됩니다. 이전엔 객체를 만든 뒤 별도 줄에서 b.data[b.data.Length - 1] = 99;라고 적어야 했습니다.

C# 13 신규는 컴파일러 변환 규칙의 확장이지 런타임 변경은 아니라, .NET 런타임 버전 요구사항은 따로 없습니다.


6. 함정과 주의사항

함정 1 — ^0은 끝 다음, 인덱싱하면 OOR

C#
int[] xs = { 1, 2, 3 };
int? a = xs[^1];        // 3 (마지막 요소)
int? b = xs[^0];        // ❌ IndexOutOfRangeException

int[] all = xs[..^0];   // ✅ 전체 — 범위 끝으로는 OK

^NLength - N이므로 ^0 = Length. 인덱싱은 0..Length-1만 유효해서 ^0은 OOR. 범위 끝(a..^0)으로는 의미가 있지만, 인덱스로는 절대 쓰지 말 것.

함정 2 — arr[1..3]은 매 호출 GC alloc

C#
// ❌ Update 안에서 매 프레임 새 배열
void Update()
{
    int[] mid = inputs[1..^1];   // GC alloc!
    Process(mid);
}

// ✅ Span으로 받기
void Update()
{
    ReadOnlySpan<int> mid = inputs.AsSpan(1..^1);
    Process(mid);                 // Process가 ReadOnlySpan을 받도록 시그니처 조정
}

신입이 ^/..의 가독성에 만족해서 무심코 arr[a..b]를 핫패스에 박아 두는 순간, 매 프레임 GC alloc이 시작됩니다. 슬라이스가 필요하면 거의 항상 AsSpan(...)로 받습니다 — 메서드 시그니처도 ReadOnlySpan<T>/Span<T>를 받도록 만드는 편이 안전합니다.

함정 3 — foreach (var x in arr[1..3])도 임시 배열을 만든다

C#
foreach (var x in arr[1..3])     // ❌ 임시 배열 생성 (alloc!)
    Console.WriteLine(x);

foreach (var x in arr.AsSpan(1..3))   // ✅ Span 슬라이스 위에서 직접 순회
    Console.WriteLine(x);

foreach의 컬렉션 자리에 슬라이스 표현식을 쓰면 그 표현식 결과(=새 배열)가 임시 객체로 살아납니다. 일회용 순회라도 alloc이 발생하니 핫패스에서는 AsSpan(range)을 거쳐 순회해야 합니다.

함정 4 — 변수에 담은 Index는 메서드 호출 비용

C#
// ❌ Index 변수가 GetOffset 호출을 만든다
Index i = ^1;
return xs[i];

// ✅ 인라인 ^1은 단순 인덱싱
return xs[^1];

위 IL 비교에서 봤듯이, 상수 ^N은 컴파일러가 단순 인덱싱으로 풀지만 변수에 담긴 IndexGetOffset(int) 메서드 호출이 추가됩니다. 핫패스에서는 인라인으로 쓰는 편이 빠릅니다.


7. C# 버전별 변화

버전 변화 비고
8.0 ^ (Index)·.. (Range) 도입 / Span<T>도 동일 문법 지원 기본
8.0 string[Range], Array[Range] 인덱서 추가 새 객체 알로케이션 발생
8.0 List<T>[Index] 인덱서 추가 (Range는 미지원)  
13 객체 초기화자 안에서 ^ 사용 가능 새 패턴 한 줄
13 (C# 13 일반) Span<T> 관련 ref struct 제약 일부 완화 PART 6-12 참조

C# 13의 ^ 객체 초기화자 확장은 새 런타임 기능이 아니라 컴파일러가 받아들이는 위치의 확장이므로, 기존 .NET 런타임에서도 그대로 동작합니다.


8. 정리

  • [ ] arr[^1]은 단순 인덱싱. 컴파일러가 arr[arr.Length - 1]로 풀어주므로 무료.
  • [ ] 변수에 담은 IndexGetOffset(int) 메서드 호출이 추가된다. 인라인 사용을 우선.
  • [ ] arr[1..3]은 새 배열을 만든다RuntimeHelpers.GetSubArray<T>가 호출되어 alloc 발생.
  • [ ] 슬라이스 받는 형태로 Span<T>/ReadOnlySpan<T>(arr.AsSpan(1..3))을 쓰면 zero-alloc.
  • [ ] arr[..]도 새 배열 — 단순히 전체를 읽고 싶으면 그냥 arr을 쓰면 된다.
  • [ ] ^0은 인덱싱 시 OOR(=Length). 범위 끝으로만 사용.
  • [ ] string도 슬라이스가 새 string 알로케이션 — 부분 검사만 한다면 s.AsSpan(..).
  • [ ] List<T>Index 인덱서만 지원. Range는 없으므로 CollectionsMarshal.AsSpan(list)[range] 우회.
  • [ ] 핫패스에서 arr[a..b]를 직접 받지 말고 arr.AsSpan(a..b)Update/FixedUpdate GC.Alloc 0의 출발점.
  • [ ] foreach (var x in arr[a..b])도 임시 배열을 만든다. 순회도 AsSpan으로.
  • [ ] C# 13부터 객체 초기화자 안에서 ^ 사용 허용new T { buffer = { [^1] = 0 } } 가능.
반응형

+ Recent posts