Span 를 사용해야 할 5가지 이유
소개: Hello World
런타임이 .Net Core 2.1 이후라면 표준으로 사용할 수 있다.
그렇지 않으면 Nuget에서 System.Memory 라는 패키지를 넣자.
그리고, 언어는 C# 7.2 이상이 필요하다.
Span라는게 뭐야?
A. 우선, 배열 같은 것이라고 생각해도 좋다.
정확하게 말한다면, 배열의 일부분을 가리키는 뷰이다.
다음 코드에서는 array의 뷰로 span을 만들고 있다.
using System;
public static class Program
{
public static void Main()
{
var array = new int[]{0, 1, 2, 3, 4, 5, 6};
var span = new Span<int>(array);
Console.WriteLine(span[3]); // result: 3
Console.WriteLine(span.Slice(2)[3]); // result: 5
}
}
Span
static class HogeClass
{
public static void SomeMethod1(Span<int> span)
{
Console.WriteLine(span[0]); // OK
span[0] = 3; // OK
}
public static void SomeMethod2(ReadOnlySpan<int> span)
{
Console.WriteLine(span[0]); // OK
span[0] = 3; // NG
}
}
Span
그림을 보면 알 수 있듯이, Span
또한 Span
Span를 사용해야 하는 이유
1. 잘 만들어진 배열 view
종래 .Net의 배열 뷰라고 하면 System.ArraySegment
- 성능이 좋다.
- 읽기 전용 버전(ReadOnlySpan
)가 준비되어 있다. - System.Array뿐만 아니라 스택 배열과 관리되지 않는 힙에 대해서도 이용 가능하다.
- T가 관리되지 않는 타입일 때, MemoryMarshal에 따라 타입을 넘는 유연한 읽고 쓰기가 가능하다.
- IList
으로 캐스팅 하지 않아도 인덱서를 사용할 수 있다.
대충 이것만으로도 Span
2. No more unsafe
C#과 같은 객체 지향 언어에서는 외부에서 본 행동을 인터페이스로 정의하고 구현을 은폐함으로써 복잡한 구현에서 추상화된 기능을 꺼내왔다. 이러한 객체 지향의 실현 기구는 어느 정도의 계산 능력을 필요로 하고 있지만, 오늘날의 개발은 거의 필수라고 말해도 좋을 정도로 성공을 거두고 있다.
그런데 C#은 상호 운용성이나 성능 이라든지에 적당히 신경을 쓰고 있는 언어이므로, 부분적으로 제한을 걸면서 낮은 수준의 데이터 구조를 지원하고 있다. 즉 System.Array, 스택 배열, 관리되지 않는 힙 이라는 세 종류의 배열이 있다.
기존의 안전한 컨텍스트에서 사용할 수 있는 것은 System.Array뿐으로 다른 2개의 이용에는 unsafe가 필요했다. 3종의 배열은 모두 첫 번째 요소에 대한 참조와 배열의 길이를 가지며, 내부적으로 메모리에 연속하고, 색인 사용이 가능하다는 그야말로 추상화 할 것 같은 공통된 기능을 가지지만, 포인터 없이는 실현 될 수 없다. 라이브러리라면 몰라도 응용 프로그램 코드를 안전하지 않은 컨텍스트로 쓴다는 것은 마음이 내키지 않는다.
그러던 Span
3. The type is the document, the type is the contract
내가 생각하는 정적 타이핑의 가장 큰 장점은 형식 자체가 문서화 되고, 또한 계약이 되는 것이다.
타입이 명시된 코드는 컴파일 시에 검증된 일종의 코딩 실수는 논리적 보증에서 검출된다.
예를 들어, IReadOnlyList
기존 배열에 대해서 타입에 의한 계약을 베푸는 수단은 부족했다.
예를 들어 다음의 코드를 보자.
public class StreamReadingBuffer
{
public int CurrentPosition { get; private set; }
public byte[] Buffer => _buffer;
private readonly byte[] _buffer;
// ~~~
}
이 클래스는 이름에서 알 수 있듯이 바이너리 스트림 읽기를 효율화 하기 위해 버퍼를 감싼 것이다.
현재의 읽기 버퍼를 Buffer 속성으로 공개하고 있지만 byte[] 형식은 내용의 변경을 방지 할 수 없다.
게다가 보지 않으면 안되는 위치도 CurrentPosition 속성으로 분리되어 버렸다.
보통 이러한 경우에는 원시 형식 대신 IReadOnlyList
Span
public class StreamReadingBuffer
{
private int _currentPosition;
public ReadOnlySpan<byte> Buffer => _buffer.Slice(_currentPosition);
private readonly byte[] _buffer;
// ~~~
}
위의 코드에서는 Buffer의 내용을 클래스의 사용자가 다시 작성할 수 없으며, 수신 버퍼 자체가 현재 위치에 따라서 분리해 낸 것으로 되어 있다. 이 변경에 따른 배열에 대한 오버 헤드도 극히 소량이다.
읽기 전용인 것과 봐야할 장소가 확보된 메모리 전체가 아닌 일부라는 것이 ReadOnlySpan
4. 그래도 나는 바이너리를 읽고 싶다
보통의 배열에 대해서도 그 표현력에 따라 역할을 가질 Span
관리되지 않는 형식은 .Net의 관리 참조를 포함하지 않는 형태이다.
가비지 컬렉터가 신경 쓰지 않는 타입 또는 C로 동등한 구조체를 만들 수 있는 형태라고 생각해도 좋다.
모든 비 관리 타입 객체는 등가인 바이트 배열을 생각할 수 있다.
Span
using System.Runtime.InteropServices;
// float 배열을 int 배열로서 읽고 쓸 수 있도록 한다.
public void Foo(Span<float> bufferAsFloat)
{
Span<int> bufferAsInt = MemoryMarshal.Cast<float, int>(bufferAsFloat);
// ~~~
}
이 기능을 사용하려면 최대의 유스 케이스는 바이너리 스트림의 읽고 쓰기이다.
독자적인(또는 독자적인 아닌 특수한) 프로토콜과 파일 형식을 구현할 때 구조체를 정의 해두면 빠르고 간단한 읽고 쓰기가 가능하게 된다. 예를 들어, 다음 코드는 PortableExecutable 파일의 헤더를 읽는 코드이다.
< PE 파일 헤더를 읽는다 >
using System;
using System.Runtime.InteropServices;
// DosHeader, ImageFileHeader, ImageOptionalHeaderなどは割愛
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct NtHeader
{
public readonly uint Signature;
public readonly ImageFileHeader FileHeader;
public readonly ImageOptionalHeader OptionalHeader;
}
public static class Sample
{
public static NtHeader ReadHeader(byte[] buffer)
{
var dosHeader = MemoryMarshal.Cast<byte, DosHeader>(buffer)[0];
return MemoryMarshal.Cast<byte, NtHeader>(buffer.AsSpan(dosHeader.e_lfanew))[0];
}
}
이른바 C++의 reinterpret_cast 같은 동작이지만, 이것을 손쉽게 할 수 있게 된 셈이다.
5. API 충분
ArraySegment
Span
- 기본 숫자 형식
- System.BitConverter
- System.IO.Stream
- Sytem.Text.Encoding
즉, 지금까지 낮은 수준 용도로 배열을 받는 API에 상당 부분 Span
Span를 사용하면 안 되는 3개의 케이스
뭐, 여기까지 Span
클래스의 멤버로 사용할 수 없던데?
A. 스펙이다.
앞서 언급했듯이 Span
< Span이 할 수 없는 것 >
public class Hoge
{
private Span<object> _span; // NG: ref 구조체 타입의 필드로 되지 앟는다.
public static Span<int> Foo()
{
Span<int> span = stackalloc int[16];
Bar(span); // NG: 제널릭 타입인수로 할 수 없다.
var obj = (object)span; // NG: object로 업캐스트 할 수 없다
Func<int> baz = () => span[0]; // NG: 델리게이트 캡쳐할 수 없다.
// True. 업캐스트 할 수 없지만 내부적으로는 object의 파생 타입.
// 어디까지나 C# 컴파일러가 정적 검증으로 금지하고 있을뿐이므로 당연하다면 당연하다.
Console.WriteLine($"Span<T> is a subtype of object: {typeof(Span<>).IsSubclassOf(typeof(object))}");
return span; // NG: stackalloc은 메소드 영역에서 나오면 죽으므로 반환값으로 할 수 없다.
}
public static void Bar<T>(T value){}
}
public ref struct Fuga : IEnumerable<int> // NG: 인터페이스를 구현할 수 없다
{
}
당연하지만, 클래스와 인터페이스를 통해 객체 지향을 실현하는 C#에서 이것은 매우 엄격한 제한이다.
여기서 제한에 걸리는 용도라면, 배열을 사용하거나 System.Memory
IList/ IReadOnlyList/ ICollection/ IEnumerable로 충분?
만약 그렇게 생각한다면, 대개의 경우 그 감각이 옳은 것 같다.
이미 언급 한 바와 같이, Span
마지막
Span
이 글은 2019-01-19에 작성되었습니다.