Memory<T> と Span<T> の使用ガイドライン

.NET には、任意の連続したメモリ領域を表す多くの型があります。 Span<T>ReadOnlySpan<T> はマネージド メモリまたはアンマネージド メモリへの参照をラップする軽量のメモリ バッファーです。 このような型はスタック上にのみ格納できるため、非同期メソッドの呼び出しを含むシナリオには適していません。 この問題に対処するために、.NET 2.1 では、Memory<T>ReadOnlyMemory<T>IMemoryOwner<T>MemoryPool<T> など、いくつかの追加型が追加されました。 Span<T> と同様に、Memory<T> とそれに関連する型は、マネージド メモリとアンマネージド メモリの両方でサポートできます。 Span<T> とは異なり、Memory<T> はマネージド ヒープ上に格納できます。

Span<T>Memory<T> は、どちらもパイプラインで使用できる構造化データのバッファーのラッパーです。 つまり、データの一部または全部をパイプライン内のコンポーネントに効率的に渡すことができるように設計されています。そのため、データを処理し、必要に応じてバッファーを変更できます。 Memory<T> とその関連する型には、複数のコンポーネントまたは複数のスレッドからアクセスできるため、標準的な使用ガイドラインに従って堅牢なコードを作成することが重要です。

所有者、コンシューマー、有効期間管理

バッファーは API 間で渡すことができ、複数のスレッドからアクセスできる場合があるため、バッファーの有効期間の管理方法に注意してください。 次の 3 つの中心的な概念があります。

  • 所有権 バッファー インスタンスの所有者は、バッファーが使用されなくなったときにバッファーを破棄することを含め、有効期間の管理を担当します。 すべてのバッファーの所有者は 1 つです。 通常、所有者とは、バッファーを作成したコンポーネント、またはファクトリーからバッファーを受け取ったコンポーネントです。 所有権は譲渡することもできます。コンポーネント A はバッファーの制御をコンポーネント B に譲渡することができます。その時点でコンポーネント A はバッファーを使用できなくなり、コンポーネント B が、使用されなくなったバッファーの破棄を担当します。

  • Consumption。 バッファー インスタンスのコンシューマーは、読み取り、場合によっては書き込みでバッファー インスタンスを使用できます。 何らかの外部の同期メカニズムが提供されていない限り、バッファーは同時に持つことができるコンシューマーは 1 つです。 バッファーのアクティブなコンシューマーは必ずしもバッファーの所有者ではありません。

  • リース。 リースは、特定のコンポーネントがバッファーの消費者になることができる時間の長さです。

これら 3 つの概念を示す疑似コード例を次に示します。 擬似コード内の Buffer は、Char 型の Memory<T> またはSpan<T> のバッファーを表します。 Main メソッドはバッファーをインスタンス化し、WriteInt32ToBuffer メソッドを呼び出して整数の文字列表現をバッファーに書き込み、次に DisplayBufferToConsole メソッドを呼び出してバッファーの値を表示します。

using System;

class Program
{
    // Write 'value' as a human-readable string to the output buffer.
    void WriteInt32ToBuffer(int value, Buffer buffer);

    // Display the contents of the buffer to the console.
    void DisplayBufferToConsole(Buffer buffer);

    // Application code
    static void Main()
    {
        var buffer = CreateBuffer();
        try
        {
            int value = Int32.Parse(Console.ReadLine());
            WriteInt32ToBuffer(value, buffer);
            DisplayBufferToConsole(buffer);
        }
        finally
        {
            buffer.Destroy();
        }
    }
}

Main メソッドによってバッファーが作成され、その所有者も作成されます。 そのため、Main には使用されなくなったバッファーを破棄する責任があります。 擬似コードは、バッファーで Destroy メソッドを呼び出すことによってこれを示します。 (実際には、Memory<T> にも Span<T> にも Destroy メソッドはありません。実際のコード例については、この記事の後の方で取り上げます。)

バッファーには WriteInt32ToBufferDisplayBufferToConsole という 2 つのコンシューマーがあります。 同時に存在するコンシューマーは 1 つのみであり (最初は WriteInt32ToBuffer、次に DisplayBufferToConsole)、どちらのコンシューマーもバッファーを所有していません。 この文脈における "コンシューマー" は、バッファーの読み取り専用ビューを意味していない点にも注意してください。バッファーの読み取り/書き込みビューがある場合、コンシューマーは WriteInt32ToBuffer と同様にバッファーの内容を変更できます。

