Interfejsy struktury ref

Notatka

Ten artykuł jest specyfikacją funkcji. Specyfikacja służy jako dokument projektowy dla funkcji. Zawiera proponowane zmiany specyfikacji wraz z informacjami wymaganymi podczas projektowania i opracowywania funkcji. Te artykuły są publikowane do momentu sfinalizowania proponowanych zmian specyfikacji i włączenia ich do obecnej specyfikacji ECMA.

Mogą wystąpić pewne rozbieżności między specyfikacją funkcji a ukończoną implementacją. Te różnice są przechwytywane w odpowiednich spotkania projektowego języka (LDM).

Więcej informacji na temat procesu wdrażania specyfikacji funkcji można znaleźć w standardzie języka C# w artykule dotyczącym specyfikacji .

Kwestia dotycząca mistrza: https://github.com/dotnet/csharplang/issues/7608

Streszczenie

Ten wniosek rozszerzy możliwości ref struct, tak aby mogły implementować interfejsy i uczestniczyć jako argumenty typu ogólnego.

Motywacja

Niezdolność ref struct do implementowania interfejsów oznacza, że nie mogą uczestniczyć w dość podstawowych technikach abstrakcji platformy .NET. Span<T>, mimo że ma wszystkie atrybuty listy sekwencyjnej, nie mogą uczestniczyć w metodach, które przyjmują IReadOnlyList<T>, IEnumerable<T>itp. Zamiast tego należy kodować konkretne metody dla Span<T>, które mają praktycznie tę samą implementację. Zezwolenie ref struct na implementowanie interfejsów pozwoli na abstrakcję operacji w podobny sposób, jak ma to miejsce dla innych typów.

Szczegółowy projekt

interfejsy dla struktur ref

Język będzie zezwalał na implementowanie interfejsów przez typy ref struct. Składnia i reguły są takie same jak w przypadku normalnych struct z kilkoma wyjątkami, które uwzględniają ograniczenia typów ref struct.

Możliwość implementowania interfejsów nie ma wpływu na istniejące ograniczenia dotyczące wystąpień ref struct boxingu. Oznacza to, że nawet jeśli ref struct implementuje określony interfejs, nie można go bezpośrednio rzutować, ponieważ reprezentuje akcję boksu.

ref struct File : IDisposable
{
    private SafeHandle _handle;
    public void Dispose()
    {
        _handle.Dispose();
    }
}

File f = ...;
// Error: cannot box `ref struct` type `File`
IDisposable d = f;

Możliwość implementowania interfejsów jest przydatna tylko w połączeniu z zdolnością ref struct do udziału w argumentach generycznych (jak określa później ).

Aby umożliwić interfejsom pełne pokrycie ekspresji ref struct oraz problemów związanych z cyklem życia, język pozwoli na użycie [UnscopedRef] w metodach i właściwościach interfejsu. Jest to konieczne, ponieważ pozwala interfejsom, które abstrakcyjnie obsługują struct, mieć taką samą elastyczność jak przy bezpośrednim użyciu struct. Rozważmy następujący przykład:

interface I1
{
    [UnscopedRef]
    ref int P1 { get; }
    ref int P2 { get; }
}

struct S1
{
    [UnscopedRef]
    internal ref int P1 { get {...} }

    internal ref int P2 { get {...} }
}

ref int M<T>(T t, S1 s)
    where T : I1, allows ref struct
{
    // Error: may return ref to t
    return ref t.P1;

    // Error: may return ref to t
    return ref s.P1;

    // Okay
    return ref t.P2;

    // Okay
    return ref s.P2;
}

Gdy element członkowski struct / ref struct implementuje element członkowski interfejsu z atrybutem [UnscopedRef], element implementujący może być również oznakowany [UnscopedRef], ale nie jest to wymagane. Jednak członka z [UnscopedRef] nie można używać do implementacji członka, który nie ma tego atrybutu (szczegóły).

interface I1
{
    [UnscopedRef]
    ref int P1 { get; }
    ref int P2 { get; }
}

struct S1
{
    internal ref int P1 { get {...} }
    internal ref int P2 { get {...} }
}

struct S2
{
    [UnscopedRef]
    internal ref int P1 { get {...} }
    internal ref int P2 { get {...} }
}

struct S3 : I1
{
    internal ref int P1 { get {...} }
    // Error: P2 is marked with [UnscopedRef] and cannot implement I1.P2 as is not marked 
    // with [UnscopedRef]
    [UnscopedRef]
    internal ref int P2 { get {...} }
}

class C1 : I1
{
    internal ref int P1 { get {...} }
    internal ref int P2 { get {...} }
}

Domyślne metody interfejsu stanowią problem dla ref struct, ponieważ nie ma ochrony przed domyślnym polem implementacji elementu członkowskiego this.

interface I1
{
    void M()
    {
        // Danger: both of these box if I1 is implemented by a ref struct
        I1 local1 = this;
        object local2 = this;
    }
}

// Error: I1.M cannot implement interface member I1.M() for ref struct S
ref struct S : I1 { }

Aby to obsłużyć, należy wymusić implementację wszystkich członków interfejsu ref struct, nawet jeśli mają domyślne implementacje. Środowisko uruchomieniowe zostanie również zaktualizowane, aby zgłosić wyjątek, jeśli domyślny element interfejsu jest wywoływany na typie ref struct.

Aby uniknąć wyjątku w czasie wykonywania, kompilator zgłosi błąd wywołania niewirtualnej metody instancji (lub właściwości) dla parametru typu, który dopuszcza ref struct. Oto przykład:

