wiprog

C#, .NET, Scala ... について勉強したことのメモ

Span<T> のつかいみち

Qiita から移行 (2019/12/10 投稿)

--

これは、 C# Advent Calendar 2019 の 10 日目の記事です(遅刻すみません!)。 前の記事は、 @Xeltica さんの C# 用ゲームエンジンを自作した話 です。

.NET Core 2.1 で使えるようになってしばらくたった Span<T> ですが、まだ使えてないよ〜って C#er のみなさんもまだまだいらっしゃると思うので、ぼくが書いたコードを晒していきます。 もっと速くなるよ、とかあれば教えてください!

文字列系

ここでのコツは、 Span<char>#ToString() をうまく使うことと、 stackalloc でスタック領域を活用することです。 ヒープアロケーションを極力避けることで、高速に動作させます。

byte[] -> 16 進数 への変換

byte 配列を 16 進数の文字列に変換する拡張メソッドです。

/// <summary>
/// 16 進数の文字列に変換します
/// </summary>
public static string ToHexString(this byte[] source, bool upperCase = false)
{
    Span<char> buffer = stackalloc char[source.Length * 2];
    source.WriteAsHexChars(buffer, out _, upperCase);
    return buffer.ToString();
}

/// <summary>
/// バイト列を 16 進数の列として書き込みます
/// </summary>
public static void WriteAsHexChars(this ReadOnlySpan<byte> bytes, Span<char> dest, out int charsWritten,
    bool upperCase = false)
{
    charsWritten = 0;
    
    foreach (byte b in bytes)
    {
        b.WriteAsHexChars(dest.Slice(charsWritten), out var count, upperCase);
        charsWritten += count;
    }
}

/// <summary>
/// バイトを 16 進数として書き込みます
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void WriteAsHexChars(this byte b, Span<char> dest, out int charsWritten, bool upperCase = false)
{
    ReadOnlySpan<char> source = upperCase ? s_byteToHexUpper[b] : s_byteToHexLower[b];
    source.CopyTo(dest);
    charsWritten = source.Length; // 2 固定のはず
}

// byte -> char[] に変換するための 配列
private static readonly char[][] s_byteToHexLower =
    Enumerable.Range(0, 256).Select(x => ((byte) x).ToString("x2").ToCharArray()).ToArray();

private static readonly char[][] s_byteToHexUpper =
    Enumerable.Range(0, 256).Select(x => ((byte) x).ToString("X2").ToCharArray()).ToArray();

snake_case -> PascalCase への変換

スネークケースからパスカルケースへの変換をします。これは他の変換 (例: Pascal -> snake) にも応用できます。

public static string SnakeCaseToPascalCase(this string snake)
{
    ReadOnlySpan<char> snakeSpan = snake;
    Span<char> buffer = stackalloc char[snakeSpan.Length];

    int bufferPos = 0;
    bool toUpper = true;
    for (var i = 0; i < snake.Length; i++)
    {
        var target = snakeSpan[i];

        if (target == '_')
        {
            toUpper = true;
        }
        else
        {
            buffer[bufferPos++] = toUpper ? char.ToUpper(target) : target;
            toUpper = false;
        }
    }

    return buffer.Slice(0, bufferPos).ToString();
}

MD5 ハッシュの計算

MD5 ハッシュの計算にも Span が活用できます。 MD5CryptoServiceProvider はすこしラップしてあげると使いやすくなりますが、ここでも Span を活用します。 Span のコツは、できるだけ最後まで Span で扱うこと (= スタックを最大限に活用)、だと思ってます。 さっきの byte[] -> 16 進への変換も利用して、16 進数の書き込みまで Span だけでやってます。

/// <summary>
/// MD5 ヘルパ
/// </summary>
public static class Md5HashHelper
{
    // md5 インスタンスキャッシュ
    // このインスタンスはメソッド呼び出しによって内部状態が遷移するため
    // かならず内部状態の初期化を行うこと。
    // 呼び出し後に自動的に内部状態が初期化されるメソッドは ComputeHash, TryComputeHash のみです。
    [ThreadStatic] private static MD5CryptoServiceProvider t_md5 = null;

    /// <summary>
    /// MD5 ハッシュのバイト長
    /// </summary>
    public const int HashBytesLength = 16;

    /// <summary>
    /// MD5 ハッシュの十六進表記の文字数
    /// </summary>
    public const int HashHexStringLength = 32;
    
    /// <summary>
    /// キャッシュを使用するか
    /// </summary>
    /// <remarks>
    /// true の場合、 MD5CryptoServiceProvider のインスタンスをスレッドごとにキャッシュして使用します。
    /// false の場合は毎回新しい MD5CryptoServiceProvider のインスタンスを使用します。
    /// </remarks>
    public static bool UseCache { get; set; } = true;