メソッド呼び出しの開始からメソッドから返されるまでの間に、WriteInt32ToBuffer メソッドはバッファー上にリースを持ちます (消費することができます)。 同様に、DisplayBufferToConsole は実行中にバッファー上にリースを持ち、メソッドがアンワインドするとリースは解放されます (リース管理のための API はありません。"リース" は概念的なものです)。

Memory<T> と所有者/コンシューマー モデル

所有者、コンシューマー、有効期間管理」セクションで説明したように、バッファーには常に所有者がいます。 .NET は 2 つの所有モデルをサポートしています:

  • 単一の所有権をサポートするモデル。 バッファーは、その有効期間全体にわたって単一の所有者を持ちます。

  • 所有権の譲渡をサポートするモデル。 バッファーの所有権は、元の所有者 (作成者) から別のコンポーネントに譲渡できます。譲渡されたコンポーネントがバッファーの有効期間管理を担当するようになります。 さらにその所有者が所有権を別のコンポーネントに譲渡することもできます。

明示的にバッファーの所有権を管理するには、System.Buffers.IMemoryOwner<T> インターフェイスを使用します。 IMemoryOwner<T> は両方の所有権モデルをサポートしています。 IMemoryOwner<T> の参照を持つコンポーネントがバッファーを所有します。 次の例では、IMemoryOwner<T> インスタンスを使用して Memory<T> バッファーの所有権を反映しています。

using System;
using System.Buffers;

class Example
{
    static void Main()
    {
        IMemoryOwner<char> owner = MemoryPool<char>.Shared.Rent();

        Console.Write("Enter a number: ");
        try
        {
            string? s = Console.ReadLine();

            if (s is null)
                return;

            var value = Int32.Parse(s);

            var memory = owner.Memory;

            WriteInt32ToBuffer(value, memory);

            DisplayBufferToConsole(owner.Memory.Slice(0, value.ToString().Length));
        }
        catch (FormatException)
        {
            Console.WriteLine("You did not enter a valid number.");
        }
        catch (OverflowException)
        {
            Console.WriteLine($"You entered a number less than {Int32.MinValue:N0} or greater than {Int32.MaxValue:N0}.");
        }
        finally
        {
            owner?.Dispose();
        }
    }

    static void WriteInt32ToBuffer(int value, Memory<char> buffer)
    {
        var strValue = value.ToString();

        var span = buffer.Span;
        for (int ctr = 0; ctr < strValue.Length; ctr++)
            span[ctr] = strValue[ctr];
    }

    static void DisplayBufferToConsole(Memory<char> buffer) =>
        Console.WriteLine($"Contents of the buffer: '{buffer}'");
}

この例を usingステートメント と記述することもできます:

using System;
using System.Buffers;

class Example
{
    static void Main()
    {
        using (IMemoryOwner<char> owner = MemoryPool<char>.Shared.Rent())
        {
            Console.Write("Enter a number: ");
            try
            {
                string? s = Console.ReadLine();

                if (s is null)
                    return;

                var value = Int32.Parse(s);

                var memory = owner.Memory;
                WriteInt32ToBuffer(value, memory);
                DisplayBufferToConsole(memory.Slice(0, value.ToString().Length));
            }
            catch (FormatException)
            {
                Console.WriteLine("You did not enter a valid number.");
            }
            catch (OverflowException)
            {
                Console.WriteLine($"You entered a number less than {Int32.MinValue:N0} or greater than {Int32.MaxValue:N0}.");
            }
        }
    }

    static void WriteInt32ToBuffer(int value, Memory<char> buffer)
    {
        var strValue = value.ToString();

        var span = buffer.Slice(0, strValue.Length).Span;
        strValue.AsSpan().CopyTo(span);
    }

    static void DisplayBufferToConsole(Memory<char> buffer) =>
        Console.WriteLine($"Contents of the buffer: '{buffer}'");
}

このコードの場合:

  • Main メソッドは IMemoryOwner<T> インスタンスへの参照を保持しているため、Main メソッドはバッファーの所有者です。

  • WriteInt32ToBuffer および DisplayBufferToConsole メソッドは、Memory<T> をパブリック API として受け入れます。 そのため、これらはバッファーの消費者です。 これらのメソッドは、一度に 1 つずつバッファーを使用します。