public interface I1
{
    sealed void M3() {}
}

class C
{
    static void Test2<T>(T x) where T : I1, allows ref struct
    {
#line 100
        x.M3(); // (100,9): error: A non-virtual instance interface member cannot be accessed on a type parameter that allows ref struct.
    }
}

Istnieje również otwarte pytanie projektowe dotyczące raportowania ostrzeżenia w przypadku wywołania wirtualnej (nie abstrakcyjnej) metody instancyjnej (lub właściwości) na parametrze typu, który pozwala na strukturę ref.

Szczegółowe uwagi:

  • ref struct może implementować interfejs
  • ref struct nie może być częścią domyślnych członków interfejsu
  • Nie można rzutować ref struct do interfejsów, które implementuje, ponieważ jest to operacja boksu

ref, struktura — parametry ogólne

type_parameter_constraints_clause
    : 'where' type_parameter ':' type_parameter_constraints
    ;

type_parameter_constraints
    : restrictive_type_parameter_constraints
    | allows_type_parameter_constraints_clause
    | restrictive_type_parameter_constraints ',' allows_type_parameter_constraints_clause

restrictive_type_parameter_constraints
    : primary_constraint
    | secondary_constraints
    | constructor_constraint
    | primary_constraint ',' secondary_constraints
    | primary_constraint ',' constructor_constraint
    | secondary_constraints ',' constructor_constraint
    | primary_constraint ',' secondary_constraints ',' constructor_constraint
    ;

primary_constraint
    : class_type
    | 'class'
    | 'struct'
    | 'unmanaged'
    ;

secondary_constraints
    : interface_type
    | type_parameter
    | secondary_constraints ',' interface_type
    | secondary_constraints ',' type_parameter
    ;

constructor_constraint
    : 'new' '(' ')'
    ;

allows_type_parameter_constraints_clause
    : 'allows' allows_type_parameter_constraints

allows_type_parameter_constraints
    : allows_type_parameter_constraint
    | allows_type_parameter_constraints ',' allows_type_parameter_constraint

allows_type_parameter_constraint
    : ref_struct_clause

ref_struct_clause
    : 'ref' 'struct'

Język umożliwi korzystanie z parametrów ogólnych w celu wyrażenia zgody na obsługę ref struct jako argumentów przy użyciu składni allows ref struct wewnątrz klauzuli where:

T Identity<T>(T p)
    where T : allows ref struct
    => p;

// Okay
Span<int> local = Identity(new Span<int>(new int[10]));

Jest to podobne do innych elementów w klauzuli where, że określa możliwości parametru ogólnego. Różnica polega na tym, że inne elementy składni ograniczają zestaw typów, które mogą spełniać parametr ogólny, podczas gdy allows ref struct rozszerza zestaw typów. Jest to skutecznie antyograniczenie, ponieważ usuwa niejawne ograniczenie, które uniemożliwia ref struct spełnienie ogólnego parametru. W związku z tym otrzymujemy nowy prefiks składni, allows, aby to było bardziej zrozumiałe.

Parametr typu powiązany przez allows ref struct ma wszystkie zachowania typu ref struct:

  1. Nie można umieścić wystąpień w pudełku
  2. Instancje uczestniczą w zasadach cyklu życia, podobnie jak normalny ref struct.
  3. Parametru typu nie można użyć w polach static, elementach tablicy i tym podobnych.
  4. Wystąpienia oznaczyć można scoped

Przykłady tych reguł w działaniu:

interface I1 { }
I1 M1<T>(T p)
    where T : I1, allows ref struct
{
    // Error: cannot box potential ref struct
    return p;
}

T M2<T>(T p)
    where T : allows ref struct
{
    Span<int> span = stackalloc int[42];

    // The safe-to-escape of the return is current method because one of the inputs is
    // current method
    T t = M3<int, T>(span);

    // Error: the safe-to-escape is current method.
    return t;

    // Okay
    return default;
    return p;
}

R M3<T, R>(Span<T> span)
    where R : allows ref struct
{
    return default;
}

Ograniczenie przeciwdziałające nie jest "dziedziczone" z ograniczeń typu parametru. Na przykład S w poniższym kodzie nie można zastąpić strukturą ref:

class C<T, S>
    where T : allows ref struct
    where S : T
{}

Szczegółowe uwagi:

  • Nie można określić where T : allows ref struct parametru ogólnego
    • Miej where T : U, gdzie U jest znanym typem referencyjnym
    • Mają ograniczenie where T : class
    • Nie można użyć jako argumentu ogólnego, chyba że odpowiedni parametr jest również where T: allows ref struct
  • allows ref struct musi być ostatnim ograniczeniem w klauzuli where
  • Parametr typu T, który ma allows ref struct ma wszystkie te same ograniczenia co typ ref struct.

Reprezentacja w metadanych

Parametry typu zezwalające na struktury odwołujące się będą kodowane w metadanych zgodnie z dokumentacją dotyczącą ogólnych typów przypominających byref zawartą w . Stosując w szczególności wartość flagi CorGenericParamAttr.gpAllowByRefLike(0x0020) lub System.Reflection.GenericParameterAttributes.AllowByRefLike(0x0020). Określa, czy środowisko uruchomieniowe obsługuje tę funkcję, sprawdzając obecność pola System.Runtime.CompilerServices.RuntimeFeature.ByRefLikeGenerics. API zostały dodane w https://github.com/dotnet/runtime/pull/98070.

