ここでは、 UTF-8 でエンコードされた Span<byte> を、 Span<byte> のまま空白文字を削除するメソッドを解説します。
公式ライブラリにあれば良かったのですが、少なくとも .NET8 に存在しないので、自作します。
このページに記載されているコードはコピペOKですが、著者は責任は負いません。自己責任でお願いします。
空白文字を削除するにあたり、どの文字を空白とみなすかを定義する。
今回は、 char.IsWhiteSpace(char) に準拠する。
※[ ] 内は UTF-8 での byte 値(16 進数表記)。
SpaceSeparator カテゴリーの文字
SPACE (U+0020) [20]
NO-BREAK SPACE (U+00A0) [C2A0]
OGHAM SPACE MARK (U+1680) [E19A80]
EN QUAD (U+2000) [E28080]
EM QUAD (U+2001) [E28081]
EN SPACE (U+2002) [E28082]
EM SPACE (U+2003) [E28083]
THREE-PER-EM SPACE (U+2004) [E28084]
FOUR-PER-EM SPACE (U+2005) [E28085]
SIX-PER-EM SPACE (U+2006) [E28086]
FIGURE SPACE (U+2007) [E28087]
PUNCTUATION SPACE (U+2008) [E28088]
THIN SPACE (U+2009) [E28089]
HAIR SPACE (U+200A) [E2808A]
NARROW NO-BREAK SPACE (U+202F) [E280AF]
MEDIUM MATHEMATICAL SPACE (U+205F) [E2819F]
IDEOGRAPHIC SPACE (U+3000) [E38080]
LineSeparator カテゴリーの文字
LINE SEPARATOR (U+2028) [E280A8]
ParagraphSeparator カテゴリーの文字
PARAGRAPH SEPARATOR (U+2029) [E280A9]
CHARACTER TABULATION (U+0009) [09]
LINE FEED (U+000A) [0A]
LINE TABULATION (U+000B) [0B]
FORM FEED (U+000C) [0C]
CARRIAGE RETURN (U+000D) [0D]
NEXT LINE (U+0085) [C285]
文字を判定するにあたり、その文字は何 byte の文字かどうかを判定する必要がある。
参考 (https://ja.wikipedia.org/wiki/UTF-8) によると、どうやら上位ビットから 1 が連続する数を数えればいいらしい。
1 が連続する数が示す意味は下記の通り。
0 の場合 - 1 byte 文字
1 の場合 - 複数の byte で構成される文字で、先頭以外の byte 値。
2 の場合 - 2 byte 文字
3 の場合 - 3 byte 文字
4 の場合 - 4 byte 文字
5 以上の場合 - 不正な値
という訳で、文字の byte 数を取得するメソッドを定義する。
using System.Numerics;
/// <summary>
/// 先頭 1 <see cref="byte"/> の値から、 1 文字の <see cref="byte"/> 数を取得します。
/// </summary>
/// <param name="value">判定する文字の先頭の <see cref="byte"/> 値。</param>
/// <returns>1 文字の <see cref="byte"/> 数。</returns>
private static int GetByteSizeWithFirstValue(uint value)
{
// ビットフラグを反転して、MSB から 0 ビットの数を計算する。
// uint は 32 bit のため、上位 3 byte 分 (24 bit) を結果から差し引く。
return (BitOperations.LeadingZeroCount((~value) & 0xff) - 24);
}
ここでは、先頭の文字が空白文字かどうかを判定するメソッドを定義する。
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
/// <summary>
/// 指定した UTF-8 で表される文字列の先頭が、空白を示す文字かどうかを判定します。
/// </summary>
/// <remarks>空白の対象文字は <see cref="char.IsWhiteSpace(char)"/> と同等です。</remarks>
/// <param name="utf8Text">UTF-8 で表される文字列の <see cref="byte"/> 型のメモリ領域。</param>
/// <param name="readByteCount">読み取った <see cref="byte"/> 数。 この値はメソッドが <see langword="false"/> を返す場合も読み取り数を返します。</param>
/// <returns>指定した UTF-8 で表される文字列の先頭が空白を示す文字の場合は <see langword="true"/> 、それ以外の場合は <see langword="false"/> 。</returns>
public static bool IsStartWithWhiteSpace(ReadOnlySpan<byte> utf8Text, out int readByteCount)
{
/*
* 削除対象文字 -> https://learn.microsoft.com/en-us/dotnet/api/system.char.iswhitespace
*/
if (utf8Text.IsEmpty)
{
readByteCount = 0;
return false;
}
uint top = MemoryMarshal.GetReference(utf8Text); // 先頭 1 byte の値
// ["最上位 bit が 0" == "128 未満"] の場合は 1 byte 文字
if (top < 128)
{
readByteCount = 1;
// SPACE (U+0020) || CHARACTER TABULATION (U+0009) .. CARRIAGE RETURN (U+000D) || NEXT LINE (U+0085)
return top == 0x0020 || IsOtherWhiteSpace1Byte(top);
}
// 文字の byte 数を取得
readByteCount = GetByteSizeWithFirstValue(top);
if (readByteCount > utf8Text.Length) // 要求される byte 数に対して引数が短い場合
{
readByteCount = utf8Text.Length;
return false;
}
return readByteCount switch
{
2 => IsSpace2Byte(utf8Text) || IsNextLine(utf8Text),
3 => IsSpace3Byte(utf8Text) || IsLineOrParagraphSeparator(utf8Text),
_ => false,
};
}
/// <summary>
/// 指定した <see cref="byte"/> 型のメモリ領域の先頭の参照を <see cref="ushort"/> 型の参照に変換します。<br></br>
/// このメソッドはメモリ領域の長さをチェックしません。
/// </summary>
/// <param name="bytes"><see cref="ushort"/> 型の参照に変換する <see cref="byte"/> 型のメモリ領域。</param>
/// <returns><see cref="byte"/> 型のメモリ領域の先頭と同じ位置を示す <see cref="ushort"/> 型の参照。</returns>
private static ref ushort AsUInt16(ReadOnlySpan<byte> bytes)
{
return ref Unsafe.As<byte, ushort>(ref MemoryMarshal.GetReference(bytes));
}
/// <summary>
/// <see cref="byte"/> 型のメモリ領域の指定した 0 から始まるインデックス位置にある要素の参照を
/// <see cref="ushort"/> 型の参照に変換します。<br></br>
/// このメソッドはメモリ領域の長さと <paramref name="index"/> アクセスのチェックを行いません。
/// </summary>
/// <param name="bytes"><see cref="ushort"/> 型の参照に変換する <see cref="byte"/> 型のメモリ領域。</param>
/// <param name="index">参照を取得する 0 から始まるインデックス位置。</param>
/// <returns><see cref="byte"/> 型の隣接するメモリ領域の指定した 0 から始まるインデックス位置にある要素と同じ位置を示す
/// <see cref="ushort"/> 型の参照。</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static ref ushort AsUInt16(ReadOnlySpan<byte> bytes, int index)
{
return ref Unsafe.As<byte, ushort>(ref Unsafe.AddByteOffset(ref MemoryMarshal.GetReference(bytes), index));
}
/// <summary>
/// 指定したメモリ領域の先頭 3 <see cref="byte"/> を使用して <see cref="uint"/> 型の値を取得します。<br></br>
/// <see cref="uint"/> 型を形成する <see cref="byte"/> 順序は <see cref="BitConverter.IsLittleEndian"/> に依存します。<br></br>
/// このメソッドはメモリ領域の範囲をチェックしません。
/// </summary>
/// <param name="span"><see cref="byte"/> データを読み取る <see cref="byte"/> 型の隣接するメモリ領域。</param>
/// <returns>メモリ領域の先頭 3 <see cref="byte"/> をコピーした <see cref="uint"/> 型の値。</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static uint ReadUInt24(scoped ReadOnlySpan<byte> span)
{
return ReadUInt24(ref MemoryMarshal.GetReference(span));
}
/// <summary>
/// 指定した連続するメモリ領域を示す参照から 3 <see cref="byte"/> を使用して <see cref="uint"/> 型の値を取得します。<br></br>
/// <see cref="uint"/> 型を形成する <see cref="byte"/> 順序は <see cref="BitConverter.IsLittleEndian"/> に依存します。
/// </summary>
/// <param name="reference"><see cref="byte"/> データを読み取る、連続するメモリ領域を示す <see cref="byte"/> 型の参照。</param>
/// <returns>メモリ領域の先頭 3 <see cref="byte"/> をコピーした <see cref="uint"/> 型の値。</returns>
private static uint ReadUInt24(scoped ref readonly byte reference)
{
uint result = 0;
Unsafe.CopyBlockUnaligned(ref Unsafe.As<uint, byte>(ref result), in reference, 3);
return result;
}
private static bool IsSpace2Byte(ReadOnlySpan<byte> utf8Text)
{
ref ushort left = ref AsUInt16(utf8Text);
ref ushort right = ref AsUInt16(NoBreakSpace());
// NO-BREAK SPACE (U+00A0)
return left == right;
}
private static bool IsNextLine(ReadOnlySpan<byte> utf8Text)
{
ref ushort left = ref AsUInt16(utf8Text);
ref ushort right = ref AsUInt16(ref NextLine());
// NEXT LINE (U+0085)
return left == right;
}
private static bool IsSpace3Byte(ReadOnlySpan<byte> utf8Text)
{
ref byte reference = ref MemoryMarshal.GetReference(utf8Text); // 先頭の参照を取得
if (reference == 0xe1)
{
ref ushort left = ref Unsafe.As<byte, ushort>(ref Unsafe.AddByteOffset(ref reference, 1));
ref ushort right = ref AsUInt16(OghamSpaceMark(), 1);
// OGHAM SPACE MARK (U+1680)
return left == right;
}
else
{
ref ushort left = ref AsUInt16(utf8Text);
ref ushort right = ref AsUInt16(EnQuad());
if (left == right)
{
// EN QUAD (U+2000) .. HAIR SPACE (U+200A) || NARROW NO-BREAK SPACE (U+202F)
int lowest = Unsafe.AddByteOffset(ref reference, 2);
return lowest >= 0x80 && lowest <= 0x8a) || lowest == 0xaf;
}
else
{
uint u0 = ReadUInt24(utf8Text);
uint u1 = ReadUInt24(MediumMathematicalSpace());
if (u0 == u1) // MEDIUM MATHEMATICAL SPACE (U+205F)
return true;
u1 = ReadUInt24(IdeographicSpace());
return u0 == u1; // IDEOGRAPHIC SPACE (U+3000)
}
}
}
private static bool IsOtherWhiteSpace1Byte(uint byteValue)
{
// CHARACTER TABULATION (U+0009) .. CARRIAGE RETURN (U+000D)
return (9 <= byteValue && byteValue <= 13);
}
private static bool IsLineOrParagraphSeparator(ReadOnlySpan<byte> utf8Text)
{
ref byte reference = ref MemoryMarshal.GetReference(utf8Text); // 先頭の参照を取得
// 先頭 2 byte を比較するために ushort にキャスト
ref ushort left = ref Unsafe.As<byte, ushort>(ref reference);
ref ushort right = ref AsUInt16(LineSeparator());
if (left == right)
{
uint u = Unsafe.AddByteOffset(ref reference, 2);
// LINE SEPARATOR (U+2028) || PARAGRAPH SEPARATOR (U+2029)
return u == 0xa8 || u == 0xa9;
}
else
return false;
}
#region
/// <summary>
/// OGHAM SPACE MARK (U+1680) [3 <see cref="byte"/>]
/// </summary>
/// <returns>OGHAM SPACE MARK (U+1680) の <see cref="byte"/> データを示す読み取り専用のメモリ領域。</returns>
/// <value>0xE19A80</value>
private static ReadOnlySpan<byte> OghamSpaceMark() => [0xe1, 0x9a, 0x80];
/// <summary>
/// EN QUAD (U+2000) [3 <see cref="byte"/>]
/// </summary>
/// <returns>EN QUAD (U+2000) の <see cref="byte"/> データを示す読み取り専用のメモリ領域。</returns>
/// <value>0xE28080</value>
private static ReadOnlySpan<byte> EnQuad() => [0xe2, 0x80, 0x80];
/// <summary>
/// NO-BREAK SPACE (U+00A0) [2 <see cref="byte"/>]
/// </summary>
/// <returns>NO-BREAK SPACE (U+00A0) の <see cref="byte"/> データを示す読み取り専用のメモリ領域。</returns>
/// <value>0xC2A0</value>
private static ReadOnlySpan<byte> NoBreakSpace() => [0xc2, 0xa0];
/// <summary>
/// LINE SEPARATOR (U+2028) [3 <see cref="byte"/>]
/// </summary>
/// <returns>LINE SEPARATOR (U+2028) の <see cref="byte"/> データを示す読み取り専用のメモリ領域。</returns>
/// <value>0xE280A8</value>
private static ReadOnlySpan<byte> LineSeparator() => [0xe2, 0x80, 0xa8];
/// <summary>
/// NEXT LINE (U+0085) [2 <see cref="byte"/>]
/// </summary>
/// <returns>NEXT LINE (U+0085) の <see cref="byte"/> データを示す読み取り専用のメモリ領域。</returns>
/// <value>0xC285</value>
private static ReadOnlySpan<byte> NextLine() => [0xc2, 0x85];
/// <summary>
/// MEDIUM MATHEMATICAL SPACE (U+205F) [3 <see cref="byte"/>]
/// </summary>
/// <returns>MEDIUM MATHEMATICAL SPACE (U+205F) の <see cref="byte"/> データを示す読み取り専用のメモリ領域。</returns>
/// <value>0xE2819F</value>
private static ReadOnlySpan<byte> MediumMathematicalSpace() => [0xe2, 0x81, 0x9f];
/// <summary>
/// IDEOGRAPHIC SPACE (U+3000) [3 <see cref="byte"/>]
/// </summary>
/// <returns>IDEOGRAPHIC SPACE (U+3000) の <see cref="byte"/> データを示す読み取り専用のメモリ領域。</returns>
/// <value>0xE38080</value>
private static ReadOnlySpan<byte> IdeographicSpace() => [0xe3, 0x80, 0x80];
#endregion
昨今の CPU では一度に複数の byte で演算することができる。基本的には 32 bit CPU では 4 byte、 64 bit CPU では 8 byte。
C# では IntPtr (nint) ・ UIntPtr (nuint) 型が CPU が一度に計算できる byte 数に相当するが、このメソッドでは 3 byte のみ使用するので uint 型 (int 型でも可)を使用。
byte 値をひとつひとつ読み取って比較する場合 (for 文など) 、当然回数分演算することになるのだが、そもそも CPU は 1 回の演算で複数の byteを計算できるのだから、 複数の byte で演算すれば演算回数を減らすことができる。(当然処理速度が上がる。)
という訳で、読み取る byte 数が 4 byte なら Unsafe.As<Tfrom, TTo>() (またはポインターなど)で int か uint (8 byte なら long か ulong) にキャストして演算すれば、回数が 1 回で済む。
が、今回比較するのは 3 byte 。 4 byte に満たない状態で上記の方法を取ってしまっては範囲外のメモリーの値を読み取ってしまう。
そこで、 4 byte である uint 型 (int 型でも可)のローカル変数を用意し、この変数に 3 byte をコピーしてから演算するようにひと手間加える。
一見余計な手間が増えて処理速度が遅くなるように見えるが、 3 byte 分を for 文などで愚直に演算するよりは速い。
ここでは、実際に空白文字を削除する公開メソッドを定義する。
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
/// <inheritdoc cref="TrimStart(Span{byte}, out int)"/>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static Span<byte> TrimStart(Span<byte> utf8Text)
{
return TrimStart(utf8Text, out _);
}
/// <summary>
/// 指定した UTF-8 で表される文字列の先頭から、空白を示す文字を削除した新しいメモリ領域を取得します。
/// </summary>
/// <remarks>空白の対象文字は <see cref="char.IsWhiteSpace(char)"/> と同等です。</remarks>
/// <param name="utf8Text">UTF-8 で表される文字列の <see cref="byte"/> 型のメモリ領域。</param>
/// <param name="trimByteCount">先頭から削除した <see cref="byte"/> 数。</param>
/// <returns>指定した UTF-8 で表される文字列の先頭から空白を削除した新しいメモリ領域。</returns>
public static Span<byte> TrimStart(Span<byte> utf8Text, out int trimByteCount)
{
TrimStart(ref MemoryMarshal.GetReference(utf8Text), utf8Text.Length, out trimByteCount);
return utf8Text[trimByteCount..];
}
/// <inheritdoc cref="TrimStart(ReadOnlySpan{byte}, out int)"/>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static ReadOnlySpan<byte> TrimStart(ReadOnlySpan<byte> utf8Text)
{
return TrimStart(utf8Text, out _);
}
/// <inheritdoc cref="TrimStart(Span{byte}, out int)"/>
public static ReadOnlySpan<byte> TrimStart(ReadOnlySpan<byte> utf8Text, out int trimByteCount)
{
TrimStart(ref MemoryMarshal.GetReference(utf8Text), utf8Text.Length, out trimByteCount);
return utf8Text[trimByteCount..];
}
private static void TrimStart(ref byte utf8Text, int length, out int readByteCount)
{
readByteCount = 0;
do
{
int i = length - readByteCount;
if (i <= 0)
return;
Span<byte> span = MemoryMarshal.CreateSpan(ref Unsafe.AddByteOffset(ref utf8Text, readByteCount), i);
if (!IsStartWithWhiteSpace(span, out int u) || u <= 0)
{
return;
}
readByteCount += u;
}
while (true);
}
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
/// <inheritdoc cref="TrimEnd(Span{byte}, out int)"/>
public static Span<byte> TrimEnd(Span<byte> utf8Text)
{
return TrimEnd(utf8Text, out _);
}
/// <summary>
/// 指定した UTF-8 で表される文字列の末尾から、空白を示す文字を削除した新しいメモリ領域を取得します。
/// </summary>
/// <remarks>空白の対象文字は <see cref="char.IsWhiteSpace(char)"/> と同等です。</remarks>
/// <param name="utf8Text">UTF-8 で表される文字列の <see cref="byte"/> 型のメモリ領域。</param>
/// <param name="trimByteCount">末尾から削除した <see cref="byte"/> 数。</param>
/// <returns>指定した UTF-8 で表される文字列の末尾から空白を削除した新しいメモリ領域。</returns>
public static Span<byte> TrimEnd(Span<byte> utf8Text, out int trimByteCount)
{
TrimEnd(ref MemoryMarshal.GetReference(utf8Text), utf8Text.Length, out trimByteCount);
return utf8Text[..^trimByteCount];
}
/// <inheritdoc cref="TrimEnd(ReadOnlySpan{byte}, out int)"/>
public static ReadOnlySpan<byte> TrimEnd(ReadOnlySpan<byte> utf8Text)
{
return TrimEnd(utf8Text, out _);
}
/// <inheritdoc cref="TrimEnd(Span{byte}, out int)"/>
public static ReadOnlySpan<byte> TrimEnd(ReadOnlySpan<byte> utf8Text, out int trimByteCount)
{
TrimEnd(ref MemoryMarshal.GetReference(utf8Text), utf8Text.Length, out trimByteCount);
return utf8Text[..^trimByteCount];
}
private static void TrimEnd(ref byte utf8Text, int length, out int readByteCount)
{
readByteCount = 0;
CharInvertEnumerator enumerator = new(ref utf8Text, length);
while (enumerator.MoveNext())
{
if (IsStartWithWhiteSpace(ref enumerator.Current, enumerator.CurrentLength, out int read))
{
readByteCount += read;
}
else
{
return;
}
}
}
private static bool IsMiddleValue(uint value)
{
return (value & 0b1100_0000) == 0b1000_0000;
}
/// <summary>
/// 文字の先頭の <see cref="byte"/> 参照を末尾から列挙する構造体
/// </summary>
private ref struct CharInvertEnumerator
{
private readonly ref byte reference;
private readonly int length;
private int currentIndex;
public CharInvertEnumerator(ref byte reference, int length)
{
this.reference = ref reference;
this.length = length;
this.currentIndex = length;
}
public readonly ref byte Current
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
get
{
return ref Unsafe.AddByteOffset(ref reference, currentIndex)
}
}
public readonly int CurrentLength
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
get
{
return length - currentIndex;
}
}
public bool MoveNext()
{
while (currentIndex > 0)
{
currentIndex--;
if (IsMiddleValue(Current))
continue;
else
return true;
}
return false;
}
}
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
/// <summary>
/// 指定した UTF-8 で表される文字列の先頭と末尾から、空白を示す文字を削除した新しいメモリ領域を取得します。
/// </summary>
/// <remarks>空白の対象文字は <see cref="char.IsWhiteSpace(char)"/> と同等です。</remarks>
/// <param name="utf8Text">UTF-8 で表される文字列の <see cref="byte"/> 型のメモリ領域。</param>
/// <returns>指定した UTF-8 で表される文字列の先頭と末尾から空白を削除した新しいメモリ領域。</returns>
public static Span<byte> Trim(Span<byte> utf8Text)
{
ref byte reference = ref Trim(ref MemoryMarshal.GetReference(utf8Text), utf8Text.Length, out int newLength);
return MemoryMarshal.CreateSpan(ref reference, newLength);
}
/// <inheritdoc cref="Trim(Span{byte})"/>
public static ReadOnlySpan<byte> Trim(ReadOnlySpan<byte> utf8Text)
{
ref byte reference = ref Trim(ref MemoryMarshal.GetReference(utf8Text), utf8Text.Length, out int newLength);
return MemoryMarshal.CreateReadOnlySpan(ref reference, newLength);
}
/// <summary>
/// UTF-8 で表される文字列の先頭と末尾から空白を示す文字を削除し、
/// 新しく先頭となる参照とメモリ領域の長さを取得します。
/// </summary>
/// <param name="utf8Text">基となる UTF-8 文字列を示すメモリ領域の先頭の参照。</param>
/// <param name="length">基となる UTF-8 文字列を示すメモリ領域の長さ。</param>
/// <param name="newLength">新しいメモリ領域の長さ。</param>
/// <returns>新しく先頭となる参照。</returns>
private static ref byte Trim(ref byte utf8Text, int length, out int newLength)
{
TrimStart(ref utf8Text, length, out newLength); // newLength を一時変数として使用
ref byte reference = ref Unsafe.AddByteOffset(ref utf8Text, newLength);
length -= newLength;
TrimEnd(ref reference, length, out newLength); // newLength を一時変数として使用
newLength = length - newLength;
return ref reference;
}
筆者 : Megria 2025/2/04 更新