WriteInt32ToBuffer メソッドはバッファーに値を書き込むことが意図されていますが、DisplayBufferToConsole メソッドではそう意図されていません。 これを反映するために、型 ReadOnlyMemory<T> の引数を受け入れておくことができます。 ReadOnlyMemory<T> の詳細については、「規則 2: バッファーを読み取り専用にする場合は ReadOnlySpan<T> または ReadOnlyMemory<T> を使用する」を参照してください。

"所有者なしの" Memory<T> インスタンス

IMemoryOwner<T> を使用せずに Memory<T> インスタンスを作成できます。 この場合、バッファーの所有権は明示的ではなく暗黙的であり、単一の所有者モデルのみがサポートされます。 これには、次の方法を使用します。

  • 次の例のように、Memory<T> コンストラクターのいずれかを直接呼び出し、T[] を渡します。

  • String.AsMemory 拡張メソッドを呼び出して ReadOnlyMemory<char> インスタンスを生成します。

using System;

class Example
{
    static void Main()
    {
        Memory<char> memory = new char[64];

        Console.Write("Enter a number: ");
        string? s = Console.ReadLine();

        if (s is null)
            return;

        var value = Int32.Parse(s);

        WriteInt32ToBuffer(value, memory);
        DisplayBufferToConsole(memory);
    }

    static void WriteInt32ToBuffer(int value, Memory<char> buffer)
    {
        var strValue = value.ToString();
        strValue.AsSpan().CopyTo(buffer.Slice(0, strValue.Length).Span);
    }

    static void DisplayBufferToConsole(Memory<char> buffer) =>
        Console.WriteLine($"Contents of the buffer: '{buffer}'");
}

最初に Memory<T> インスタンスを作成したメソッドは、バッファーの暗黙的な所有者です。 譲渡を支援する IMemoryOwner<T> インスタンスはないため、所有権を他のコンポーネントに譲渡することはできません (または、ランタイムのガベージ コレクターがバッファーを所有していて、すべてのメソッドがバッファーを消費するだけであると想像することもできます)。

使用のガイドライン

メモリ ブロックは所有されていますが、複数のコンポーネントに渡されることが意図されており、その一部は特定のメモリ ブロック上で同時に動作する可能性があるため、Memory<T>Span<T> の両方を使用するためのガイドラインを確立することが重要です。 コンポーネントで次の場合があるため、ガイドラインが必要です:

  • 所有者がメモリ ブロックを解放した後に、コンポーネントがメモリ ブロックへの参照を保持します。

  • あるコンポーネントがバッファーに対して動作し、それ同時に他のコンポーネントがそのバッファーに対して動作し、そのようなプロセスでは、バッファー内のデータが破損します。

  • スタックに割り当てられる Span<T> の性質によってパフォーマンスが最適化され、Span<T> はメモリ ブロック上での操作に適した型になりますが、Span<T> にはいくつかの大きな制限もあります。 Span<T> を使用するタイミングと、Memory<T> を使用するタイミングを把握することが重要です。

以下は、Memory<T> とその関連する型を正しく使用するためのレコメンデーションです。 明記しない限り、Memory<T>Span<T> に適用されるガイダンスは ReadOnlyMemory<T>ReadOnlySpan<T> にも適用されます。

規則 1: 同期 API の場合、可能であればパラメーターとして Memory<T> ではなく Span<T> を使用する。

Span<T>Memory<T> よりも汎用性が高く、さまざまな連続するメモリ バッファーを表すことができます。 Span<T>Memory<T>> よりもパフォーマンスに優れています。 最後に、Memory<T>.Span プロパティを使用して Memory<T> インスタンスを Span<T> に変換することはできますが、Span<T> から Memory<T> に変換することはできません。 そのため、呼び出し元が Memory<T> インスタンスを持っていた場合、いずれにしても Span<T> パラメーターを使用してメソッドを呼び出すことができます。

Memory<T> ではなく型 Span<T> のパラメーターを使用すると、適切な消費メソッドの実装を記述する場合にも役立ちます。 メソッドのリース時間を過ぎてバッファーにアクセスを試行しないように、コンパイル時のチェックが自動的に実行されます (詳細については後述します)。