using, instrukcja

Instrukcja using rozpozna i użyje implementacji interfejsu IDisposable, gdy zasób jest strukturą ref.

ref struct S2 : System.IDisposable
{
    void System.IDisposable.Dispose()
    {
    }
}

class C
{
    static void Main()
    {
        using (new S2())
        {
        } // S2.System.IDisposable.Dispose is called
    }
}

Należy pamiętać, że preferencja jest przyznawana metodzie Dispose, która implementuje wzorzec, a dopiero gdy taka metoda nie zostanie znaleziona, używana jest implementacja IDisposable.

Instrukcja using rozpozna i użyje implementacji interfejsu IDisposable, gdy zasób jest parametrem typu, który ma allows ref struct i IDisposable w swoim zbiorze efektywnych interfejsów.

class C
{
    static void Test<T>(T t) where T : System.IDisposable, allows ref struct
    {
        using (t)
        {
        }
    }
}

Należy pamiętać, że metoda dla wzorca Dispose nie zostanie rozpoznana w parametrze typu, który allows ref struct, ponieważ interfejs (i jest to jedyne miejsce, w którym możemy szukać wzorca) nie jest strukturą ref.

interface IMyDisposable
{
    void Dispose();
}
class C
{
    static void Test<T>(T t, IMyDisposable s) where T : IMyDisposable, allows ref struct
    {
        using (t) // Error, the pattern is not recognized
        {
        }

        using (s) // Error, the pattern is not recognized
        {
        }
    }
}

await using, instrukcja

Obecnie język nie zezwala na używanie struktur ref jako zasobów w instrukcji await using. To samo ograniczenie zostanie zastosowane do parametru typu, który allows ref struct.

Istnieje propozycja zniesienia ogólnych ograniczeń dotyczących użycia struktur ref w metodach asynchronicznych — https://github.com/dotnet/csharplang/pull/7994. W pozostałej części sekcji opisano zachowanie po tym, jak ogólne ograniczenie dla instrukcji await using zostanie zniesione, jeśli lub kiedy to nastąpi.

Instrukcja await using rozpozna i użyje implementacji interfejsu IAsyncDisposable, gdy zasób jest strukturą ref.

ref struct S2 : IAsyncDisposable
{
    ValueTask IAsyncDisposable.DisposeAsync()
    {
    }
}

class C
{
    static async Task Main()
    {
        await using (new S2())
        {
        } // S2.IAsyncDisposable.DisposeAsync
    }
}

Należy pamiętać, że preferencja jest przyznawana metodzie DisposeAsync, która implementuje wzorzec, a dopiero gdy taka metoda nie zostanie znaleziona, używana jest implementacja IAsyncDisposable.

Wzorzec metody DisposeAsync będzie rozpoznawany na parametrze typu allows ref struct, tak jak jest rozpoznawany na parametrach typu bez takiego ograniczenia obecnie.

interface IMyAsyncDisposable
{
    ValueTask DisposeAsync();
}

class C
{
    static async Task Test<T>() where T : IMyAsyncDisposable, new(), allows ref struct
    {
        await using (new T())
        {
        } // IMyAsyncDisposable.DisposeAsync
    }
}

Instrukcja using rozpozna i użyje implementacji interfejsu IAsyncDisposable, gdy zasób jest parametrem typu, który spełnia warunek allows ref struct, proces wyszukiwania metody wzorca DisposeAsync zakończył się niepowodzeniem, a IAsyncDisposable jest częścią efektywnych interfejsów typu.

interface IMyAsyncDisposable1
{
    ValueTask DisposeAsync();
}

interface IMyAsyncDisposable2
{
    ValueTask DisposeAsync();
}

class C
{
    static async Task Test<T>() where T : IMyAsyncDisposable1, IMyAsyncDisposable2, IAsyncDisposable, new(), allows ref struct
    {
        await using (new T())
        {
            System.Console.Write(123);
        } // IAsyncDisposable.DisposeAsync
    }
}

foreach, instrukcja

Należy odpowiednio zaktualizować sekcję https://github.com/dotnet/csharpstandard/blob/standard-v6/standard/statements.md#1295-the-foreach-statement, aby uwzględnić następujące elementy.

Instrukcja foreach rozpozna i użyje implementacji interfejsu IEnumerable<T>/IEnumerable, gdy kolekcja jest strukturą ref.

ref struct S : IEnumerable<int>
{
    IEnumerator<int> IEnumerable<int>.GetEnumerator() {...}
    System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() {...}
}

class C
{
    static void Main()
    {
        foreach (var i in new S()) // IEnumerable<int>.GetEnumerator
        {
        }
    }
}

Wzorzec metody GetEnumerator będzie rozpoznawany na parametrze typu allows ref struct, tak jak jest rozpoznawany na parametrach typu bez takiego ograniczenia obecnie.

interface IMyEnumerable<T>
{
    IEnumerator<T> GetEnumerator();
}

class C
{
    static void Test<T>(T t) where T : IMyEnumerable<int>, allows ref struct
    {
        foreach (var i in t) // IMyEnumerable<int>.GetEnumerator
        {
        }
    }
}

Instrukcja foreach rozpozna i użyje implementacji interfejsu IEnumerable<T>/IEnumerable, gdy kolekcja jest parametrem typu, który allows ref struct, proces wyszukiwania metody wzorca GetEnumerator zakończył się niepowodzeniem, a IEnumerable<T>/IEnumerable znajduje się w zbiorze efektywnych interfejsów parametru typu.