    /// <summary>
    /// 指定したバイト配列のハッシュ値を計算します。
    /// </summary>
    public static byte[] ComputeHash(byte[] buffer)
    {
        return ComputeHash(buffer, 0, buffer.Length);
    }

    /// <summary>
    /// 指定したバイト配列の指定した領域のハッシュ値を計算します。
    /// </summary>
    public static byte[] ComputeHash(byte[] buffer, int offset, int count)
    {
        var md5 = RentUnsafe();
        var result = md5.ComputeHash(buffer, offset, count);
        ReturnUnsafe(md5);
        return result;
    }

    /// <summary>
    /// 指定した Stream オブジェクトのハッシュ値を計算します
    /// </summary>
    public static byte[] ComputeHash(Stream inputStream)
    {
        var md5 = RentUnsafe();
        var result = md5.ComputeHash(inputStream);
        ReturnUnsafe(md5);
        return result;
    }

    /// <summary>
    /// 入力バイト列のハッシュ値を計算し、出力バイト列にコピーします。
    /// </summary>
    public static bool TryComputeHash(ReadOnlySpan<byte> source, Span<byte> destination, out int bytesWritten)
    {
        var md5 = RentUnsafe();
        var result = md5.TryComputeHash(source, destination, out bytesWritten);
        ReturnUnsafe(md5);
        return result;
    }

    /// <summary>
    /// 入力バイト列のハッシュ値を計算し、16 進数表記で出力文字列にコピーします
    /// </summary>
    public static bool TryComputeHash(ReadOnlySpan<byte> source, Span<char> destination, out int charsWritten, bool upperCase = false)
    {
        Span<byte> buffer = stackalloc byte[16];
        if (TryComputeHash(source, buffer, out var bytesWritten))
        {
            ((ReadOnlySpan<byte>) buffer).WriteAsHexChars(destination, out charsWritten, upperCase);
            return true;
        }
        else
        {
            charsWritten = 0;
            return false;
        }
    }
    
    /// <summary>
    /// MD5 インスタンスを借用します。
    /// </summary>
    private static MD5CryptoServiceProvider RentUnsafe()
    {
        if (!UseCache || t_md5 == null)
        {
            return new MD5CryptoServiceProvider();
        }
        else
        {
            var md5 = t_md5;
            t_md5 = null;
            return md5;
        }
    }

    /// <summary>
    /// MD5 インスタンスをキャッシュに返却します。
    /// ※返却前に内部状態が汚染されたインスタンスを返却しないこと。
    /// </summary>
    private static void ReturnUnsafe(MD5CryptoServiceProvider md5)
    {
        if (UseCache && t_md5 == null && md5 != null)
        {
            t_md5 = md5;
        }
    }
}

Utf8Json のカスタムフォーマッタ

JSON のフォーマッタを書くときも Span を活用できます。

Enum を CamelCase にするフォーマッタ

PascalCase な enum メンバを CamelCase でシリアライズしたいことがあって書いてみました。

public class CamelCaseEnumFormatter<T> : IJsonFormatter<T>
    where T : struct
{
    public void Serialize(ref JsonWriter writer, T value, IJsonFormatterResolver formatterResolver)
    {
        var str = value.ToString();
        Span<char> buffer = stackalloc char[str.Length];
        str.AsSpan().CopyTo(buffer);
        buffer[0] = char.ToLower(buffer[0]);
        writer.WriteString(buffer.ToString());
    }

    public T Deserialize(ref JsonReader reader, IJsonFormatterResolver formatterResolver)
    {
        var str = reader.ReadString();
        return Enum.Parse<T>(str, true);
    }
}

おまけ: StringBuilder

これは半分遊びですが、スタック上で複雑な文字列を構築していきたいときのための、StringBuilder の Span 実装です。

public ref struct SpanStringBuilder
{
    private Span<char> _buffer;

    private int _index;

    public SpanStringBuilder(Span<char> buffer)
    {
        _buffer = buffer;
        _index = 0;
    }

    public void Write(ReadOnlySpan<char> str)
    {
        str.CopyTo(_buffer.Slice(_index));
        _index += str.Length;
    }

    public void WriteLine(ReadOnlySpan<char> str)
    {
        Write(str);
        Write(Environment.NewLine);
    }

    public void Write(char c)
    {
        _buffer[_index++] = c;
    }

    public void Write(int value)
    {
        if (!value.TryFormat(_buffer.Slice(_index), out var charsWritten))
        {
            throw new OutOfMemoryException("buffer のサイズが足りません");
        }

        _index += charsWritten;
    }

    public Span<char> AsSpan() => _buffer.Slice(0, _index);

    public override string ToString() => AsSpan().ToString();
}