完全に同期していても、Span<T> パラメーターではなく Memory<T> パラメーターの使用が必要な場合があります。 おそらく、利用している API は Memory<T> 引数のみを受け入れます。 これは問題ありませんが、Memory<T> を同期的に使用するときに伴うトレードオフに注意してください。

規則 2: バッファーを読み取り専用にする場合は ReadOnlySpan<T> または ReadOnlyMemory<T> を使用する。

前述の例では、DisplayBufferToConsole メソッドはバッファーからの読み取りのみを行います。バッファーの内容は変更しません。 メソッドのシグネチャは次のように変更する必要があります。

void DisplayBufferToConsole(ReadOnlyMemory<char> buffer);

実際、この規則と規則 1 を組み合わせると、さらに改善され、メソッドのシグネチャを次のように書き換えることができます。

void DisplayBufferToConsole(ReadOnlySpan<char> buffer);

DisplayBufferToConsole メソッドは、考えられるほぼすべてのバッファー型 (T[]stackalloc で割り当てられたストレージなど) で動作します。 String を直接渡すこともできます。 詳細については、GitHub イシュー dotnet/docs #25551 を参照してください。

規則 3: メソッドが Memory<T> を受け入れて void を返した場合、メソッドが返した後に Memory<T> インスタンスを使用してはならない。

これは、前述した "リース" の概念に関連しています。 Memory<T> インスタンスに対する void を返すメソッドのリースは、メソッドが開始時に開始され、メソッドの終了時に終了します。 コンソールからの入力に基づいてループで Log を呼び出す次の例を考えてみましょう。

using System;
using System.Buffers;

public class Example
{
    // implementation provided by third party
    static extern void Log(ReadOnlyMemory<char> message);

    // user code
    public static void Main()
    {
        using (var owner = MemoryPool<char>.Shared.Rent())
        {
            var memory = owner.Memory;
            var span = memory.Span;
            while (true)
            {
                string? s = Console.ReadLine();

                if (s is null)
                    return;

                int value = Int32.Parse(s);
                if (value < 0)
                    return;

                int numCharsWritten = ToBuffer(value, span);
                Log(memory.Slice(0, numCharsWritten));
            }
        }
    }

    private static int ToBuffer(int value, Span<char> span)
    {
        string strValue = value.ToString();
        int length = strValue.Length;
        strValue.AsSpan().CopyTo(span.Slice(0, length));
        return length;
    }
}

Log が完全同期メソッドである場合、メモリ インスタンスのアクティブなコンシューマーは常に 1 つのみなので、このコードは予想どおりに動作します。 ただし、代わりに Log がこの実装を持っている場合を想像してください。

// !!! INCORRECT IMPLEMENTATION !!!
static void Log(ReadOnlyMemory<char> message)
{
    // Run in background so that we don't block the main thread while performing IO.
    Task.Run(() =>
    {
        StreamWriter sw = File.AppendText(@".\input-numbers.dat");
        sw.WriteLine(message);
    });
}

この実装では、元のメソッドから返された後も、Log はバックグラウンドで Memory<T> インスタンスを使用しようとするため、リースに違反することになります。 Main メソッドがバッファーを変更しているときに、Log がバッファーを読み込もうとすると、データが破損する可能性があります。

これを解決する方法はいくつかあります。

  • 以下の Log メソッドの実装のように、Log メソッドは void ではなく Task を返す可能性があります。

    // An acceptable implementation.
    static Task Log(ReadOnlyMemory<char> message)
    {
        // Run in the background so that we don't block the main thread while performing IO.
        return Task.Run(() => {
            StreamWriter sw = File.AppendText(@".\input-numbers.dat");
            sw.WriteLine(message);
            sw.Flush();
        });
    }
    
  • Log は代わりに次のように実装できます。

    // An acceptable implementation.
    static void Log(ReadOnlyMemory<char> message)
    {
        string defensiveCopy = message.ToString();
        // Run in the background so that we don't block the main thread while performing IO.
        Task.Run(() =>
        {
            StreamWriter sw = File.AppendText(@".\input-numbers.dat");
            sw.WriteLine(defensiveCopy);
            sw.Flush();
        });
    }
    

規則 4: メソッドが Memory<T> を受け入れて Task を返す場合、Task が終了状態に遷移した後に Memory<T> インスタンスを使用してはならない。