interface IMyEnumerable1<T>
{
    IEnumerator<int> GetEnumerator();
}

interface IMyEnumerable2<T>
{
    IEnumerator<int> GetEnumerator();
}

class C
{
    static void Test<T>(T t) where T : IMyEnumerable1<int>, IMyEnumerable2<int>, IEnumerable<int>, allows ref struct
    {
        foreach (var i in t) // IEnumerable<int>.GetEnumerator
        {
        }
    }
}

Wzorzec enumerator zostanie rozpoznany dla parametru typu, który allows ref struct, tak jak jest rozpoznawany dla parametrów typu bez tego ograniczenia.

interface IGetEnumerator<TEnumerator> where TEnumerator : allows ref struct 
{
    TEnumerator GetEnumerator();
}

class C
{
    static void Test1<TEnumerable, TEnumerator>(TEnumerable t)
        where TEnumerable : IGetEnumerator<TEnumerator>, allows ref struct
        where TEnumerator : IEnumerator, IDisposable, allows ref struct 
    {
        foreach (var i in t) // IEnumerator.MoveNext/Current
        {
        }
    }

    static void Test2<TEnumerable, TEnumerator>(TEnumerable t)
        where TEnumerable : IGetEnumerator<TEnumerator>, allows ref struct
        where TEnumerator : IEnumerator<int>, allows ref struct 
    {
        foreach (var i in t) // IEnumerator<int>.MoveNext/Current
        {
        }
    }

    static void Test3<TEnumerable, TEnumerator>(TEnumerable t)
        where TEnumerable : IGetEnumerator<TEnumerator>, allows ref struct
        where TEnumerator : IMyEnumerator<int>, allows ref struct 
    {
        foreach (var i in t) // IMyEnumerator<int>.MoveNext/Current
        {
        }
    }
}

interface IMyEnumerator<T> : System.IDisposable
{
    T Current {get;}
    bool MoveNext();
}

Instrukcja foreach rozpoznaje i używa implementacji interfejsu IDisposable, gdy moduł wyliczający jest strukturą ref.

struct S1
{
    public S2 GetEnumerator()
    {
        return new S2();
    }
}

ref struct S2 : System.IDisposable
{
    public int Current {...}
    public bool MoveNext() {...}
    void System.IDisposable.Dispose() {...}
}

class C
{
    static void Main()
    {
        foreach (var i in new S1())
        {
        } // S2.System.IDisposable.Dispose()
    }
}

Należy pamiętać, że preferencja jest przyznawana metodzie Dispose, która implementuje wzorzec, a dopiero gdy taka metoda nie zostanie znaleziona, używana jest implementacja IDisposable.

Instrukcja foreach rozpozna i użyje implementacji interfejsu IDisposable, gdy moduł wyliczający jest parametrem typu, którego allows ref struct oraz IDisposable znajdują się w jego efektywnych interfejsach.

interface ICustomEnumerator
{
    int Current {get;}
    bool MoveNext();
}

interface IGetEnumerator<TEnumerator> where TEnumerator : allows ref struct 
{
    TEnumerator GetEnumerator();
}

class C
{
    static void Test<TEnumerable, TEnumerator>(TEnumerable t)
        where TEnumerable : IGetEnumerator<TEnumerator>
        where TEnumerator : ICustomEnumerator, System.IDisposable, allows ref struct 
    {
        foreach (var i in t)
        {
        } // System.IDisposable.Dispose()
    }
}

Należy pamiętać, że metoda dla wzorca Dispose nie zostanie rozpoznana w parametrze typu, który allows ref struct, ponieważ interfejs (i jest to jedyne miejsce, w którym możemy szukać wzorca) nie jest strukturą ref. Ponadto, ponieważ środowisko uruchomieniowe nie umożliwia sprawdzania podczas wykonywania, czy parametr typu, który implementuje interfejs allows ref struct, również implementuje interfejs IDisposable, enumerator parametrów typu, który allows ref struct, będzie niedozwolony, chyba że IDisposable znajduje się w jego efektywnym zbiorze interfejsów.

interface ICustomEnumerator
{
    int Current {get;}
    bool MoveNext();
}

interface IMyDisposable
{
    void Dispose();
}

interface IGetEnumerator<TEnumerator> where TEnumerator : allows ref struct 
{
    TEnumerator GetEnumerator();
}

class C
{
    static void Test<TEnumerable, TEnumerator>(TEnumerable t)
        where TEnumerable : IGetEnumerator<TEnumerator>
        where TEnumerator : ICustomEnumerator, IMyDisposable, allows ref struct 
    {
        // error CS9507: foreach statement cannot operate on enumerators of type 'TEnumerator'
        //               because it is a type parameter that allows ref struct and
        //               it is not known at compile time to implement IDisposable.
        foreach (var i in t)
        {
        }
    }
}

await foreach, instrukcja

Należy odpowiednio zaktualizować sekcję https://github.com/dotnet/csharpstandard/blob/standard-v6/standard/statements.md#1295-the-foreach-statement, aby uwzględnić następujące elementy.

Instrukcja await foreach rozpozna i użyje implementacji interfejsu IAsyncEnumerable<T>, jeśli kolekcja jest strukturą ref.

ref struct S : IAsyncEnumerable<int>
{
    IAsyncEnumerator<int> IAsyncEnumerable<int>.GetAsyncEnumerator(CancellationToken token) {...}
}

class C
{
    static async Task Main()
    {
        await foreach (var i in new S()) // S.IAsyncEnumerable<int>.GetAsyncEnumerator
        {
        }
    }
}

Wzorzec metody GetAsyncEnumerator będzie rozpoznawany na parametrze typu allows ref struct, tak jak jest rozpoznawany na parametrach typu bez takiego ograniczenia obecnie.

interface IMyAsyncEnumerable<T>
{
    IAsyncEnumerator<int> GetAsyncEnumerator(CancellationToken cancellationToken = default);
}

class C
{
    static async Task Test<T>() where T : IMyAsyncEnumerable<int>, allows ref struct
    {
        await foreach (var i in default(T)) // IMyAsyncEnumerable<int>.GetAsyncEnumerator
        {
        }
    }
}

Instrukcja await foreach rozpozna i użyje implementacji interfejsu IAsyncEnumerable<T>, gdy kolekcja jest parametrem typu powiązanym z allows ref struct, proces wyszukiwania metody wzorcowej GetAsyncEnumerator zakończył się niepowodzeniem, a IAsyncEnumerable<T> znajduje się w efektywnym zbiorze interfejsów parametru typu.

interface IMyAsyncEnumerable1<T>
{
    IAsyncEnumerator<int> GetAsyncEnumerator(CancellationToken cancellationToken = default);
}

interface IMyAsyncEnumerable2<T>
{
    IAsyncEnumerator<int> GetAsyncEnumerator(CancellationToken cancellationToken = default);
}

class C
{
    static async Task Test<T>() where T : IMyAsyncEnumerable1<int>, IMyAsyncEnumerable2<int>, IAsyncEnumerable<int>, allows ref struct
    {
        await foreach (var i in default(T)) // IAsyncEnumerable<int>.GetAsyncEnumerator
        {
            System.Console.Write(i);
        }
    }
}

Instrukcja await foreach będzie nadal uniemożliwić moduł wyliczający struktury ref i moduł wyliczający parametrów typu, który allows ref struct. Przyczyną jest to, że moduł wyliczający musi być zachowany w wywołaniach await MoveNextAsync().

Typ delegata dla funkcji anonimowej lub grupy metod

Sekcja https://github.com/dotnet/csharplang/blob/main/proposals/csharp-10.0/lambda-improvements.md#delegate-types stwierdza:

Kompilator może w przyszłości zezwolić na powiązanie większej liczby sygnatur z typami System.Action<> i System.Func<>, jeśli typy ref struct będą dozwolone jako argumenty typu, na przykład.

Action<> i Func<> typów z ograniczeniami allows ref struct ich parametrów typu będą używane w większej liczbie scenariuszy obejmujących typy ref struct w podpisie delegata.

Jeśli docelowe środowisko uruchomieniowe obsługuje ograniczenia allows ref struct, anonimowe ogólne typy delegatów będą obejmować ograniczenie allows ref struct dla swoich parametrów typu. Umożliwi to zastąpienie tych parametrów typu typami struktury ref i innymi parametrami typu na parametry z ograniczeniem allows ref struct.

Tablice liniowe

Sekcja https://github.com/dotnet/csharplang/blob/main/proposals/csharp-12.0/inline-arrays.md#detailed-design stwierdza:

Język zapewni typowo-bezpieczny i referencyjnie-bezpieczny sposób uzyskiwania dostępu do elementów typów tablic osadzonych. Dostęp będzie oparty na przedziale czasowym. To ogranicza obsługę typów tablic wewnętrznych z typami elementów, których można użyć jako argumentu typu.

Gdy typy rozpiętości są zmieniane na obsługę zakresów struktur ref, ograniczenie powinno zostać zniesione dla wbudowanych tablic struktur ref.

Poprawność

Chcielibyśmy zweryfikować poprawność zarówno ref struct antyograniczenia, jak i pojęcia antyograniczenia jako całości. W tym celu chcemy skorzystać z istniejących dowodów poprawności dostarczonych dla systemu typów języka C#. To zadanie jest łatwiejsze dzięki zdefiniowaniu nowego języka, który jest podobny do języka C#, ale bardziej regularny w budowie. Zweryfikujemy bezpieczeństwo tego modelu, a następnie określimy tłumaczenie dźwiękowe na ten język. Ponieważ ten nowy język jest skoncentrowany na ograniczeniach, nazwiemy ten język „constraint-C#”.

Podstawowy warunek bezpieczeństwa dla typu ref struct, który musi być zachowany, to fakt, że zmienne tego typu nie mogą pojawiać się na stercie. To ograniczenie można zakodować za pomocą ograniczenia. Ponieważ ograniczenia zezwalają na podstawienie, nie zabraniają go, technicznie zdefiniujemy odwrotne ograniczenie: heap. Ograniczenie heap określa, że typ może się pojawić na stercie. W ramach "constraint-C#" wszystkie typy spełniają ograniczenie heap z wyjątkiem struktur odniesienia. Ponadto wszystkie istniejące parametry typu w języku C# zostaną obniżone do parametrów typu z ograniczeniem heap w "constraint-C#".

Teraz, zakładając, że istniejący język C# jest bezpieczny, możemy przenieść reguły struktury ref języka C# do "constraint-C#".

  1. Pola klas nie mogą mieć typu struktury ref.
  2. Pola statyczne nie mogą mieć typu struktury ref.
  3. Nie można przekonwertować zmiennych typu struktury ref na struktury inne niż ref.
  4. Zmienne typu ref-struct nie mogą być zastępowane jako argumenty typu.
  5. Zmienne typu ref-struct nie mogą implementować interfejsów.