これは規則 3 の単なる非同期版です。 前の例の Log メソッドは、この規則に従うために次のように記述することができます。

// An acceptable implementation.
static Task Log(ReadOnlyMemory<char> message)
{
    // Run in the background so that we don't block the main thread while performing IO.
    return Task.Run(() =>
    {
        StreamWriter sw = File.AppendText(@".\input-numbers.dat");
        sw.WriteLine(message);
        sw.Flush();
    });
}

ここで、"終了状態" とは、タスクの状態が完了、失敗、またはキャンセル済みに移行することを意味します。 つまり、"終了状態" とは、"スローまたは実行継続の待機を発生させるすべてのもの" を意味します。

このガイダンスは、TaskTask<TResult>ValueTask<TResult>、または同様の型を返すメソッドに適用されます。

規則 5: コンストラクターがパラメーターとして Memory<T> を受け入れる場合、構築されたオブジェクトのインスタンス メソッドは Memory<T> インスタンスのコンシューマーであると見なされる。

次の例を考えてみましょう。

class OddValueExtractor
{
    public OddValueExtractor(ReadOnlyMemory<int> input);
    public bool TryReadNextOddValue(out int value);
}

void PrintAllOddValues(ReadOnlyMemory<int> input)
{
    var extractor = new OddValueExtractor(input);
    while (extractor.TryReadNextOddValue(out int value))
    {
      Console.WriteLine(value);
    }
}

ここで、OddValueExtractor コンストラクターは ReadOnlyMemory<int> をコンストラクター パラメーターとして受け入れます。そのため、コンストラクター自体が ReadOnlyMemory<int> インスタンスのコンシューマーであり、戻り値に対するすべてのインスタンス メソッドも元の ReadOnlyMemory<int> インスタンスのコンシューマーです。 つまり、インスタンスが TryReadNextOddValue メソッドに直接渡されない場合でも、TryReadNextOddValueReadOnlyMemory<int> インスタンスを消費します。

規則 6: 型に設定できる Memory<T> 型のプロパティ (または同等のインスタンス メソッド) がある場合、そのオブジェクトに対するインスタンス メソッドは Memory<T> インスタンスのコンシューマーであると見なされる。

これは単に規則 5 の一種です。 この規則が存在する理由は、プロパティ セッターまたは同等のメソッドは入力をキャプチャして永続化すると想定されているため、同じオブジェクトに対するインスタンス メソッドがキャプチャした状態を利用する可能性があるためです。

この規則をトリガーする例を次に示します。

class Person
{
    // Settable property.
    public Memory<char> FirstName { get; set; }

    // alternatively, equivalent "setter" method
    public SetFirstName(Memory<char> value);

    // alternatively, a public settable field
    public Memory<char> FirstName;
}

規則 7: IMemoryOwner<T> 参照がある場合、どこかの時点でそれを破棄するか所有権を譲渡する必要がある (両方を実行する必要はない)。

Memory<T> インスタンスはマネージド メモリまたはアンマネージド メモリのいずれかでサポートされている可能性があるため、Memory<T> インスタンスに対して実行された処理が完了したら、所有者は IMemoryOwner<T>Dispose を呼び出す必要があります。 または、所有者が IMemoryOwner<T> インスタンスの所有権を別のコンポーネントに譲渡することもできます。譲渡の時点で、獲得した側のコンポーネントは適切なタイミングで Dispose を呼び出す責任を負います (詳細については後述します)。

IMemoryOwner<T> インスタンスでの Dispose メソッドの呼び出しに失敗すると、マネージド メモリのリークやその他のパフォーマンス低下が発生する可能性があります。

この規則は、MemoryPool<T>.Rent のようなファクトリ メソッドを呼び出すコードにも適用されます。 呼び出し元は、返された IMemoryOwner<T> の所有者になり、終了時にインスタンスを破棄する責任を負います。

規則 8: API サーフェスに IMemoryOwner<T> パラメーターがある場合、そのインスタンスの所有権を受け入れていることを示す。

この種類のシグナルを持つインスタンスを受け入れることは、コンポーネントがこのインスタンスの所有権を取得しようとしていることを示します。 規則 7 に従い、コンポーネントは適切な破棄の責任を負うようになります。

IMemoryOwner<T> インスタンスの所有権を別のコンポーネントに譲渡したコンポーネントは、メソッド呼び出しの完了後にそのインスタンスを使用できなくなります。

重要

コンストラクターでパラメーターとして IMemoryOwner<T> が受け入れられる場合は、その型で IDisposable が実装されている必要があり、Dispose メソッドで IMemoryOwner<T> オブジェクトの Dispose を呼び出す必要があります。

規則 9: 同期的 p/invoke メソッドをラップしている場合、API は Span<T> をパラメーターとして受け入れる必要がある。

規則 1 に従うと、Span<T> は一般に同期的 API に使用するために適した型です。 次の例のように、fixed キーワードを介して Span<T> インスタンスを固定できます。

using System.Runtime.InteropServices;

[DllImport(...)]
private static extern unsafe int ExportedMethod(byte* pbData, int cbData);

public unsafe int ManagedWrapper(Span<byte> data)
{
    fixed (byte* pbData = &MemoryMarshal.GetReference(data))
    {
        int retVal = ExportedMethod(pbData, data.Length);

        /* error checking retVal goes here */

        return retVal;
    }
}

前の例では、入力の範囲が空の場合などに、pbData が null になる可能性があります。 cbData が 0 であっても、エクスポートされたメソッドで pbData を null 以外にする必要がある場合、このメソッドは次のように実装できます。

public unsafe int ManagedWrapper(Span<byte> data)
{
    fixed (byte* pbData = &MemoryMarshal.GetReference(data))
    {
        byte dummy = 0;
        int retVal = ExportedMethod((pbData != null) ? pbData : &dummy, data.Length);

        /* error checking retVal goes here */

        return retVal;
    }
}

規則 10: 非同期的 p/invoke メソッドをラップしている場合、API は Memory<T> をパラメーターとして受け入れる必要がある。

非同期操作で fixed キーワードは使用できないので、インスタンスが表す連続するメモリの種類に関係なく、Memory<T>.Pin メソッドを使用して Memory<T> インスタンスを固定します。 次の例は、この API を使用して非同期の p/invoke 呼び出しを実行する方法を示しています。

using System.Runtime.InteropServices;

[UnmanagedFunctionPointer(...)]
private delegate void OnCompletedCallback(IntPtr state, int result);

[DllImport(...)]
private static extern unsafe int ExportedAsyncMethod(byte* pbData, int cbData, IntPtr pState, IntPtr lpfnOnCompletedCallback);

private static readonly IntPtr _callbackPtr = GetCompletionCallbackPointer();

public unsafe Task<int> ManagedWrapperAsync(Memory<byte> data)
{
    // setup
    var tcs = new TaskCompletionSource<int>();
    var state = new MyCompletedCallbackState
    {
        Tcs = tcs
    };
    var pState = (IntPtr)GCHandle.Alloc(state);

    var memoryHandle = data.Pin();
    state.MemoryHandle = memoryHandle;

    // make the call
    int result;
    try
    {
        result = ExportedAsyncMethod((byte*)memoryHandle.Pointer, data.Length, pState, _callbackPtr);
    }
    catch
    {
        ((GCHandle)pState).Free(); // cleanup since callback won't be invoked
        memoryHandle.Dispose();
        throw;
    }

    if (result != PENDING)
    {
        // Operation completed synchronously; invoke callback manually
        // for result processing and cleanup.
        MyCompletedCallbackImplementation(pState, result);
    }

    return tcs.Task;
}

private static void MyCompletedCallbackImplementation(IntPtr state, int result)
{
    GCHandle handle = (GCHandle)state;
    var actualState = (MyCompletedCallbackState)(handle.Target);
    handle.Free();
    actualState.MemoryHandle.Dispose();

    /* error checking result goes here */

    if (error)
    {
        actualState.Tcs.SetException(...);
    }
    else
    {
        actualState.Tcs.SetResult(result);
    }
}

private static IntPtr GetCompletionCallbackPointer()
{
    OnCompletedCallback callback = MyCompletedCallbackImplementation;
    GCHandle.Alloc(callback); // keep alive for lifetime of application
    return Marshal.GetFunctionPointerForDelegate(callback);
}

private class MyCompletedCallbackState
{
    public TaskCompletionSource<int> Tcs;
    public MemoryHandle MemoryHandle;
}

関連項目