Nowe reguły mają zastosowanie do ograniczenia heap:

  1. Pola klas muszą mieć typy spełniające ograniczenie heap.
  2. Pola statyczne muszą mieć typy spełniające ograniczenie heap.
  3. Typy z ograniczeniem heap mają jedynie konwersję tożsamościową.
  4. Zmienne typu ref-struct można zastąpić tylko parametrami typu bez ograniczenia heap.
  5. Typy struktury ref mogą implementować tylko interfejsy bez domyślnych członków interfejsu.

Reguły (4) i (5) są nieco zmienione. Należy pamiętać, że reguła (4) nie musi być przenoszona dokładnie dlatego, że mamy pojęcie parametrów typu bez heap ograniczenia. Reguła (5) jest skomplikowana. Implementowanie interfejsów nie jest powszechnie niewykonalne, ale domyślne metody interfejsu oznaczają odbiornik typu interfejsu, który jest typem niewartościowym i narusza regułę (3). W związku z tym członkowie interfejsu domyślnego nie są dozwolone.

Dzięki tym regułom "constraint-C#" jest bezpieczny dla struktury ref struct, obsługuje zamianę typów i wspiera implementację interfejsu. Następnym krokiem jest tłumaczenie języka zdefiniowanego w tej propozycji, który możemy nazwać "allow-C#", na "constraint-C#". Na szczęście jest to banalne. Obniżenie jest prostą transformacją składniową. Składnia where T : allows ref struct w "allow-C#" jest równoważna w "constraint-C#" do braku ograniczeń, a brak klauzul "allow clauses" jest równoważny z ograniczeniem heap. Ponieważ semantyka abstrakcyjna i typowanie są równoważne, "allow-C#" również jest poprawne.

Istnieje jedna ostatnia właściwość, którą możemy rozważyć: czy wszystkie wpisane terminy w języku C# są również wpisywane w "constraint-C#". Innymi słowy, chcemy wiedzieć, czy każdy termin t w języku C# jest poprawnie typowany, gdy odpowiedni termin t' zostanie sprowadzony do "constraint-C#". To nie jest ograniczenie poprawności — oznacza to, że źle sformułowane terminy w naszym języku docelowym nigdy nie umożliwiłyby braku bezpieczeństwa — raczej dotyczy to zgodności z wcześniejszymi wersjami. Jeśli zdecydujemy się użyć typowania "constraint-C#", aby zweryfikować "allow-C#", chcemy potwierdzić, że nie czynimy żadnego istniejącego kodu w języku C# nielegalnym.

Ponieważ wszystkie terminy języka C# zaczynają się od prawidłowych terminów "constraint-C#", możemy zweryfikować zachowanie, sprawdzając każde z naszych nowych ograniczeń "constraint-C#". Najpierw dodanie ograniczenia heap. Ponieważ wszystkie parametry typu w języku C# nabyłyby ograniczenie heap, wszystkie istniejące terminy muszą spełniać określone ograniczenie. Dotyczy to wszystkich typów z wyjątkiem struktur ref, co jest odpowiednie, ponieważ struktury ref mogą nie być obecnie wyświetlane jako argumenty typu. Dotyczy to również wszystkich parametrów typu, ponieważ wszystkie one same uzyskują ograniczenie heap. Ponadto, ponieważ ograniczenie heap jest prawidłową kombinacją ze wszystkimi innymi ograniczeniami, nie stanowiłoby to żadnych problemów. Zasady (1–5) nie stwarzałyby żadnych problemów, ponieważ bezpośrednio odpowiadają istniejącym zasadom języka C# lub są ich złagodzeniem. W związku z tym wszystkie terminy z możliwością wpisywania w języku C# powinny być wpisywane w "constraint-C#" i nie należy wprowadzać żadnych zmian powodujących niezgodność.

Otwarte problemy

Składnia anty-ograniczeniowa

decyzja: użyj where T: allows ref struct

Propozycja ta ma na celu ujawnienie ref struct anty-ograniczenia poprzez rozszerzenie istniejącej składni where w celu wprowadzenia allows ref struct. To zwięźle opisuje funkcję i można je rozszerzyć, aby w przyszłości uwzględnić inne elementy, takie jak wskaźniki. Istnieją inne rozwiązania, które warto dyskutować.

Pierwszy to po prostu wybranie innej składni do użycia w klauzuli where. Dostępne są inne proponowane opcje:

  • ~ref struct: ~ służy jako znacznik, że składnia, która następuje, jest anty-ograniczeniem.
  • include ref struct: używanie includes zamiast allows
void M<T>(T p)
    where T : IDisposable, ~ref struct
{
    p.Dispose();
}

Drugi polega na całkowitym użyciu nowej klauzuli, aby wyjaśnić, że to, co następuje, rozszerza zestaw dozwolonych typów. Zwolennicy tego czują, że użycie składni w where może prowadzić do nieporozumień podczas czytania. W początkowej propozycji użyto następującej składni: allow T: ref struct:

void M<T>(T p)
    where T : IDisposable
    allow T : ref struct
{
    p.Dispose();
}

Składnia where T: allows ref struct była nieco bardziej preferowana w dyskusjach LDM.

Co i kontrawariancja

Decyzja: brak nowych problemów

Aby parametry typu były maksymalnie użyteczne, muszą być kompatybilne z ogólną wariancją, jeśli są oznaczone jako allows ref struct. W szczególności musi być dozwolone, aby parametr był zarówno kowariantny/kontrawariantny, jak i allows ref struct. Brak tego oznacza, że nie będzie można ich używać w wielu najpopularniejszych typach delegate i interface na platformie .NET, takich jak Func<T>, Action<T>, IEnumerable<T>itp.

Po dyskusji stwierdzono, że nie jest to problem. Ograniczenie allows ref struct jest po prostu innym sposobem, w jaki struct można użyć jako argumentów ogólnych. Podobnie jak normalny argument struct redukuje wariancję interfejsu API, tak samo zrobi ref struct.

Automatyczne stosowanie do członków delegowanych

decyzja: nie stosuje się automatycznie

Dla wielu ogólnych członków delegate język może automatycznie stosować allows ref struct, ponieważ jest to wyłącznie korzystna zmiana. Należy wziąć pod uwagę, że w przypadku delegatów stylu Func<> / Action<> i większości definicji interfejsu nie ma żadnych wad w rozszerzeniu, aby umożliwić ref struct. Język może określać reguły, w których bezpieczne jest automatyczne stosowanie tego ograniczenia. Spowoduje to usunięcie procesu ręcznego i przyspieszy wdrożenie tej funkcji.

Ta automatyczna aplikacja allows ref struct stwarza jednak kilka problemów. Pierwszy dotyczy scenariuszy obejmujących wiele celów. Kod kompiluje się w jednej strukturze docelowej, ale kończy się niepowodzeniem w innej i nie ma żadnego wskaźnika składniowego, dlaczego interfejsy API powinny zachowywać się inaczej.

// Works in net9.0 but fails in all other TF
Func<Span<char>> func;

Może to prowadzić do dezorientacji klientów, a zaglądanie na zmiany w Func<T> w źródle net9.0 nie da klientom żadnych wskazówek co do tego, co się zmieniło.

Drugi problem polega na tym, że bardzo subtelne zmiany w kodzie mogą powodować przerażające działanie w odległości problemów. Rozważ następujący kod:

interface I1<T>
{
}

Ten interfejs kwalifikuje się do automatycznego stosowania allows ref struct. Jeśli jednak deweloper pojawi się później i doda domyślną metodę interfejsu, to nagle przestanie ona być dostępna, co spowoduje problemy z użytkownikami, którzy już utworzyli wywołania, takie jak I1<Span<char>>. Jest to bardzo subtelna zmiana, która byłaby trudna do śledzenia.

Zmiana powodująca niezgodność binarną

Dodawanie allows ref struct do istniejącego interfejsu API nie jest zmianą powodującą niezgodność ze źródłem. Jest to wyłącznie rozszerzanie zestawu dozwolonych typów dla interfejsu API. Należy śledzić, czy jest to zmiana naruszająca zgodność binarną, czy nie. Niejasne, czy aktualizowanie atrybutów parametru ogólnego stanowi zmianę powodującą niezgodność binarną.

Ostrzegaj przy wywołaniu DIM

Czy kompilator powinien ostrzegać przed następującym wywołaniem M, ponieważ tworzy możliwość wystąpienia wyjątku środowiska uruchomieniowego?

interface I1
{
    // Virtual method with default implementation
    void M() { }
}

// Invocation of a virtual instance method with default implementation in a generic method that has the `allows ref struct`
// anti-constraint
void M<T>(T p)
    where T : allows ref struct, I1
{
    p.M(); // Warn?
}

Może to być jednak hałaśliwe i nie jest bardzo pomocne w większości scenariuszy. Język C# będzie wymagać struktur ref w celu zaimplementowania wszystkich wirtualnych interfejsów API. W związku z tym, przy założeniu, że inni gracze przestrzegają tej samej reguły, jedyną sytuacją, w której może to spowodować wyjątek, jest dodanie metody po fakcie dokonanym. Autor kodu zużywanego często nie ma wiedzy na temat wszystkich tych szczegółów i często nie ma kontroli nad strukturami ref, które będą używane przez kod. W związku z tym jedyną akcją, która może wykonać autor, jest pominięcie ostrzeżenia.

Zagadnienia dotyczące

Obsługa środowiska uruchomieniowego

Ta funkcja wymaga kilku elementów pomocy technicznej od zespołu środowiska uruchomieniowego/bibliotek:

  • Zapobieganie stosowaniu domyślnych metod interfejsu do ref struct
  • Interfejs API w System.Reflection.Metadata do kodowania wartości gpAcceptByRefLike
  • Obsługa parametrów ogólnych typu ref struct

Większość tej pomocy technicznej prawdopodobnie już istnieje. Zgodnie z opisem ref struct, ogólna obsługa parametru została już zaimplementowana. Istnieje możliwość, że implementacja DIM już uwzględnia ref struct. Ale każdy z tych elementów musi być odnaleziony.

Przechowywanie wersji interfejsu API

umożliwia antyograniczenia dla struktury ref

Ograniczenie allows ref struct można bezpiecznie zastosować do dużej liczby ogólnych definicji, które nie mają implementacji. Oznacza to, że większość delegatów, interfejsów i metod abstract może bezpiecznie stosować allows ref struct do ich parametrów. Są to tylko definicje interfejsu API bez implementacji, dlatego rozszerzenie zestawu dozwolonych typów spowoduje tylko błędy, jeśli są używane jako argumenty typu, w których ref struct nie są dozwolone.

Właściciele interfejsów API mogą polegać na prostej regule "jeśli jest kompilowana, jest bezpieczna". Kompilator zgłosi błąd dla wszystkich niebezpiecznych użyć allows ref struct, podobnie jak w przypadku użyć ref struct.

Jednocześnie należy wziąć pod uwagę kwestie wersjonowania interfejsów API. Ogólnie właściciele interfejsów API powinni unikać dodawania allows ref struct do parametrów typu, gdzie właścicielski typ lub element może ulec zmianie w przyszłości, stając się niezgodnym z allows ref struct. Na przykład:

  • Metoda abstract, która może później ulec zmianie na metodę virtual
  • Typ abstract, do którego mogą zostać dodane implementacje później

W takich przypadkach autor interfejsu API powinien zachować ostrożność podczas dodawania allows ref struct, chyba że jest pewien, że ewolucja typu lub składowej nie będzie używać T w sposób, który łamie zasady ref struct.

Usunięcie ograniczenia allows ref struct jest zawsze zmianą powodującą niezgodności w kodzie źródłowym i plikach binarnych.

Domyślne metody interfejsu

Autorzy interfejsów API muszą pamiętać, że dodanie funkcji DIMS spowoduje przerwanie ref struct implementatorów do momentu ponownego skompilowania. Jest to podobne do istniejącego zachowania DIM, gdzie dodanie DIM do interfejsu spowoduje przerwanie istniejących implementacji do momentu ich ponownego skompilowania. Oznacza to, że autorzy interfejsów API muszą rozważyć prawdopodobieństwo ref struct implementacji podczas dodawania modułów DIM.

Istnieją trzy składniki kodu potrzebne do utworzenia tej sytuacji:

interface I1
{
    // 1. The addition of a DIM method to an _existing_ interface
    void M() { }
}

// 2. A ref struct implementing the interface but not explicitly defining the DIM 
// method
ref struct S : I1 { }

// 3. The invocation of the DIM method in a generic method that has the `allows ref struct`
// anti-constraint
void M<T>(T p)
    where T : allows ref struct, I1
{
    p.M();
}

Wszystkie trzy z tych składników są potrzebne do utworzenia tego konkretnego problemu. Co najmniej (1) i (2) muszą znajdować się w różnych zestawach. Gdyby znajdowały się w tym samym zestawie, wystąpi błąd kompilacji.

UnscopedRef

Dodawanie lub usuwanie [UnscopedRef] z interface członków to zmiana powodująca niezgodność źródła (i potencjalnie tworzenie problemów ze środowiskiem uruchomieniowym). Atrybut należy zastosować podczas definiowania elementu członkowskiego interfejsu i nie należy go dodawać ani usuwać później.

Span Span<<T>>

Ta kombinacja funkcji nie zezwala na konstrukcje, takie jak Span<Span<T>>. Jest to nieco jaśniejsze, patrząc na definicję Span<T>:

readonly ref struct Span<T>
{
    public readonly ref T _data;
    public readonly int _length;

    public Span(T[] array) { ... }

    public static implicit operator Span<T>(T[]? array) { }
 
    public static implicit operator Span<T>(ArraySegment<T> segment) { }
}

Gdyby definicja tego typu zawierała allows ref struct wszystkie wystąpienia T w definicji musiałyby być traktowane tak, jakby były potencjalnie typem ref struct. Przedstawia to dwie klasy problemów.

Pierwsza dotyczy API, takich jak Span(T[] array), a operator T nie może być używany jako ref struct: jest używany jako element tablicy lub parametr ogólny, którego nie można allows ref struct. Istnieje kilka publicznych interfejsów API w Span<T>, które używają T w sposób niekompatybilny z ref struct. Są to publiczne interfejsy API, których nie można usunąć, dlatego muszą zostać zracjonalizowane przez język. Najbardziej prawdopodobną ścieżką do przodu jest to, że kompilator traktował specjalnie Span<T> i wyda kod błędu związany z jednym z tych interfejsów API, gdy argument T jest lub potencjalnieref struct.

Drugą kwestią jest to, że język nie obsługuje pól ref, które są ref struct. Istnieje propozycja projektu umożliwiająca korzystanie z tej funkcji. Nie jest jasne, czy zostanie to zaakceptowane w języku, czy jest wystarczająco wyraziste, aby obsłużyć pełny zakres scenariuszy dotyczących Span<T>.

Obie te kwestie wykraczają poza zakres niniejszej propozycji.

Logika implementacji UnscopedRef

Uzasadnienie dla reguł [UnscopedRef] implementacji interfejsu jest najłatwiej zrozumieć podczas wizualizowania parametru this jako jawnego, a nie niejawnego argumentu dla metod. Rozważmy na przykład następujące struct, w których this jest wizualizowany jako niejawny parametr (podobny do sposobu obsługi go w języku Python):

struct S
{
    public void M(scoped ref S this) { }
}

[UnscopedRef] na elemencie członkowskim interfejsu określa, że this brakuje scoped do celów okresu istnienia w miejscu wywołania. Umożliwienie pominięcia [UnscopedRef] przy implementującym elemencie skutecznie zezwala na zaimplementowanie parametru, który jest ref T, realizowany przez parametr, który jest scoped ref T. Język umożliwia już następujące czynności:

interface I1
{
    void M(ref Span<char> span);
}

struct S : I1
{
    public void M(scoped ref Span<char> span) { }
}

Powiązane elementy: