Partager via


Améliorations apportées aux structures de bas niveau

Remarque

Cet article est une spécification de fonctionnalité. La spécification sert de document de conception pour la fonctionnalité. Elle inclut les changements de spécification proposés, ainsi que les informations nécessaires à la conception et au développement de la fonctionnalité. Ces articles sont publiés jusqu'à ce que les changements proposés soient finalisés et incorporés dans la spécification ECMA actuelle.

Il peut y avoir des divergences entre la spécification de la fonctionnalité et l'implémentation réalisée. Ces différences sont consignées dans les notes pertinentes de la réunion de conception linguistique (LDM).

Pour en savoir plus sur le processus d'adoption des speclets de fonctionnalité dans la norme du langage C#, consultez l'article sur les spécifications.

Récapitulatif

Cette proposition est une agrégation de plusieurs propositions différentes pour struct l'amélioration des performances : Les champs ref et la possibilité de remplacer les valeurs par défaut de la durée de vie. L'objectif est de parvenir à une conception qui tienne compte des diverses propositions visant à créer un ensemble de fonctionnalités unique et global pour les améliorations de bas niveau de struct.

Note : Les versions précédentes de cette spécification utilisaient les termes « ref-safe-to-escape » et « safe-to-escape », qui ont été introduits dans la spécification de la fonctionnalité de sécurité Span. Le comité standard ECMA a modifié les noms en « ref-safe-context » et « safe-context », respectivement. Les valeurs du contexte sûr ont été affinées pour utiliser « declaration-block », « function-member » et « caller-context » de manière cohérente. Les speclets avaient utilisé des formulations différentes pour ces termes, et avaient également utilisé « safe-to-return » comme synonyme de « caller-context ». Ce speclet a été mis à jour pour utiliser les termes de la norme C# 7.3.

Toutes les fonctionnalités décrites dans ce document n'ont pas été implémentées dans C# 11. C# 11 comprend :

  1. champs ref et scoped
  2. [UnscopedRef]

Ces fonctionnalités restent des propositions ouvertes pour une future version de C# :

  1. champs ref à ref struct
  2. Types restreints en cours de retrait

Motivation

Les versions antérieures de C# ont introduit plusieurs fonctionnalités de performance de bas niveau dans le langage : les retours de fonction ref, ref struct, les pointeurs de fonction, etc. ... Ces fonctionnalités ont permis aux développeurs .NET d'écrire du code hautement performant tout en continuant de profiter des règles du langage C# pour la sécurité des types et de la mémoire. Il a également permis la création de types de performance fondamentaux dans les bibliothèques .NET comme Span<T>.

À mesure que ces fonctionnalités ont gagné en importance dans l'écosystème .NET, les développeurs, qu'ils soient internes ou externes, nous ont fourni des informations sur les points de friction restants dans l'écosystème. Les endroits où ils ont encore besoin de passer au code unsafe pour faire leur travail, ou de demander au runtime de traiter des cas spéciaux comme Span<T>.

Aujourd'hui, Span<T> est réalisé en utilisant le type internal ByReference<T> que le système d'exécution traite effectivement comme un champ ref. Cela offre l’avantage des champs ref, mais avec l’inconvénient que le langage ne fournit aucune vérification de sécurité pour celle-ci, comme il le fait pour d'autres utilisations de ref. En outre, seul dotnet/runtime peut utiliser ce type en tant que internal, de sorte que les tiers ne peuvent pas concevoir leurs propres primitives basées sur les champs ref. Une partie de la motivation de ce travail est de supprimer ByReference<T> et d'utiliser les champs ref appropriés dans toutes les bases de code.

Cette proposition vise à résoudre ces problèmes en s'appuyant sur nos fonctionnalités de bas niveau existantes. Plus précisément, elle vise à

  • Autorisez les types ref struct à déclarer des champs ref.
  • permettre au runtime de définir complètement Span<T> en utilisant le système de type C# et de supprimer les cas spéciaux comme ByReference<T>
  • Permettre aux types struct de renvoyer des ref dans leurs champs.
  • Permettre à l'exécution de supprimer les utilisations unsafe causées par les limitations des valeurs par défaut de la durée de vie
  • permettre la déclaration de tampons fixed sûrs pour les types gérés et non gérés dans struct.

Conception détaillée

Les règles de ref struct sécurité sont définies dans le document de sécurité de l'étendue en utilisant les termes précédents. Ces règles ont été incorporées dans la norme C# 7 aux paragraphes 9.7.2 et 16.4.12. Le présent document décrira les modifications qu'il convient d'apporter au présent document à la suite de cette proposition. Une fois acceptée en tant que fonctionnalité approuvée, ces modifications seront incorporées dans ce document.

Une fois cette conception achevée, notre définition de Span<T> sera la suivante :

readonly ref struct Span<T>
{
    readonly ref T _field;
    readonly int _length;

    // This constructor does not exist today but will be added as a part 
    // of changing Span<T> to have ref fields. It is a convenient, and
    // safe, way to create a length one span over a stack value that today 
    // requires unsafe code.
    public Span(ref T value)
    {
        _field = ref value;
        _length = 1;
    }
}

Fournir des champs ref et scoped

Le langage permettra aux développeurs de déclarer des champs ref à l'intérieur d'un ref struct. Cela peut être utile, par exemple, pour encapsuler de grandes instances struct mutables ou pour définir des types à haute performance comme Span<T> dans des bibliothèques en dehors du runtime.

ref struct S 
{
    public ref int Value;
}

Un champ ref sera émis dans les métadonnées à l'aide de la signature ELEMENT_TYPE_BYREF. Cela n'est pas différent de la façon dont nous émettons des localisations ref ou des arguments ref Par exemple, ref int _field sera émis en tant que ELEMENT_TYPE_BYREF ELEMENT_TYPE_I4. Nous devrons mettre à jour l'ECMA335 pour autoriser cette entrée, mais le transfert devrait être assez simple.

Les développeurs peuvent continuer à initialiser un ref struct avec un champ default en utilisant l'expression ref, auquel cas tous les champs ref déclarés auront la valeur null. Toute tentative d'utilisation de ces champs entraînera l'émission d'un NullReferenceException.

ref struct S 
{
    public ref int Value;
}

S local = default;
local.Value.ToString(); // throws NullReferenceException

Bien que le langage C# prétende qu'un ref ne peut pas être null, cela est légal au niveau de l'exécution et a une sémantique bien définie. Les développeurs qui introduisent des champs ref dans leurs types doivent être conscients de cette possibilité et doivent être fortement découragés de divulguer ce détail dans le code de consommation. Au lieu de cela, les champs ref devraient être validés comme non nuls en utilisant les aides d'exécution et en lançant lorsqu'un struct non initialisé est utilisé de manière incorrecte.

ref struct S1 
{
    private ref int Value;

    public int GetValue()
    {
        if (System.Runtime.CompilerServices.Unsafe.IsNullRef(ref Value))
        {
            throw new InvalidOperationException(...);
        }

        return Value;
    }
}

Un champ ref peut être combiné avec des modificateurs readonly de la manière suivante :

  • readonly ref  : il s'agit d'un champ qui ne peut pas être réaffecté en dehors d'un constructeur ou de méthodes init. Il est possible de leur attribuer une valeur en dehors de ces contextes
  • ref readonly : il s'agit d'un champ qui peut être réaffecté, mais qui ne peut être affecté d'une valeur à aucun moment. C'est ainsi qu'un paramètre in peut être réaffecté à un champ ref.
  • readonly ref readonly : une combinaison de ref readonly et readonly ref.
ref struct ReadOnlyExample
{
    ref readonly int Field1;
    readonly ref int Field2;
    readonly ref readonly int Field3;

    void Uses(int[] array)
    {
        Field1 = ref array[0];  // Okay
        Field1 = array[0];      // Error: can't assign ref readonly value (value is readonly)
        Field2 = ref array[0];  // Error: can't repoint readonly ref
        Field2 = array[0];      // Okay
        Field3 = ref array[0];  // Error: can't repoint readonly ref
        Field3 = array[0];      // Error: can't assign ref readonly value (value is readonly)
    }
}

Un readonly ref struct exige que les champs ref soient déclarés readonly ref. Il n'est pas nécessaire qu'ils soient déclarés readonly ref readonly. Cela permet à un readonly struct d'avoir des mutations indirectes via un tel champ, mais ce n'est pas différent d'un champ readonly qui pointe vers un type de référence aujourd'hui (plus de détails).

Une readonly ref sera émise dans les métadonnées à l’aide de l’indicateur initonly, comme pour tout autre champ. Un champ ref readonly sera attribué à System.Runtime.CompilerServices.IsReadOnlyAttribute. Un readonly ref readonly sera émis avec les deux éléments.

Cette fonctionnalité nécessite un support d'exécution et des changements dans la spécification de l'ECMA. En tant que telles, elles ne seront activées que lorsque l'indicateur de fonctionnalité correspondant sera activé dans la corelib. Le problème du suivi de l'API exacte est suivi ici https://github.com/dotnet/runtime/issues/64165

L'ensemble des modifications de nos règles de safe-context nécessaires pour autoriser les champs ref est limité et ciblé. Les règles prennent déjà en compte les champs ref existants et utilisés à partir des API. Les changements ne doivent porter que sur deux aspects : la façon dont ils sont créés et la façon dont ils sont réaffectés.

Tout d'abord, les règles établissant les valeurs de contexte ref-safe pour les champs doivent être mises à jour pour les champs ref comme suit :

Une expression sous la forme ref e.Fref-safe-context comme suit :

  1. Si F est un champ ref, son ref-safe-context est le ref-safe-context de e.
  2. Sinon, si e est un type de référence, son ref-safe-context est le caller-context.
  3. Dans le cas contraire, son ref-safe-context est tiré du ref-safe-context de e.

Il ne s'agit pas d'un changement de règle, car les règles ont toujours tenu compte de l'existence d'un état ref à l'intérieur d'un ref struct. En réalité, c'est ainsi que l'état ref dans Span<T> a toujours fonctionné et les règles de consommation en tiennent correctement compte. Le changement ici vise simplement à permettre aux développeurs d'accéder directement aux champs ref et à garantir qu'ils respectent les règles existantes qui s'appliquent implicitement à Span<T>.

Cela signifie cependant que les champs ref peuvent être renvoyés en tant que ref à partir d'un ref struct, mais que les champs normaux ne peuvent pas l'être.

ref struct RS
{
    ref int _refField;
    int _field;

    // Okay: this falls into bullet one above. 
    public ref int Prop1 => ref _refField;

    // Error: This is bullet four above and the ref-safe-context of `this`
    // in a `struct` is function-member.
    public ref int Prop2 => ref _field;
}

Cela peut sembler une erreur à première vue, mais il s'agit d'un point de conception délibéré. Encore une fois, la présente proposition ne crée pas une nouvelle règle, mais reconnaît les règles existantes Span<T> selon lesquelles les développeurs peuvent déclarer leur propre état ref.

Ensuite, les règles de réaffectation des références doivent être adaptées à la présence de champs ref. Le principal scénario de réaffectation des références est celui des constructeurs ref struct qui stockent des paramètres ref dans des champs ref. Le support sera plus général, mais il s'agit du scénario de base. Pour soutenir cela, les règles pour la réaffectation des références seront ajustées pour prendre en compte les champs ref comme suit :

Règles de réaffectation des références

L'opérande gauche de l'opérateur = ref doit être une expression qui se lie à une variable locale ref, à un paramètre ref (autre que this), à un paramètre out ou à un champ ref.

Pour une réaffectation ref de la forme e1 = ref e2, les deux conditions suivantes doivent être remplies :

  1. e2 doit avoir ref-safe-context au moins aussi grand que le ref-safe-context de e1
  2. e1 doit avoir le même contexte sécurisé que e2Note

Cela signifie que le constructeur Span<T> souhaité fonctionne sans aucune annotation supplémentaire :

readonly ref struct Span<T>
{
    readonly ref T _field;
    readonly int _length;

    public Span(ref T value)
    {
        // Falls into the `x.e1 = ref e2` case, where `x` is the implicit `this`. The 
        // safe-context of `this` is *return-only* and ref-safe-context of `value` is 
        // *caller-context* hence this is legal.
        _field = ref value;
        _length = 1;
    }
}

La modification des règles de réaffectation des références signifie que les paramètres ref peuvent désormais s'échapper d'une méthode en tant que champ ref dans une valeur ref struct Comme indiqué dans la section relative aux considérations de compatibilité, cela peut modifier les règles pour les API existantes qui n'ont jamais voulu que les paramètres ref s'échappent sous la forme d'un champ ref. Les règles de durée de vie des paramètres sont basées uniquement sur leur déclaration et non sur leur utilisation. Tous les paramètres ref et in ont un ref-safe-context du caller-context et peuvent donc désormais être renvoyés par ref ou un champ ref. Afin de prendre en charge les API ayant des paramètres ref qui peuvent être escaping ou non-escaping, et ainsi restaurer la sémantique du site d'appel de C# 10, le langage introduira des annotations de durée de vie limitée.

Modificateur scoped

Le mot-clé scoped sera utilisé pour restreindre la durée de vie d'une valeur. Elles peuvent être appliquées à un ref ou à une valeur qui est un ref struct et ont pour effet de restreindre la durée de vie du ref-safe-context ou du safe-context, respectivement, au function-member. Exemple :

Paramètre ou Local ref-safe-context contexte sécurisé
Span<int> s function-member caller-context
scoped Span<int> s function-member function-member
ref Span<int> s caller-context caller-context
scoped ref Span<int> s function-member caller-context

Dans cette relation, le ref-safe-context d'une valeur ne peut jamais être plus large que le safe-context.

Cela permet aux API en C# 11 d'être annotées de manière à ce qu'elles aient les mêmes règles qu'en C# 10 :

Span<int> CreateSpan(scoped ref int parameter)
{
    // Just as with C# 10, the implementation of this method isn't relevant to callers.
}

Span<int> BadUseExamples(int parameter)
{
    // Legal in C# 10 and legal in C# 11 due to scoped ref
    return CreateSpan(ref parameter);

    // Legal in C# 10 and legal in C# 11 due to scoped ref
    int local = 42;
    return CreateSpan(ref local);

    // Legal in C# 10 and legal in C# 11 due to scoped ref
    Span<int> span = stackalloc int[42];
    return CreateSpan(ref span[0]);
}

L'annotation scoped signifie également que le paramètre this d'un struct peut désormais être défini comme scoped ref T. Auparavant, il devait être traité de manière spéciale dans les règles en tant que paramètre ref, qui avait des règles de contexte ref-safe différentes des autres paramètres ref (voir toutes les références concernant l'inclusion ou l'exclusion du récepteur dans les règles de contexte sécurisé). Il peut désormais être exprimé comme un concept général dans l'ensemble des règles, ce qui les simplifie encore davantage.

L'annotation scoped peut également être appliquée aux endroits suivants :

  • locals : Cette annotation définit la durée de vie en tant que safe-context, ou ref-safe-context dans le cas d'un local ref, au membre de la fonction indépendamment de la durée de vie de l'initialisateur.
Span<int> ScopedLocalExamples()
{
    // Error: `span` has a safe-context of *function-member*. That is true even though the 
    // initializer has a safe-context of *caller-context*. The annotation overrides the 
    // initializer
    scoped Span<int> span = default;
    return span;

    // Okay: the initializer has safe-context of *caller-context* hence so does `span2` 
    // and the return is legal.
    Span<int> span2 = default;
    return span2;

    // The declarations of `span3` and `span4` are functionally identical because the 
    // initializer has a safe-context of *function-member* meaning the `scoped` annotation
    // is effectively implied on `span3`
    Span<int> span3 = stackalloc int[42];
    scoped Span<int> span4 = stackalloc int[42];
}

D'autres utilisations de scoped sur les locales sont discutées ci-dessous.

L'annotation scoped ne peut être appliquée à aucun autre emplacement, y compris les retours, les champs, les éléments de tableau, etc. De plus, alors que scoped a un impact lorsqu'il est appliqué à n'importe quel ref, in ou out, il n'a d'impact que lorsqu'il est appliqué à des valeurs qui sont ref struct. Le fait d'avoir des déclarations comme scoped int n'a pas d'impact parce qu'un non ref struct est toujours sûr à renvoyer. Le compilateur créera un diagnostic pour de tels cas afin d'éviter toute confusion de la part du développeur.

Modifier le comportement des paramètres out

Pour limiter davantage l'impact de la modification de la compatibilité consistant à rendre les paramètres ref et in retournables en tant que champs ref, le langage modifiera la valeur par défaut du ref-safe-context pour les paramètres out pour qu'elle soit fonction-member. Dorénavant, les paramètres out sont implicitement scoped out. Du point de vue de la compatibilité, cela signifie qu'ils ne peuvent pas être retournés par ref:

ref int Sneaky(out int i) 
{
    i = 42;

    // Error: ref-safe-context of out is now function-member
    return ref i;
}

Cela augmentera la flexibilité des API qui renvoient des valeurs ref struct et ont des paramètres out parce qu'il n'est plus nécessaire de considérer que le paramètre est capturé par référence. Ce point est important car il s'agit d'un modèle courant dans les API de type lecteur :

Span<byte> Read(Span<byte> buffer, out int read)
{
    // .. 
}

Span<byte> Use()
{
    var buffer = new byte[256];

    // If we keep current `out` ref-safe-context this is an error. The language must consider
    // the `read` parameter as returnable as a `ref` field
    //
    // If we change `out` ref-safe-context this is legal. The language does not consider the 
    // `read` parameter to be returnable hence this is safe
    int read;
    return Read(buffer, out read);
}

La langue ne considèrera plus les arguments passés à un paramètre out comme pouvant être retournés. Le fait de traiter l'entrée d'un paramètre out comme pouvant être retournée était extrêmement déroutant pour les développeurs. Il détourne essentiellement l’intention de out en obligeant les développeurs à prendre en compte la valeur transmise par l'appelant, qui n'est jamais utilisée, sauf dans les cas où les langues ne respectent pas out. À partir du transfert, les langages qui prennent en charge ref struct doivent s'assurer que la valeur originale transmise à un paramètre out n'est jamais lue.

C# y parvient grâce à ses règles d'affectation définies. Cela permet à la fois de respecter nos règles de contexte sûres et de prendre en compte le code existant qui assigne puis renvoie des valeurs de paramètres out

Span<int> StrangeButLegal(out Span<int> span)
{
    span = default;
    return span;
}

L'ensemble de ces changements signifie que l'argument d'un paramètre out ne contribue pas aux valeurs de safe-context ou de ref-safe-context pour les invocations de méthodes. Cela réduit considérablement l'impact global des champs ref sur la compatibilité et simplifie la façon dont les développeurs envisagent out. Un argument d'un paramètre out ne contribue pas au retour, il s'agit simplement d'une sortie.

Déduire le safe-context des expressions de déclaration

Le contexte sûr d'une variable de déclaration provenant d'un out argument (M(x, out var y)) ou d'une déconstruction ((var x, var y) = M()) est le plus étroit des éléments suivants :

  • contexte de l'appelant
  • si la variable out est marquée scoped, alors declaration-block (c'est-à-dire membre d'une fonction ou plus étroit).
  • si le type de la variable out est ref struct, considérez tous les arguments à l'invocation qui le contient, y compris le récepteur :
    • safe-context de tout argument dont le paramètre correspondant n'est pas out et a un safe-context de return-only ou plus large
    • contexte ref-safe de tout argument dont le paramètre correspondant a un contexte ref-safe de return-only ou plus large

Voir aussi Exemples de safe-context inféré d'expressions de déclaration.

Paramètres scoped implicites

Globalement, il existe deux emplacements ref qui sont implicitement déclarés comme scoped :

  • this sur une struct méthode d'instance
  • Paramètres out

Les règles de ref-safe-context seront écrites en termes de scoped ref et ref. Pour les besoins du ref-safe-context, un paramètre in est équivalent à ref et out est équivalent à scoped ref. Les éléments in et out ne seront spécifiquement mentionnés que s'ils sont importants pour la sémantique de la règle. Dans le cas contraire, ils sont simplement considérés comme ref et scoped ref respectivement.

Lors de la discussion du ref-safe-context des arguments qui correspondent à des paramètres in, ils seront généralisés en tant qu'arguments ref dans la spécification. Dans le cas où l'argument est une lvalue, le ref-safe-context est celui de la lvalue, sinon il s'agit d'un function-member. Une fois encore, in ne sera mentionné ici que s'il est important pour la sémantique de la règle en question.

Return-only safe context

La conception exige également l'introduction d'un nouveau contexte de sécurité : return-only. Ce contexte est similaire au caller-context en ce sens qu'il peut être retourné, mais uniquement par le biais d'une instruction return.

Le détail du return-only est qu'il s'agit d'un contexte qui est plus grand que function-member mais plus petit que caller-context. Une expression fournie à une instruction return doit être au moins return-only. De ce fait, la plupart des règles existantes sont caduques. Par exemple, l'affectation à un paramètre ref à partir d'une expression dont le safe-context est return-only échouera parce qu'elle est plus petite que le refsafe-context du paramètre , qui est caller-context. La nécessité de ce nouveau contexte d'échappement sera discutée plus loin.

Il y a trois emplacements qui sont par défaut en return-only :

  • Un paramètre ref ou in aura un ref-safe-context de return-only. Ceci est fait en partie pour ref struct afin d'éviter des problèmes stupides d'assignation cyclique. Pour simplifier le modèle et réduire les modifications de compatibilité, cela s'effectue uniformément.
  • Un paramètre out pour un ref struct aura un safe-context de return-only. Cela permet à return et out d'être aussi expressifs l'un que l'autre. Il n'y a pas de problème d'affectation cyclique, car out est implicitement scoped, de sorte que le ref-safe-context est toujours plus petit que le safe-context.
  • Un paramètre this pour un constructeur struct aura un safe-context de return-only. Ceci est dû au fait qu'ils sont modélisés comme des paramètres out.

Toute expression ou instruction qui renvoie explicitement une valeur à partir d'une méthode ou d'un lambda doit avoir un safe-context, et le cas échéant un ref-safe-context, d'au moins return-only. Cela inclut les instructions return, les membres à corps d'expression et les expressions lambda.

De même, toute affectation à une out doit avoir un safe-context d'au moins return-only. Ce n'est pourtant pas un cas particulier, cela découle simplement des règles d'affectation existantes.

Remarque : une expression dont le type n'est pas un type ref struct a toujours un safe-context de caller-context.

Règles pour l'invocation de méthodes

Les règles ref-safe-context pour l'invocation de méthodes seront mises à jour de plusieurs manières. La première consiste à reconnaître l'impact de scoped sur les arguments. Pour un argument expr donné, qui est passé au paramètre p :

  1. Si p est scoped ref, expr ne contribue pas au ref-safe-context lors de l'examen des arguments.
  2. Si p est scoped, expr ne contribue pas au safe-context lors de l'examen des arguments.
  3. Si p est out alors expr ne contribue pas au ref-safe-context ou au safe-contextplus de détails.

La mention « ne contribue pas » signifie que les arguments ne sont tout simplement pas pris en compte lors du calcul de la valeur du contexte ref-safe ou du contexte sécurisé , respectivement, pour le retour de la méthode. C'est parce que les valeurs ne peuvent pas contribuer à cette durée de vie car l'annotation scoped l'empêche.

Les règles d'invocation des méthodes peuvent maintenant être simplifiées. Le récepteur n'a plus besoin de faire l'objet d'un cas spécial ; dans le cas de struct, il s'agit simplement d'un scoped ref T. Les règles de valeur doivent être modifiées pour prendre en compte les retours du champ ref.

Une valeur résultant de l'invocation d'une méthode e1.M(e2, ...), où M() ne renvoie pas de ref-safe-context, a un safe-context pris dans le plus étroit des suivants :

  1. le caller-context.
  2. Lorsque le retour est un ref struct, le safe-context apporté par toutes les expressions d'argument
  3. Lorsque le retour est un ref struct, le ref-safe-context est constitué de tous les arguments ref.

Si M() renvoie une structure ref-to-ref, le safe-context est le même que le safe-context de tous les arguments qui sont ref-to-ref-struct. Il s'agit d'une erreur s'il y a plusieurs arguments avec un contexte sécurisé différent, parce que les arguments de méthode doivent correspondre à.

Les règles d'appel de ref peuvent être simplifiées en :

Une valeur résultant de l'invocation d'une méthode ref e1.M(e2, ...), lorsque M() ne renvoie pas de ref-to-ref-struct, est le ref-safe-context le plus étroit des contextes suivants :

  1. le caller-context.
  2. Le safe-context apporté par toutes les expressions d'arguments.
  3. le ref-safe-context apporté par tous les arguments de ref.

Si M() renvoie effectivement une structure ref-to-ref, le contexte ref-safe-context est le contexte ref-safe-context le plus étroit auquel contribuent tous les arguments qui sont des structures ref-to-ref.

Cette règle nous permet maintenant de définir les deux variantes des méthodes souhaitées :

Span<int> CreateWithoutCapture(scoped ref int value)
{
    // Error: value Rule 3 specifies that the safe-context be limited to the ref-safe-context
    // of the ref argument. That is the *function-member* for value hence this is not allowed.
    return new Span<int>(ref value);
}

Span<int> CreateAndCapture(ref int value)
{
    // Okay: value Rule 3 specifies that the safe-context be limited to the ref-safe-context
    // of the ref argument. That is the *caller-context* for value hence this is not allowed.
    return new Span<int>(ref value);
}

Span<int> ComplexScopedRefExample(scoped ref Span<int> span)
{
    // Okay: the safe-context of `span` is *caller-context* hence this is legal.
    return span;

    // Okay: the local `refLocal` has a ref-safe-context of *function-member* and a 
    // safe-context of *caller-context*. In the call below it is passed to a 
    // parameter that is `scoped ref` which means it does not contribute 
    // ref-safe-context. It only contributes its safe-context hence the returned
    // rvalue ends up as safe-context of *caller-context*
    Span<int> local = default;
    ref Span<int> refLocal = ref local;
    return ComplexScopedRefExample(ref refLocal);

    // Error: similar analysis as above but the safe-context of `stackLocal` is 
    // *function-member* hence this is illegal
    Span<int> stackLocal = stackalloc int[42];
    return ComplexScopedRefExample(ref stackLocal);
}

Règles pour les initialisateurs d'objets

Le contexte sécurisé d'une expression d'initialiseur d'objet est le plus étroit parmi les éléments suivants :

  1. Le safe-context de l'appel au constructeur.
  2. Le safe-context et le ref-safe-context des arguments des indexeurs d'initialisateurs de membres qui peuvent s'échapper vers le récepteur.
  3. Le contexte safe-context des affectations dans les initialisateurs de membres à des fixateurs non lisibles ou le contexte ref-safe-context dans le cas d'une affectation ref.

Une autre façon de modéliser ceci est de penser que tout argument d'un initialisateur de membre qui peut être assigné au récepteur est un argument du constructeur. Cela s'explique par le fait que l'initialisateur de membre est en fait un appel au constructeur.

Span<int> heapSpan = default;
Span<int> stackSpan = stackalloc int[42];
var x = new S(ref heapSpan)
{
    Field = stackSpan;
}

// Can be modeled as 
var x = new S(ref heapSpan, stackSpan);

Cette modélisation est importante car elle démontre que notre MAMM doit tenir compte spécialement des initialisateurs de membres. Considérez que ce cas particulier doit être illégal, car il permet qu'une valeur avec un contexte sécurisé plus étroit soit attribuée à un contexte plus large.

Les arguments des méthodes doivent correspondre

La présence de champs ref signifie que les règles concernant les arguments de méthode doivent être mises à jour, car un paramètre ref peut désormais être stocké en tant que champ dans un argument ref struct de la méthode. Auparavant, la règle ne devait prendre en compte qu'un autre ref struct stocké en tant que champ. L'impact de ceci est discuté dans les considérations sur la compatibilité. La nouvelle règle est ...

Pour toute invocation de méthode e.M(a1, a2, ... aN)

  1. Calculer le safe-context le plus étroit à partir de :
    • caller-context
    • Le safe-context de tous les arguments
    • Le ref-safe-context de tous les arguments ref dont les paramètres correspondants ont un ref-safe-context de caller-context.
  2. Tous les arguments ref des types ref struct doivent être assignables par une valeur dans ce contexte sécurisé de . Il s'agit d'un cas où ref ne se généralise pas pour inclure in et out

Pour toute invocation de méthode e.M(a1, a2, ... aN)

  1. Calculer le safe-context le plus étroit à partir de :
    • caller-context
    • Le safe-context de tous les arguments
    • Le ref-safe-context de tous les arguments ref dont les paramètres correspondants ne sont pas scoped
  2. Tous les arguments out des types ref struct doivent être assignables par une valeur dans ce contexte sécurisé de .

La présence de scoped permet aux développeurs de réduire la friction créée par cette règle en marquant les paramètres qui ne sont pas renvoyés comme scoped. Cela supprime leurs arguments de (1) dans les deux cas ci-dessus et offre une plus grande flexibilité aux appelants.

L'impact de ce changement est discuté plus en détail ci-dessous. Dans l'ensemble, cela permettra aux développeurs de rendre les sites d'appel plus flexibles en annotant les valeurs de type ref non escomptées avec scoped.

Variation de l'étendue des paramètres

Le modificateur scoped et l'attribut [UnscopedRef] (voir ci-dessous) sur les paramètres ont également un impact sur nos règles de remplacement d'objet, d'implémentation d'interface et de conversion delegate. La signature d'un remplacement, d'une implémentation d'interface ou d'une conversion delegate peut :

  • Ajouter scoped à un paramètre ref ou in
  • Ajouter scoped à un paramètre ref struct
  • Supprimer [UnscopedRef] d'un paramètre out
  • Supprimer [UnscopedRef] d'un paramètre ref d'un type ref struct

Toute autre différence concernant scoped ou [UnscopedRef] est considérée comme une non-concordance.

Le compilateur signalera un diagnostic pour les non-concordances non sûres entre les remplacements, les implémentations d'interface et les conversions de délégués dans les cas suivants :

  • La méthode a un paramètre ref ou out de type ref struct avec une incompatibilité d’ajout de [UnscopedRef] (sans supprimer scoped). (Dans ce cas, une affectation cyclique idiote est possible, donc aucun autre paramètre n’est nécessaire.)
  • Ou les deux sont vraies :
    • La méthode retourne un ref struct ou retourne un ref ou un ref readonly, ou la méthode a un paramètre ref ou out de type ref struct.
    • La méthode possède au moins un paramètre ref, in ou out supplémentaire, ou un paramètre de type ref struct.

Le diagnostic n’est pas signalé dans d’autres cas, car :

  • Les méthodes avec de telles signatures ne peuvent pas capturer les refs passés, donc toute non-concordance scopée n'est pas dangereuse.
  • Il s'agit notamment de scénarios très courants et simples (par exemple, les anciens paramètres out utilisés dans les signatures de méthode TryParse), et signaler des incompatibilités de portée uniquement parce qu'elles sont utilisées dans la version 11 du langage (et donc que le paramètre out a une portée différente) serait source de confusion.

Le diagnostic est signalé comme une erreur si les deux signatures incompatibles utilisent les règles de contexte sûres C#11 ; sinon, le diagnostic est un avertissement .

L'avertissement de non-concordance de portée peut être signalé sur un module compilé avec les règles de ref safe-context C#7.2 où scoped n'est pas disponible. Dans certains cas, il peut être nécessaire de supprimer l'avertissement si l'autre signature non concordante ne peut être modifiée.

Le modificateur scoped et l'attribut [UnscopedRef] ont également les effets suivants sur les signatures de méthodes :

  • Le modificateur scoped et l'attribut [UnscopedRef] n'affectent pas le masquage.
  • Les surcharges ne peuvent pas différer uniquement sur scoped ou [UnscopedRef]

La section relative au champ ref et scoped est longue, aussi je vais conclure par un bref résumé des changements majeurs proposés.

  • Une valeur qui a un ref-fafe-context pour le caller-context est retournable par ref ou ref champ.
  • Un paramètre out aurait un safe-context de function-member.

Notes détaillées :

  • Un champ ref ne peut être déclaré qu'à l'intérieur d'un champ ref struct.
  • Un champ ref ne peut pas être déclaré static, volatile ou const
  • Un champ ref ne peut pas avoir un type ref struct
  • Le processus de génération de l'assemblage de référence doit préserver la présence d'un champ ref à l'intérieur d'un champ ref struct.
  • Un readonly ref struct doit déclarer ses champs ref comme readonly ref
  • Pour les valeurs by-ref, le modificateur scoped doit apparaître avant in, out ou ref.
  • Le document sur les règles de sécurité de l'étendue sera mis à jour comme indiqué dans le présent document.
  • Les nouvelles règles ref safe-context seront en vigueur lorsque
    • La bibliothèque de base contient l'indicateur de fonctionnalité indiquant la prise en charge des champs ref.
    • La valeur de langversion est égale ou supérieure à 11.

Syntaxe

13.6.2 Déclarations de variables locales : ajout de 'scoped'?.

local_variable_declaration
    : 'scoped'? local_variable_mode_modifier? local_variable_type local_variable_declarators
    ;

local_variable_mode_modifier
    : 'ref' 'readonly'?
    ;

13.9.4 l'for instruction: ajouté 'scoped'?indirectement à partir de local_variable_declaration.

13.9.5 l'foreach instruction: ajouté 'scoped'?.

foreach_statement
    : 'foreach' '(' 'scoped'? local_variable_type identifier 'in' expression ')'
      embedded_statement
    ;

12.6.2 Listes d'arguments : ajout de 'scoped'? pour la variable de déclaration out.

argument_value
    : expression
    | 'in' variable_reference
    | 'ref' variable_reference
    | 'out' ('scoped'? local_variable_type)? identifier
    ;

12.7 Expressions de déconstruction:

[TBD]

15.6.2 Paramètres de méthode : ajout de 'scoped'? à parameter_modifier.

fixed_parameter
    : attributes? parameter_modifier? type identifier default_argument?
    ;

parameter_modifier
    | 'this' 'scoped'? parameter_mode_modifier?
    | 'scoped' parameter_mode_modifier?
    | parameter_mode_modifier
    ;

parameter_mode_modifier
    : 'in'
    | 'ref'
    | 'out'
    ;

20.2 Déclarations de délégués: ajouté 'scoped'?indirectement à partir de fixed_parameter.

12.19 Expressions de fonctions anonymes : ajout de 'scoped'?.

explicit_anonymous_function_parameter
    : 'scoped'? anonymous_function_parameter_modifier? type identifier
    ;

anonymous_function_parameter_modifier
    : 'in'
    | 'ref'
    | 'out'
    ;

Types restreints en cours de retrait

Le compilateur a un concept d'un ensemble de « types restreints » qui est largement non documenté. Ces types ont reçu un statut spécial parce qu'en C# 1.0, il n'y avait pas d'usage général pour exprimer leur comportement. Il s'agit notamment du fait que les types peuvent contenir des références à la pile d'exécution. Au lieu de cela, le compilateur avait une connaissance spéciale de ces éléments et limitait leur utilisation à des façons qui seraient toujours sûres : retours interdits, pas d'utilisation comme éléments de tableau, pas d'utilisation dans les génériques, etc...

Une fois que les champs ref sont disponibles et étendus pour prendre en charge ref struct, ces types peuvent être définis correctement en C# à l’aide d’une combinaison de champs ref struct et ref. Par conséquent, lorsque le compilateur détecte qu'un système d'exécution prend en charge les champs ref, il n'a plus de notion de types restreints. Il utilisera plutôt les types tels qu'ils sont définis dans le code.

Pour prendre en charge cela, nos règles de contexte sécurisées ref seront mises à jour comme suit :

  • __makeref sera traité comme une méthode avec la signature static TypedReference __makeref<T>(ref T value)
  • __refvalue sera traitée comme une méthode avec la signature static ref T __refvalue<T>(TypedReference tr). L'expression __refvalue(tr, int) utilisera effectivement le deuxième argument comme paramètre de type.
  • __arglist en tant que paramètre aura un ref-safe-context et un safe-context de function-member.
  • __arglist(...) en tant qu'expression aura un ref-safe-context et un safe-context de function-member.

Les systèmes d'exécution conformes veilleront à ce que TypedReference, RuntimeArgumentHandle et ArgIterator soient définis comme ref struct. En outre, TypedReference doit être considéré comme ayant un champ ref vers un ref struct pour n'importe quel type possible (il peut stocker n'importe quelle valeur). Ceci, combiné aux règles ci-dessus, garantira que les références à la pile ne s'échappent pas au-delà de leur durée de vie.

Note : à proprement parler, il s'agit d'un détail d'implémentation du compilateur et non d'un élément du langage. Toutefois, étant donné la relation avec les champs ref, elle est incluse dans la proposition linguistique pour simplifier.

Fournir sans périmètre fixé

L'un des points de friction les plus notables est l'impossibilité de renvoyer des champs par ref dans les membres d'instance d'un struct. Cela signifie que les développeurs ne peuvent pas créer de méthodes/propriétés retournant ref et doivent se résoudre à exposer directement des champs. Cela réduit l'utilité des retours par ref dans struct, où ils sont souvent les plus souhaités.

struct S
{
    int _field;

    // Error: this, and hence _field, can't return by ref
    public ref int Prop => ref _field;
}

La justification de ce défaut est raisonnable, mais il n'y a rien d'intrinsèquement mauvais à ce qu'un struct échappe à this par référence, c'est simplement le défaut choisi par les règles de contexte safe-context.

Pour remédier à ce problème, le langage fournira l'inverse de l'annotation de durée de vie scoped en prenant en charge un UnscopedRefAttribute. Cela peut être appliqué à n’importe quel ref et il modifie la ref-safe-context pour devenir d'un niveau plus large que sa valeur par défaut. Exemple :

UnscopedRef appliqué à ref-safe-context original Nouveau ref-safe-context
membre d'instance function-member return-only
in / ref paramètre return-only contexte de l'appelant
out paramètre function-member return-only

Lors de l'application de [UnscopedRef] à une méthode d'instance d'un struct, cela a pour effet de modifier le paramètre implicite this. Cela signifie que this agit comme un ref non annoté du même type.

struct S
{
    int field; 

    // Error: `field` has the ref-safe-context of `this` which is *function-member* because 
    // it is a `scoped ref`
    ref int Prop1 => ref field;

    // Okay: `field` has the ref-safe-context of `this` which is *caller-context* because 
    // it is a `ref`
    [UnscopedRef] ref int Prop1 => ref field;
}

L'annotation peut également être placée sur les paramètres out pour les restaurer à leur comportement C# 10.

ref int SneakyOut([UnscopedRef] out int i)
{
    i = 42;
    return ref i;
}

Pour les besoins des règles de contexte ref fiables, un [UnscopedRef] out est considéré simplement comme un ref. Similaire à la façon dont in est considérée comme ref pour des raisons liées à la durée de vie.

L'annotation [UnscopedRef] ne sera pas autorisée pour les membres init et les constructeurs à l'intérieur de struct. Ces membres sont déjà considérés comme spéciaux en termes de la sémantique de ref, car ils voient les membres de readonly comme mutables. Cela signifie que le fait d'apporter ref à ces membres apparaît comme un simple ref, et non comme ref readonly. Cela est autorisé dans les limites des constructeurs et de init. Autoriser [UnscopedRef] permettrait à un tel ref de s'échapper incorrectement en dehors du constructeur et autoriserait une mutation après que la sémantique readonly a eu lieu.

Le type d'attribut aura la définition suivante :

namespace System.Diagnostics.CodeAnalysis
{
    [AttributeUsage(
        AttributeTargets.Method | AttributeTargets.Property | AttributeTargets.Parameter,
        AllowMultiple = false,
        Inherited = false)]
    public sealed class UnscopedRefAttribute : Attribute
    {
    }
}

Notes détaillées :

  • Une méthode ou une propriété d'instance annotée avec [UnscopedRef] a un ref-safe-context de this défini comme étant le caller-context.
  • Un membre annoté par [UnscopedRef] ne peut pas mettre en œuvre une interface.
  • C'est une erreur d'utiliser [UnscopedRef] sur
    • Un membre qui n'est pas déclaré sur un struct
    • un membre static, un membre init ou un constructeur sur un struct
    • Un paramètre marqué scoped
    • Un paramètre passé par valeur
    • Un paramètre passé par référence qui n'est pas implicitement étendu.

ScopedRefAttribute

Les annotations scoped seront émises dans les métadonnées via l'attribut type System.Runtime.CompilerServices.ScopedRefAttribute. L'attribut sera mis en correspondance avec le nom qualifié par l'espace de noms, de sorte que la définition n'a pas besoin d'apparaître dans un assemblage spécifique.

Le type ScopedRefAttribute est réservé à l'usage du compilateur - il n'est pas autorisé dans le code source. La déclaration de type est synthétisée par le compilateur si elle n'est pas déjà incluse dans la compilation.

Le type aura la définition suivante :

namespace System.Runtime.CompilerServices
{
    [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = false)]
    internal sealed class ScopedRefAttribute : Attribute
    {
    }
}

Le compilateur émettra cet attribut sur le paramètre avec la syntaxe scoped. Ceci ne sera émis que lorsque la syntaxe fait que la valeur diffère de son état par défaut. Par exemple, scoped out entraîne l'absence d'émission de tout attribut.

RefSafetyRulesAttribute

Il existe plusieurs différences dans les règles du contexte ref safe entre C#7.2 et C#11. Chacune de ces différences peut entraîner des changements disruptifs lors de la recompilation avec C#11 sur des références compilées avec C#10 ou des versions antérieures.

  1. Les paramètres ref/in/out sans espace peuvent échapper à une invocation de méthode en tant que champ ref d'un ref struct en C#11, mais pas en C#7.2.
  2. Les paramètres out sont implicitement scopés en C#11, et non scopés en C#7.2.
  3. ref/ Les paramètres in des types ref struct sont implicitement scopés en C#11, et non scopés en C#7.2.

Pour réduire les risques de rupture lors de la recompilation avec C#11, nous mettrons à jour le compilateur C#11 afin d'utiliser les règles de contexte ref-safe-context pour l'invocation de méthodes qui correspondent aux règles utilisées pour analyser la déclaration de méthode. Essentiellement, lors de l'analyse d'un appel à une méthode compilée avec un ancien compilateur, le compilateur C#11 utilisera les règles de contexte ref safe-context de C#7.2.

Pour permettre cela, le compilateur émettra un nouvel attribut [module: RefSafetyRules(11)] lorsque le module est compilé avec -langversion:11 ou plus ou compilé avec une corlib contenant l'indicateur de fonctionnalité pour les champs ref.

L'argument de l'attribut indique la version linguistique des règles du contexte ref safe utilisées lors de la compilation du module. La version est actuellement fixée à 11, quelle que soit la version du langage transmise au compilateur.

Les versions futures du compilateur devraient mettre à jour les règles du contexte ref-safe-context et émettre des attributs avec des versions distinctes.

Si le compilateur charge un module qui inclut un attribut [module: RefSafetyRules(version)] avec un version différent de 11, le compilateur signalera un avertissement pour la version non reconnue s'il y a des appels à des méthodes déclarées dans ce module.

Lorsque le compilateur C#11 analyse un appel de méthode :

  • Si le module contenant la déclaration de méthode inclut [module: RefSafetyRules(version)], quel que soit version, l'appel de méthode est analysé avec les règles C#11.
  • Si le module contenant la déclaration de méthode provient des sources et a été compilé avec -langversion:11 ou avec une corlib contenant l'indicateur de fonctionnalité pour les champs ref, l'appel de méthode est analysé avec les règles C#11.
  • Si le module contenant la déclaration de méthode fait référence à System.Runtime { ver: 7.0 }, l'appel de méthode est analysé avec les règles C#11. Cette règle est une mesure d'atténuation temporaire pour les modules compilés avec des versions antérieures de C#11 / .NET 7 et sera supprimée ultérieurement.
  • Dans le cas contraire, l'appel de méthode est analysé selon les règles C#7.2.

Un compilateur pré-C#11 ignorera tout RefSafetyRulesAttribute et analysera les appels de méthode avec les règles C#7.2 uniquement.

La correspondance entre RefSafetyRulesAttribute et le nom qualifié par l'espace de noms est établie, de sorte que la définition n'a pas besoin d'apparaître dans un assemblage spécifique.

Le type RefSafetyRulesAttribute est réservé à l'usage du compilateur - il n'est pas autorisé dans le code source. La déclaration de type est synthétisée par le compilateur si elle n'est pas déjà incluse dans la compilation.

namespace System.Runtime.CompilerServices
{
    [AttributeUsage(AttributeTargets.Module, AllowMultiple = false, Inherited = false)]
    internal sealed class RefSafetyRulesAttribute : Attribute
    {
        public RefSafetyRulesAttribute(int version) { Version = version; }
        public readonly int Version;
    }
}

Tampons de taille fixe sûrs

Les tampons sécurisés de taille fixe n'ont pas été fournis dans C# 11. Cette fonctionnalité pourrait être mise en œuvre dans une prochaine version de C#.

Le langage assouplira les restrictions sur les tableaux de taille fixe de manière à ce qu'ils puissent être déclarés dans un code sûr et que le type d'élément puisse être géré ou non géré. Cela rend des types comme les suivants légaux :

internal struct CharBuffer
{
    internal char Data[128];
}

Ces déclarations, tout comme leurs contreparties unsafe, définissent une séquence d’éléments N dans le type conteneur. Ces membres sont accessibles à l'aide d'un indexeur et peuvent également être convertis en instances Span<T> et ReadOnlySpan<T>.

Lors de l'indexation dans un tampon fixed de type T, l'état readonly du conteneur doit être pris en compte. Si le conteneur est readonly, l'indexeur renvoie ref readonly T, sinon il renvoie ref T.

L'accès à un tampon fixed sans indexeur n'a pas de type naturel, mais il est convertible en types Span<T>. Si le conteneur est readonly, le tampon est implicitement convertible en ReadOnlySpan<T>, sinon il peut être implicitement converti en Span<T> ou en ReadOnlySpan<T> (la conversion en Span<T> est considérée comme meilleure).

L'instance Span<T> résultante aura une longueur égale à la taille déclarée sur le tampon fixed. Le safe-context de la valeur renvoyée sera égal au safe-context du conteneur, comme ce serait le cas si l'on accédait aux données d'appui en tant que champ.

Pour chaque déclaration fixed dans un type dont le type d'élément est T, le langage génère une méthode d'indexation get only correspondante dont le type de retour est ref T. L'indexeur sera annoté avec l'attribut [UnscopedRef], car la mise en œuvre renverra des champs du type déclarant. L'accessibilité du membre correspondra à l'accessibilité du champ fixed.

Par exemple, la signature de l'indexeur pour CharBuffer.Data sera la suivante :

[UnscopedRef] internal ref char DataIndexer(int index) => ...;

Si l'index fourni se trouve en dehors des limites déclarées du tableau fixed, un IndexOutOfRangeException sera émis. Si une valeur constante est fournie, elle sera remplacée par une référence directe à l'élément approprié. Sauf si la constante est en dehors des limites déclarées, auquel cas une erreur de compilation se produira.

Un accesseur nommé sera également généré pour chaque tampon fixed, qui fournira par valeur des opérations get et set. Cela signifie que les tampons fixed ressembleront davantage à la sémantique existante des tableaux en ayant un accesseur ref ainsi que des opérations byval get et set. Cela signifie que les compilateurs auront la même flexibilité lors de l’émission de code consommant des mémoires tampons fixed que lorsqu'ils consomment des tableaux. Cela devrait faciliter l'émission d'opérations telles que await sur les tampons fixed.

Cela présente également l'avantage de rendre les tampons fixed plus faciles à utiliser dans d'autres langages. Les indexeurs nommés sont une fonctionnalité qui existe depuis la version 1.0 de .NET. Même les langages qui ne peuvent pas émettre directement un indexeur nommé peuvent généralement les utiliser (C# en est un bon exemple).

Le stockage de la mémoire tampon sera généré à l’aide de l’attribut [InlineArray]. Il s'agit d'un mécanisme discuté dans le problème 12320 qui permet spécifiquement le cas de la déclaration efficace d'une séquence de champs du même type. Ce problème particulier fait toujours l'objet d'une discussion active et l'on s'attend à ce que la mise en œuvre de cette fonctionnalité suive l'évolution de la discussion.

Initialisateurs avec des valeurs ref dans les expressions new et with

Dans la section 12.8.17.3 Initialisateurs d'objets, nous mettons à jour la grammaire comme suit :

initializer_value
    : 'ref' expression // added
    | expression
    | object_or_collection_initializer
    ;

Dans la section relative à l'expression with, nous mettons à jour la grammaire comme suit :

member_initializer
    : identifier '=' 'ref' expression // added
    | identifier '=' expression
    ;

L’opérande gauche de l’affectation doit être une expression qui se lie à un champ de référence.
L'opérande de droite doit être une expression qui produit une lvalue désignant une valeur du même type que l'opérande de gauche.

Nous ajoutons une règle similaire pour la réaffectation locale ref :
Si l'opérande de gauche est un ref inscriptible (c'est-à-dire qu'il désigne autre chose qu'un champ ref readonly), alors l'opérande de droite doit être une lvalue inscriptible.

Les règles d'échappement pour les invocations de constructeurs demeurent :

Une expression new qui invoque un constructeur obéit aux mêmes règles qu'une invocation de méthode qui est considérée comme renvoyant le type construit.

Notamment les règles d’invocation de la méthode qui ont été mises à jour ci-dessus :

Une rvalue résultant d'une invocation de méthode e1.M(e2, ...) a pour safe-context le plus petit des contextes suivants :

  1. le caller-context.
  2. Le safe-context apporté par toutes les expressions d'arguments.
  3. Lorsque le retour est un ref struct alors ref-safe-context contribué par tous les arguments ref.

Pour une expression new avec des initialiseurs, les expressions d’initialiseur sont considérées comme des arguments (elles contribuent à leur contexte sécurisé ) et les expressions d’initialiseur ref sont considérées comme des arguments ref (elles contribuent à leur ref-safe-contexte ), de manière récursive.

Changements dans un contexte non sûr

Les types de pointeurs (section 23.3) sont étendus pour permettre aux types gérés d'être des types référents. Ces types de pointeurs sont écrits sous la forme d'un type géré suivi d'un jeton *. Ils émettent un avertissement.

L'opérateur adresse-de (section 23.6.5) est assoupli pour accepter une variable avec un type géré comme opérande.

L'instruction fixed (section 23.7) est assouplie pour accepter un fixed_pointer_initializer qui est l'adresse d'une variable de type géré T ou qui est une expression d'un array_type avec des éléments de type géré T.

L'initialisateur de l'allocation de la pile (section 12.8.22) est assoupli de la même manière.

Considérations

D'autres parties de la pile de développement doivent prendre en compte certaines considérations lors de l'évaluation de cette fonctionnalité.

Considérations relatives à la compatibilité

La difficulté de cette proposition réside dans les implications de cette conception sur la compatibilité avec nos règles de sécurité d'étendue existantes, ou §9.7.2. Bien que ces règles soutiennent pleinement le concept d'un ref struct ayant des champs ref, elles ne permettent pas aux API, autres que stackalloc, de capturer l'état de ref qui se réfère à la pile. Les règles ref-safe-context partent du principe, ou du §16.4.12.8, qu'un constructeur de la forme Span(ref T value) n'existe pas. Cela signifie que les règles de sécurité ne prennent pas en compte un paramètre de ref pouvant s'échapper en tant que champ ref, ce qui permet du code comme celui-ci.

Span<int> CreateSpanOfInt()
{
    // This is legal according to the 7.2 span rules because they do not account
    // for a constructor in the form Span(ref T value) existing. 
    int local = 42;
    return new Span<int>(ref local);
}

En effet, il existe trois façons pour un paramètre ref d’échapper lors d’un appel de méthode :

  1. Par retour de valeur
  2. Par retour ref
  3. Par champ ref dans ref struct qui est retourné ou passé comme paramètre ref / out

Les règles existantes ne prennent en compte que les points (1) et (2). Ils ne prennent pas en compte le point (3), ce qui signifie que les lacunes telles que le retour des éléments locaux en tant que champs ref ne sont pas prises en compte. Ce design doit modifier les règles pour tenir compte de (3). Cela aura un faible impact sur la compatibilité des API existantes. En particulier, elle aura un impact sur les API qui ont les propriétés suivantes.

  • Avoir un ref struct dans la signature
    • Lorsque le ref struct est un type de retour, un paramètre ref ou out
    • possède un paramètre in ou ref supplémentaire, à l'exclusion du récepteur

Dans C# 10, les appelants de telles API n'ont jamais eu à considérer que l'état d'entrée ref de l'API pourrait être capturé comme un champ ref. Cela a permis à plusieurs modèles d’exister, en toute sécurité en C# 10 ; ceci deviendra non sécurisé en C# 11 en raison de la possibilité pour l'état ref de s'échapper en tant que champ ref. Exemple :

Span<int> CreateSpan(ref int parameter)
{
    // The implementation of this method is irrelevant when considering the lifetime of the 
    // returned Span<T>. The ref safe context rules only look at the method signature, not the 
    // implementation. In C# 10 ref fields didn't exist hence there was no way for `parameter`
    // to escape by ref in this method
}

Span<int> BadUseExamples(int parameter)
{
    // Legal in C# 10 but would be illegal with ref fields
    return CreateSpan(ref parameter);

    // Legal in C# 10 but would be illegal with ref fields
    int local = 42;
    return CreateSpan(ref local);

    // Legal in C# 10 but would be illegal with ref fields
    Span<int> span = stackalloc int[42];
    return CreateSpan(ref span[0]);
}

L'impact de cette rupture de compatibilité devrait être très faible. La forme d'API concernée n'a guère de sens en l'absence de champs ref et il est donc peu probable que les clients en aient créé beaucoup. Des expériences utilisant des outils pour repérer cette forme d'API dans les référentiels existants soutiennent cette assertion. Le seul référentiel avec un nombre significatif de cette forme est dotnet/runtime et c'est parce que ce référentiel peut créer des champs ref via le type intrinsèque ByReference<T>.

Malgré cela, la conception doit tenir compte de l'existence de ces API, car elles expriment un modèle valide, mais pas courant. Par conséquent, la conception doit donner aux développeurs les outils nécessaires pour restaurer les règles de durée de vie existantes lors de la mise à niveau vers C# 10. En particulier, il doit fournir des mécanismes permettant aux développeurs d'annoter les paramètres ref comme ne pouvant être échappés par ref ou un champ ref. Cela permet aux clients de définir des API en C# 11 qui ont les mêmes règles de durée de vie qu'en C# 10.

Assemblées de référence

Un assemblage de référence pour une compilation utilisant les fonctionnalités décrites dans cette proposition doit maintenir les éléments qui transmettent les informations de contexte ref-safe-context. Cela signifie que tous les attributs d'annotation de la durée de vie doivent être préservés dans leur position d'origine. Toute tentative de les remplacer ou de les omettre peut conduire à des assemblages de référence non valides.

La représentation des champs ref est plus nuancée. Idéalement, un champ ref devrait apparaître dans un assemblage de référence comme n'importe quel autre champ. Toutefois, un champ ref représente une modification du format des métadonnées, ce qui peut entraîner des problèmes avec les chaînes d'outils qui n'ont pas été mises à jour pour comprendre cette modification des métadonnées. Un exemple concret est celui de C++/CLI qui risque de provoquer une erreur s'il consomme un champ ref. Par conséquent, il est avantageux si les champs ref peuvent être omis dans des assemblages de référence de nos bibliothèques principales.

Un champ ref en lui-même n'a aucun impact sur les règles de ref safe-context. À titre d’exemple concret, envisager que remplacer la définition Span<T> existante par l'utilisation d'un champ ref n'a aucun impact sur la consommation. Le champ ref lui-même peut donc être omis en toute sécurité. Toutefois, un champ ref a d'autres effets sur la consommation qui doivent être préservés :

  • Un ref struct qui a un champ ref n'est jamais considéré comme unmanaged
  • Le type du champ ref a une incidence sur les règles d'expansion générique infinie. Par conséquent, si le type d'un champ ref contient un paramètre de type, celui-ci doit être préservé.

Compte tenu de ces règles, voici une transformation d'assemblage de référence valide pour un ref struct :

// Impl assembly 
ref struct S<T>
{
    ref T _field;
}

// Ref assembly 
ref struct S<T>
{
    object _o; // force managed 
    T _f; // maintain generic expansion protections
}

Annotations

Les durées de vie sont plus naturellement exprimées à l'aide de types. Les durées de vie d'un programme donné sont sûres lorsque les types de durée de vie vérifient le type. Bien que la syntaxe de C# ajoute implicitement des durées de vie aux valeurs, il existe un système de types sous-jacent qui décrit les règles fondamentales en la matière. Il est souvent plus facile de discuter de l'implication des changements dans la conception en termes de ces règles, c'est pourquoi elles sont incluses ici à des fins de discussion.

Notez que cette documentation n'est pas exhaustive. Documenter chaque comportement n'est pas un objectif ici. Il s'agit plutôt d'établir une compréhension générale et un verbiage commun permettant de discuter du modèle et de ses modifications potentielles.

En général, il n'est pas nécessaire de parler directement des types de durée de vie. Les exceptions sont les endroits où les durées de vie peuvent varier en fonction de sites d'« instanciation » particuliers. Il s'agit d'une sorte de polymorphisme et nous appelons ces durées de vie variables des « durées de vie génériques », représentées par des paramètres génériques. Le langage C# ne fournit pas de syntaxe pour exprimer les durées de vie génériques. Nous définissons donc une « traduction » implicite du langage C# vers un langage étendu abaissé qui contient des paramètres génériques explicites.

Les exemples ci-dessous utilisent des durées de vie nommées. La syntaxe $a fait référence à une durée de vie nommée a. Il s'agit d'une durée de vie qui n'a pas de signification en soi, mais qui peut être mise en relation avec d'autres durées de vie par le biais de la syntaxe where $a : $b. Celle-ci établit que $a est convertible en $b. Il peut être utile de considérer cela comme établissant que $a est une durée de vie au moins égale à $b.

Il existe quelques durées de vie prédéfinies pour des raisons de commodité et de brièveté :

  • $heap : il s'agit de la durée de vie de toute valeur existant sur le tas. Elle est disponible dans tous les contextes et toutes les signatures de méthode.
  • $local: Elle est disponible dans tous les contextes et toutes les signatures de méthode. Il s'agit en fait d'un porte-nom pour function-member. Elle est implicitement définie dans les méthodes et peut apparaître dans les signatures de méthodes, à l'exception de toute position de sortie.
  • $ro : le nom tient lieu de return only
  • $cm: nom générique pour le caller-context

Il existe quelques relations prédéfinies entre les durées de vie :

  • where $heap : $a pour toutes les durées de vie $a
  • where $cm : $ro
  • where $x : $local pour toutes les durées de vie prédéfinies. Les durées de vie définies par l'utilisateur n'ont aucune relation avec les durées de vie locales, sauf si elles sont explicitement définies.

Les variables de durée de vie définies sur des types peuvent être invariantes ou covariantes. Elles sont exprimées à l'aide de la même syntaxe que les paramètres génériques :

// $this is covariant
// $a is invariant
ref struct S<out $this, $a> 

Le paramètre de durée de vie $this sur les définitions de type n'est pas prédéfini, mais il a quelques règles qui lui sont associées lorsqu'il est défini :

  • Il doit s'agir du premier paramètre de durée de vie.
  • Il doit être covariant : out $this.
  • La durée de vie des champs ref doit être convertible en $this
  • La durée de vie $this de tous les champs non-ref doit être $heap ou $this.

La durée de vie d’une référence est exprimée en fournissant un argument de durée de vie à la référence. Par exemple, une référence marquée comme ref et qui fait référence au tas est exprimée comme une référence ref<$heap>.

Lors de la définition d'un constructeur dans le modèle, le nom new sera utilisé pour la méthode. Il est nécessaire d'avoir une liste de paramètres pour la valeur retournée ainsi que pour les arguments du constructeur. Cela est nécessaire pour exprimer la relation entre les entrées du constructeur et la valeur construite. Au lieu d'utiliser Span<$a><$ro>, le modèle utilisera Span<$a> new<$ro>. Le type de this dans le constructeur, y compris les durées de vie, sera la valeur de retour définie.

Les règles de base pour la durée de vie sont définies comme suit :

  • Toutes les durées de vie sont exprimées de manière syntactique en tant qu’arguments génériques, précédant les arguments de type. Ceci est vrai pour les durées de vie prédéfinies, à l'exception de $heap et $local.
  • Tous les types T qui ne sont pas des ref struct ont implicitement une durée de vie de T<$heap>. Ceci est implicite, il n'est pas nécessaire d'écrire int<$heap> dans chaque échantillon.
  • Pour un champ ref défini comme ref<$l0> T<$l1, $l2, ... $ln> :
    • Toutes les durées de vie de $l1 à $ln doivent être invariantes.
    • La durée de vie de $l0 doit être convertible en $this.
  • Pour un ref défini comme ref<$a> T<$b, ...>, $b doit être convertible en $a.
  • La durée de vie de ref d'une variable est définie par :
    • Pour un ref local, un paramètre, un champ ou un retour de type ref<$a> T, la durée de vie est de $a
    • $heap pour tous les types de référence et les champs des types de référence
    • $local pour tout le reste
  • Une affectation ou un retour est légal lorsque la conversion de type sous-jacente est légale
  • Les durées de vie des expressions peuvent être rendues explicites en utilisant les annotations cast :
    • (T<$a> expr) la durée de vie de la valeur est explicitement $a pour T<...>
    • ref<$a> (T<$b>)expr la durée de vie de la valeur est $b pour T<...> et la durée de vie de la référence est $a.

Pour les besoins des règles de durée de vie, un ref est considéré comme faisant partie du type de l'expression pour les besoins des conversions. Elle est représentée logiquement par la conversion de ref<$a> T<...> en ref<$a, T<...>>$a est covariant et T est invariant.

Définissons ensuite les règles qui nous permettent de mapper la syntaxe C# au modèle sous-jacent.

Par souci de concision, un type qui n’a aucun paramètre de durée de vie explicite est traité comme s'il existait un out $this qui est défini et appliqué à tous les champs du type. Un type avec un champ ref doit définir des paramètres explicites de durée de vie.

Cette règle existe pour soutenir notre invariant existant selon lequel T peut être affecté à scoped T pour tous types. Cela se mappe à T<$a, ...> étant assignable à T<$local, ...> pour toutes les durées de vie connues pour être convertibles en $local. En outre, cela permet d'autres choses, comme la possibilité d'assigner Span<T> du tas à ceux qui se trouvent sur la pile. Cela exclut les types où les champs ont des durées de vie différentes pour les valeurs non-réf, mais c'est la réalité de C# aujourd'hui. Modifier cela nécessiterait un changement significatif des règles C# qui devraient être tracées.

Le type de this pour un type S<out $this, ...> à l'intérieur d'une méthode d'instance est implicitement défini comme suit :

  • Pour une méthode d'instance normale : ref<$local> S<$cm, ...>
  • Pour une méthode d'instance annotée avec [UnscopedRef] : ref<$ro> S<$cm, ...>

L'absence d'un paramètre this explicite impose ici les règles implicites. Pour les exemples et les discussions complexes, envisagez d'écrire comme une méthode static et de faire de this un paramètre explicite.

ref struct S<out $this>
{
    // Implicit this can make discussion confusing 
    void M<$ro, $cm>(ref<$ro> S<$cm> s) {  }

    // Rewrite as explicit this to simplify discussion
    static void M<$ro, $cm>(ref<$local> S<$cm> this, ref<$ro> S<$cm> s) { }
}

La syntaxe des méthodes C# mappe le modèle de la manière suivante :

  • Les paramètres ref ont une durée de vie de référence de $ro
  • les paramètres de type ref struct ont une durée de vie de $cm
  • les retours de type ref ont une durée de vie de $ro
  • les retours de type ref struct ont une durée de vie de $ro
  • scoped sur un paramètre ou ref modifie la durée de vie de la référence pour qu'elle soit $local.

Explorons donc un exemple simple qui illustre le modèle présenté ici :

ref int M1(ref int i) => ...

// Maps to the following. 

ref<$ro> int Identity<$ro>(ref<$ro> int i)
{
    // okay: has ref lifetime $ro which is equal to $ro
    return ref i;

    // okay: has ref lifetime $heap which convertible $ro
    int[] array = new int[42];
    return ref array[0];

    // error: has ref lifetime $local which has no conversion to $a hence 
    // it's illegal
    int local = 42;
    return ref local;
}

Explorons maintenant le même exemple en utilisant une méthode ref struct :

ref struct S
{
    ref int Field;

    S(ref int f)
    {
        Field = ref f;
    }
}

S M2(ref int i, S span1, scoped S span2) => ...

// Maps to 

ref struct S<out $this>
{
    // Implicitly 
    ref<$this> int Field;

    S<$ro> new<$ro>(ref<$ro> int f)
    {
        Field = ref f;
    }
}

S<$ro> M2<$ro>(
    ref<$ro> int i,
    S<$ro> span1)
    S<$local> span2)
{
    // okay: types match exactly
    return span1;

    // error: has lifetime $local which has no conversion to $ro
    return span2;

    // okay: type S<$heap> has a conversion to S<$ro> because $heap has a
    // conversion to $ro and the first lifetime parameter of S<> is covariant
    return default(S<$heap>)

    // okay: the ref lifetime of ref $i is $ro so this is just an 
    // identity conversion
    S<$ro> local = new S<$ro>(ref $i);
    return local;

    int[] array = new int[42];
    // okay: S<$heap> is convertible to S<$ro>
    return new S<$heap>(ref<$heap> array[0]);

    // okay: the parameter of the ctor is $ro ref int and the argument is $heap ref int. These 
    // are convertible.
    return new S<$ro>(ref<$heap> array[0]);

    // error: has ref lifetime $local which has no conversion to $a hence 
    // it's illegal
    int local = 42;
    return ref local;
}

Voyons ensuite comment cela peut aider à résoudre le problème de l'auto-assignation cyclique :

ref struct S
{
    int field;
    ref int refField;

    static void SelfAssign(ref S s)
    {
        s.refField = ref s.field;
    }
}

// Maps to 

ref struct S<out $this>
{
    int field;
    ref<$this> int refField;

    static void SelfAssign<$ro, $cm>(ref<$ro> S<$cm> s)
    {
        // error: the types work out here to ref<$cm> int = ref<$ro> int and that is 
        // illegal as $ro has no conversion to $cm (the relationship is the other direction)
        s.refField = ref<$ro> s.field;
    }
}

Voyons ensuite comment cela permet de résoudre le problème du paramètre de capture stupide :

ref struct S
{
    ref int refField;

    void Use(ref int parameter)
    {
        // error: this needs to be an error else every call to this.Use(ref local) would fail 
        // because compiler would assume the `ref` was captured by ref.
        this.refField = ref parameter;
    }
}

// Maps to 

ref struct S<out $this>
{
    ref<$this> int refField;
    
    // Using static form of this method signature so the type of this is explicit. 
    static void Use<$ro, $cm>(ref<$local> S<$cm> @this, ref<$ro> int parameter)
    {
        // error: the types here are:
        //  - refField is ref<$cm> int
        //  - ref parameter is ref<$ro> int
        // That means the RHS is not convertible to the LHS ($ro is not covertible to $cm) and 
        // hence this reassignment is illegal
        @this.refField = ref<$ro> parameter;
    }
}

défis ouverts

Modifier la conception pour éviter les ruptures de compatibilité

Cette conception propose plusieurs ruptures de compatibilité avec nos règles ref-safe-context existantes. Même si les changements sont considérés comme minimaux en termes d'impact, une attention significative a été accordée à une conception qui évite les modifications perturbatrices.

La conception préservant la compatibilité était beaucoup plus complexe que celle-ci. Afin de préserver la compatibilité, les champs ref doivent avoir des durées de vie distinctes pour pouvoir être retournés par le champ ref et le champ ref. Essentiellement, cela nous oblige à fournir un ref-field-safe-context tracking pour tous les paramètres d'une méthode. Cela doit être calculé pour toutes les expressions et suivi dans toutes les valeurs pratiquement partout où le ref-safe-context est suivi aujourd'hui.

De plus, cette valeur a des relations avec le ref-safe-context. Par exemple, il est absurde d'avoir une valeur qui peut être retournée en tant que champ ref, mais pas directement en tant que champ ref. Cela est dû au fait que les champs ref peuvent être trivialement retournés par ref déjà (l'état deref dans un ref struct peut être retourné par ref même lorsque la valeur qui le contient ne le peut pas). Les règles doivent donc être constamment ajustées pour garantir que ces valeurs sont raisonnables les unes par rapport aux autres.

Cela signifie également que le langage a besoin d'une syntaxe pour représenter ref paramètres qui peuvent être retournés de trois manières différentes : par le champ ref, par ref et par la valeur. Par défaut, les paramètres peuvent être retournés par ref. Toutefois, à l'avenir, le retour le plus naturel, en particulier lorsque ref struct est impliqué, devrait se faire par le champ ref ou ref. Cela signifie que les nouvelles API nécessitent une annotation syntaxique supplémentaire pour être correctes par défaut. Ce n'est pas souhaitable.

Ces changements de compatibilité auront cependant un impact sur les méthodes qui ont les propriétés suivantes :

  • Avoir un Span<T> ou un ref struct
    • Lorsque le ref struct est un type de retour, un paramètre ref ou out
    • A un paramètre in ou ref supplémentaire (à l'exclusion du récepteur)

Pour comprendre l'impact, il est utile de diviser les API en catégories :

  1. Les consommateurs doivent tenir compte du fait que ref est capturé en tant que champ ref. Les constructeurs Span(ref T value) en sont le meilleur exemple.
  2. Ne veulent pas que les consommateurs tiennent compte du fait que ref est capturé en tant que champ ref. Celles-ci se répartissent en deux catégories XXX
    1. API non sûres. Il s'agit des APIS des types Unsafe et MemoryMarshal, dont MemoryMarshal.CreateSpan est le plus important. Ces API capturent le ref de manière non sécurisée, mais elles sont également connues comme étant des API non sécurisées.
    2. API sûres. Il s'agit d'API qui prennent des paramètres ref à des fins d'efficacité, mais qui ne sont capturés nulle part. Les exemples sont peu nombreux, mais l'un d'entre eux est AsnDecoder.ReadEnumeratedBytes

Ce changement profite principalement à la catégorie (1) ci-dessus. On s'attend à ce qu'elles constituent la majorité des API qui prennent un ref et renvoient un ref struct à l'avenir. Les modifications ont un impact négatif sur (2.1) et (2.2) car elles interrompent la sémantique d'appel existante en raison du changement des règles de durée de vie.

Les API de la catégorie (2.1) sont en grande partie créées par Microsoft ou par les développeurs qui ont le plus à gagner des champs ref (les Tanner du monde). Il est raisonnable de supposer que cette classe de développeurs serait ouverte à une taxe de compatibilité lors de la mise à niveau vers C# 11 sous la forme de quelques annotations pour conserver la sémantique existante, si les champs ref étaient fournis en retour.

Les API de la catégorie (2.2) constituent le plus gros problème. On ne sait pas combien d'API de ce type existent et on ne sait pas si elles sont plus ou moins fréquentes dans le code des tiers. On s'attend à ce qu'il y en ait un très petit nombre, en particulier si nous prenons la pause compat sur out. Les recherches effectuées jusqu'à présent ont révélé qu'un très petit nombre d'entre eux existent dans la surface public. Il s'agit toutefois d'un modèle difficile à rechercher car il nécessite une analyse sémantique. Avant de procéder à cette modification, il conviendrait d'adopter une approche fondée sur des outils afin de vérifier les hypothèses relatives à l'impact de cette modification sur un petit nombre de cas connus.

Pour les deux cas de la catégorie (2), la solution est simple. Les paramètres ref qui ne veulent pas être considérés comme capturables doivent ajouter scoped à ref. En (2.1), cela obligera probablement aussi le développeur à utiliser Unsafe ou MemoryMarshal, mais c'est ce qui est prévu pour les API de style non sûr.

Dans l’idéal, le langage pourrait réduire l’impact des modifications de rupture silencieuses en émettant un avertissement lorsqu’une API adopte discrètement un comportement problématique. Il s’agirait d’une méthode qui prend un ref, et retourne ref struct, mais ne capture pas réellement le ref dans le ref struct. Le compilateur pourrait émettre un problème dans ce cas en informant les développeurs que ref devrait être annoté comme scoped ref à la place.

Décision Cette conception peut être réalisée, mais la fonctionnalité qui en résulte est plus difficile à utiliser, au point que la décision a été prise d'interrompre la compatibilité.

Décision Le compilateur émettra un avertissement lorsqu'une méthode répond aux critères mais ne capture pas le paramètre ref en tant que champ ref. Cela devrait avertir adéquatement les clients lors de la mise à niveau sur les problèmes potentiels qu’ils pourraient créer.

Mots clés ou attributs

Cette conception prévoit l'utilisation d'attributs pour annoter les nouvelles règles de durée de vie. Cela aurait pu être fait tout aussi facilement avec des mots-clés contextuels. Par exemple, [DoesNotEscape] peut être associé à scoped. Toutefois, les mots-clés, même contextuels, doivent généralement satisfaire à des critères très stricts pour être inclus. Ils occupent une place précieuse dans la langue et en sont des éléments plus importants. Cette fonctionnalité, bien que précieuse, ne servira qu'à une minorité de développeurs C#.

À première vue, cela semblerait favoriser de ne pas utiliser de mots-clés, mais il y a deux points importants à prendre en compte :

  1. Les annotations auront un effet sur la sémantique du programme. Le fait que des attributs influencent la sémantique du programme est une limite que C# hésite à franchir, et il n'est pas clair si cette fonctionnalité justifie que le langage fasse ce pas.
  2. Les développeurs les plus susceptibles d'utiliser cette fonctionnalité se recoupent fortement avec l'ensemble des développeurs qui utilisent des pointeurs de fonction. Cette fonctionnalité, bien que utilisée par une minorité de développeurs, a justifié une nouvelle syntaxe et cette décision est toujours considérée comme satisfaisante.

Pris ensemble, cela signifie que la syntaxe doit être prise en compte.

Voici une ébauche de la syntaxe :

  • [RefDoesNotEscape] correspond à scoped ref
  • [DoesNotEscape] correspond à scoped
  • [RefDoesEscape] correspond à unscoped

Décision Utiliser la syntaxe pour scoped et scoped ref; utiliser l'attribut pour unscoped.

Autoriser les tampons locaux fixes

Cette conception permet d'avoir des tampons fixed sûrs qui peuvent supporter n'importe quel type. Une extension possible est de permettre à ces tampons fixed d'être déclarés comme des variables locales. Cela permettrait de remplacer un certain nombre d'opérations stackalloc existantes par un tampon fixed. Elle élargirait également l'ensemble des scénarios dans lesquels nous pourrions avoir des allocations de type pile, puisque stackalloc est limité aux types d'éléments non gérés, alors que les tampons fixed ne le sont pas.

class FixedBufferLocals
{
    void Example()
    {
        Span<int> span = stackalloc int[42];
        int buffer[42];
    }
}

Cela fonctionne, mais nous oblige à étendre un peu la syntaxe pour les locaux. Il n'est pas clair si cela vaut ou non la complexité supplémentaire. Il est possible que nous décidions de ne pas le faire pour l'instant et que nous y revenions plus tard si un besoin suffisant est démontré.

Exemple de cas où cela serait bénéfique : https://github.com/dotnet/runtime/pull/34149

Décision d'attente pour l'instant

Faut-il utiliser les modreqs ou non ?

Il convient de décider si les méthodes marquées par de nouveaux attributs de durée de vie doivent ou non être traduites en modreq dans emit. Il y aurait effectivement un mappage 1:1 entre les annotations et modreq si cette approche était adoptée.

L'ajout d'un modreq se justifie par le fait que les attributs modifient la sémantique des règles de ref-safe-context. Seuls les langages qui comprennent cette sémantique devraient appeler les méthodes en question. En outre, lorsqu'elles sont appliquées aux scénarios OHI, les durées de vie deviennent un contrat que toutes les méthodes dérivées doivent mettre en œuvre. Le fait que les annotations existent sans modreq peut conduire à des situations où des chaînes de méthodes virtual avec des annotations de durée de vie conflictuelles sont chargées (cela peut arriver si une seule partie de la chaîne virtual est compilée et que l'autre ne l'est pas).

Le travail initial sur le contexte ref-safe-context n'a pas utilisé modreq mais s'est appuyé sur les langues et le framework pour comprendre. En même temps, tous les éléments qui contribuent aux règles ref-safe-context font partie intégrante de la signature de la méthode : ref, in, ref struct, etc ... Par conséquent, toute modification des règles existantes d'une méthode entraîne déjà une modification binaire de la signature. Pour que les nouvelles annotations de durée de vie aient le même impact, il faudra qu'elles utilisent modreq.

La question est de savoir si cela n'est pas exagéré. Cela a un impact négatif en rendant les signatures plus flexibles, par exemple l’ajout de [DoesNotEscape] à un paramètre, ce qui entraîne une modification de la compatibilité binaire. Ce compromis signifie qu'au fil du temps, les frameworks comme BCL ne seront probablement pas en mesure d'assouplir de telles signatures. Elle pourrait être atténuée dans une certaine mesure en adoptant l'approche de langage utilisée avec les paramètres in et en n'appliquant modreq que dans des positions virtuelles.

Décision Ne pas utiliser modreq dans les métadonnées. La différence entre out et ref n'est pas modreq mais des valeurs de ref-safe-context différentes. Il n’y a aucun avantage réel à appliquer les règles qu'à moitié avec modreq ici.

Autoriser les tampons fixes multidimensionnels

La conception des tampons fixed devrait-elle être étendue pour inclure les tableaux multidimensionnels ? Il s'agit essentiellement d'autoriser des déclarations telles que la suivante :

struct Dimensions
{
    int array[42, 13];
}

Décision : Ne pas autoriser pour l’instant

Violation de la portée

Le référentiel d'exécution possède plusieurs API non publiques qui capturent les paramètres ref en tant que champs ref. Celles-ci ne sont pas sûres car la durée de vie de la valeur résultante n'est pas suivie. Par exemple, le constructeur Span<T>(ref T value, int length).

La majorité de ces API choisiront probablement d'avoir un suivi approprié de la durée de vie sur le retour, ce qui sera réalisé simplement par la mise à jour vers C# 11. Quelques-unes cependant voudront conserver leur sémantique actuelle de non suivi de la valeur de retour parce que leur intention est d'être peu sûres. Les exemples les plus notables sont MemoryMarshal.CreateSpan et MemoryMarshal.CreateReadOnlySpan. Pour ce faire, il faut marquer les paramètres comme scoped.

Cela signifie que le moteur d'exécution a besoin d'un modèle établi pour supprimer scoped d'un paramètre de manière non sûre :

  1. Unsafe.AsRef<T>(in T value) pourrait étendre son objectif existant en se transformant en scoped in T value. Cela lui permettrait à la fois de supprimer in et scoped des paramètres. Il s'agit alors de la méthode universelle « remove ref safety »
  2. Introduire une nouvelle méthode dont l'objectif est de supprimer scoped : ref T Unsafe.AsUnscoped<T>(scoped in T value). Cela supprime également in, car si ce n'était pas le cas, les appelants auraient encore besoin d'une combinaison d'appels de méthodes pour « supprimer la sécurité des références », la solution existante étant alors probablement suffisante.

Déchiffrer cette méthode par défaut ?

La conception ne comporte que deux emplacements qui sont scoped par défaut :

  • this est scoped ref
  • out est scoped ref

La décision concernant out est de réduire considérablement la charge de compatibilité des champs ref et en même temps constitue un paramètre par défaut plus naturel. Il permet aux développeurs de considérer réellement out comme des données circulant uniquement vers l'extérieur, tandis que si c'est ref, les règles doivent prendre en compte le flux de données dans les deux sens. Cela entraîne une grande confusion chez les développeurs.

La décision concernant this n’est pas souhaitable, car cela signifie qu’un struct ne peut pas renvoyer un champ par ref. Il s'agit d'un scénario important pour les développeurs de haut niveau et l'attribut [UnscopedRef] a été ajouté essentiellement pour ce scénario.

Les mots-clés ont un niveau élevé et l'ajouter pour un seul scénario est suspect. Nous avons réfléchi à si nous pouvions éviter complètement ce mot clé en rendant this simplement ref par défaut au lieu de scoped ref. Tous les membres qui ont besoin que this soit scoped ref pourraient le faire en marquant la méthode scoped (comme une méthode peut être marquée readonly pour créer un readonly ref aujourd’hui).

Sur un struct normal, c'est surtout un changement positif car il n'introduit des problèmes de compatibilité que lorsqu'un membre a un return-only de ref. Il y a très peu de ces méthodes et un outil pourrait les repérer et les convertir rapidement en membres scoped.

Sur un ref struct, ce changement introduit des problèmes de compatibilité beaucoup plus importants. Tenez compte des éléments suivants :

ref struct Sneaky
{
    int Field;
    ref int RefField;

    public void SelfAssign()
    {
        // This pattern of ref reassign to fields on this inside instance methods would now
        // completely legal.
        RefField = ref Field;
    }

    static Sneaky UseExample()
    {
        Sneaky local = default;

        // Error: this is illegal, and must be illegal, by our existing rules as the 
        // ref-safe-context of local is now an input into method arguments must match. 
        local.SelfAssign();

        // This would be dangerous as local now has a dangerous `ref` but the above 
        // prevents us from getting here.
        return local;
    }
}

Essentiellement, cela signifierait que toutes les invocations de méthodes d'instance sur des membres locaux mutablesref struct seraient illégales à moins que le membre local ne soit marqué comme scoped. Les règles doivent tenir compte du cas où les champs ont été réaffectés à d'autres champs dans this. Un readonly ref struct n'a pas ce problème parce que la nature readonly empêche la réaffectation des refs. Néanmoins, il s'agirait d'un changement important qui romprait la compatibilité arrière, car il aurait un impact sur pratiquement tous les ref struct mutables existants.

Cependant, un readonly ref struct reste problématique une fois que nous avons étendu les champs ref à ref struct. Cela permet de résoudre le même problème de base en déplaçant simplement la capture dans la valeur du champ ref :

readonly ref struct ReadOnlySneaky
{
    readonly int Field;
    readonly ref ReadOnlySpan<int> Span;

    public void SelfAssign()
    {
        // Instance method captures a ref to itself
        Span = new ReadOnlySpan<int>(ref Field, 1);
    }
}

On a envisagé l'idée que this puisse avoir des valeurs par défaut différentes en fonction du type de struct ou du membre. Exemple :

  • this comme ref : struct, readonly ref struct ou readonly member
  • this en tant que scoped ref: ref struct ou readonly ref struct avec le champ ref à ref struct

Cela réduit les ruptures de compatibilité et optimise la flexibilité, mais au prix de compliquer l'expérience pour les clients. Il ne résout pas entièrement le problème, car les caractéristiques futures, telles que les mémoires tampons sécurisées de fixed, nécessitent qu'un ref struct mutable ait des retours ref pour des champs qui ne fonctionnent pas avec cette conception seule, car ils tomberaient dans la catégorie scoped ref.

Décision conserver this en tant que scoped ref. Cela signifie que les exemples insidieux précédents produisent des erreurs de compilateur.

ref fields vers ref struct

Cette fonctionnalité ouvre la voie à un nouvel ensemble de règles ref-safe-context car elle permet à un champ ref de renvoyer à un champ ref struct. Cette nature générique de ByReference<T> signifiait jusqu'à présent que le moteur d'exécution ne pouvait pas disposer d'une telle construction. Par conséquent, toutes nos règles sont écrites en partant du principe que ce n'est pas possible. La fonctionnalité du champ ref ne consiste pas à créer de nouvelles règles, mais à codifier les règles existantes dans notre système. Le fait d'autoriser les champs ref à ref struct nous oblige à codifier de nouvelles règles parce qu'il y a plusieurs nouveaux scénarios à prendre en compte.

La première raison est qu’un readonly ref est désormais capable de stocker l'état de ref. Exemple :

readonly ref struct Container
{
    readonly ref Span<int> Span;

    void Store(Span<int> span)
    {
        Span = span;
    }
}

Cela signifie que, lorsque nous réfléchissons aux arguments de méthode qui doivent respecter les règles, nous devons considérer que readonly ref T est une sortie potentielle de la méthode lorsque T a potentiellement un champ ref lié à un ref struct.

Le deuxième problème est que le langage doit prendre en compte un nouveau type de contexte sûr : le ref-field safe-context. Tous les ref struct qui contiennent transitoirement un champ ref ont une autre étendue d'échappement représentant la ou les valeurs du ou des champs ref. Dans le cas de plusieurs champs ref, ils peuvent être suivis collectivement comme une valeur unique. La valeur par défaut de ce champ pour les paramètres est caller-context.

ref struct Nested
{
    ref Span<int> Span;
}

Span<int> M(ref Nested nested) => nested.Span;

Cette valeur n’est pas liée au contexte sécurisé du conteneur ; c’est-à-dire que, lorsque le contexte du conteneur se réduit, cela n'a aucun impact sur le contexte sécurisé du champ de référence des valeurs de champ ref. En outre, le ref-field-safe-context ne peut jamais être plus petit que le safe-context du conteneur.

ref struct Nested
{
    ref Span<int> Span;
}

void M(ref Nested nested)
{
    scoped ref Nested refLocal = ref nested;

    // the ref-field-safe-context of local is still *caller-context* which means the following
    // is illegal
    refLocal.Span = stackalloc int[42];

    scoped Nested valLocal = nested;

    // the ref-field-safe-context of local is still *caller-context* which means the following
    // is still illegal
    valLocal.Span = stackalloc int[42];
}

Ce ref-field safe-context a essentiellement toujours existé. Jusqu'à présent, les champs ref ne pouvaient pointer que sur le struct normal et étaient donc trivialement réduits au caller-context. Pour prendre en charge les champs ref vers ref struct, nos règles existantes doivent être mises à jour pour tenir compte de ce nouveau ref-safe-context.

Troisièmement, les règles de réaffectation de ref doivent être mises à jour pour s'assurer que nous ne violons pas le ref-field-context pour les valeurs. Essentiellement, pour x.e1 = ref e2 où le type de e1 est un ref struct, le ref-field-safe-context doit être égal.

Ces problèmes sont très solubles. L’équipe du compilateur a esquissé quelques versions de ces règles et elles découlent en grande partie de notre analyse existante. Le problème est qu'il n'existe pas de code de consommation pour ces règles, qui permette d'en prouver l'exactitude et la facilité d'utilisation. Cela nous fait hésiter à ajouter un support par peur de choisir de mauvaises valeurs par défaut et de faire reculer le runtime dans ses limites d'utilisabilité lorsqu'il en tirera profit. Cette préoccupation est d'autant plus forte que .NET 8 nous pousse probablement dans cette direction avec allow T: ref struct et Span<Span<T>>. Les règles seraient mieux rédigées si elles étaient associées à un code de consommation.

Le délai de décision permet au champref de s'étendre ref struct jusqu'à .NET 8 où nous avons des scénarios qui aideront à lecteur les règles autour de ces scénarios. Cela n'a pas été mis en œuvre à partir de .NET 9.

Qu'est-ce qui fera de C# 11.0 ?

Les fonctionnalités décrites dans ce document n'ont pas besoin d'être implémentées en une seule fois. Au lieu de cela, ils peuvent être mis en œuvre par phases sur plusieurs versions de la langue dans les catégories suivantes :

  1. champs ref et scoped
  2. [UnscopedRef]
  3. champs ref à ref struct
  4. Types restreints en cours de retrait
  5. tampons de taille fixe

Ce qui sera implémenté dans telle ou telle version n'est qu'un exercice de cadrage.

Décision : uniquement (1) et (2) ont implémenté C# 11.0. Le reste sera pris en compte dans les futures versions de C#.

Éléments futurs à prendre en considération

Annotations de durée de vie avancées

Les annotations de durée de vie de cette proposition sont limitées en ce sens qu’elles permettent aux développeurs de modifier le comportement par défaut d'échappement ou de non-échappement des valeurs. Cela ajoute une grande flexibilité à notre modèle, mais ne modifie pas radicalement l'ensemble des relations qui peuvent être exprimées. Au fond, le modèle C# reste effectivement binaire : une valeur peut-elle être renvoyée ou non ?

Cela permet de comprendre les relations à durée de vie limitée. Par exemple, une valeur qui ne peut pas être renvoyée par une méthode a une durée de vie plus courte qu'une valeur qui peut être renvoyée par une méthode. Il n'y a cependant aucun moyen de décrire la relation de durée de vie entre les valeurs qui peuvent être renvoyées par une méthode. Plus précisément, il n'y a aucun moyen de dire qu'une valeur a une durée de vie plus grande que l'autre une fois qu'il est établi que les deux peuvent être renvoyées par une méthode. L’étape suivante de notre évolution dans notre existence serait de permettre de décrire ces relations.

D'autres méthodes telles que Rust permettent d'exprimer ce type de relation et donc d'implémenter des opérations plus complexes de type scoped. Notre langage pourrait bénéficier de la même manière de l'inclusion d'une telle fonctionnalité. À l’heure actuelle, il n’y a pas de pression motivante à faire cela, mais s'il y en a à l'avenir, notre modèle scoped pourrait être étendu pour l’inclure d'une manière assez simple.

Chaque scoped pourrait se voir attribuer une durée de vie nommée en ajoutant un argument de style générique à la syntaxe. Par exemple, scoped<'a> est une valeur qui a une durée de vie de 'a. Des contraintes telles que where pourraient alors être utilisées pour décrire les relations entre ces durées de vie.

void M(scoped<'a> ref MyStruct s, scoped<'b> Span<int> span)
  where 'b >= 'a
{
    s.Span = span;
}

Cette méthode définit deux durées de vie 'a et 'b et leur relation, en particulier le fait que 'b est supérieur à 'a. Cela permet au point d'appel d'avoir des règles plus granulaires pour la manière dont les valeurs peuvent être transmises en toute sécurité dans des méthodes, par opposition aux règles plus grossières présentes aujourd'hui.

Problèmes

Les problèmes suivants sont tous liés à cette proposition :

Propositions

Les propositions suivantes sont liées à cette proposition :

Échantillons existants

Utf8JsonReader

Ce snippet particulier nécessite unsafe parce qu'il rencontre des problèmes avec le passage d'un Span<T> qui peut être alloué par la pile à une méthode d'instance sur un ref struct. Même si ce paramètre n’est pas capturé, la langue doit supposer qu’il l’est, ce qui provoque inutilement des frictions ici.

Utf8JsonWriter

Ce snippet veut muter un paramètre en échappant des éléments de données. Les données échappées peuvent être allouées à la pile par souci d'efficacité. Même si le paramètre n'est pas échappé, le compilateur lui attribue un safe-context extérieur à la méthode englobante, car il s'agit d'un paramètre. Cela signifie que pour utiliser l'allocation à la pile, l'implémentation doit utiliser unsafe afin de réaffecter le paramètre après avoir échappé les données.

Échantillons amusants

ReadOnlySpan<T>

public readonly ref struct ReadOnlySpan<T>
{
    readonly ref readonly T _value;
    readonly int _length;

    public ReadOnlySpan(in T value)
    {
        _value = ref value;
        _length = 1;
    }
}

Liste frugale

struct FrugalList<T>
{
    private T _item0;
    private T _item1;
    private T _item2;

    public int Count = 3;

    public FrugalList(){}

    public ref T this[int index]
    {
        [UnscopedRef] get
        {
            switch (index)
            {
                case 0: return ref _item0;
                case 1: return ref _item1;
                case 2: return ref _item2;
                default: throw null;
            }
        }
    }
}

Exemples et notes

Vous trouverez ci-dessous une série d'exemples démontrant comment et pourquoi les règles fonctionnent comme elles le font. Plusieurs exemples montrent des comportements dangereux et la manière dont les règles les empêchent de se produire. Il est important de garder ces exemples à l'esprit lorsque vous apportez des modifications à la proposition.

Réaffectation des références et sites d'appel

Démonstration du fonctionnement conjoint de la réaffectation des références et de l'invocation des méthodes.

ref struct RS
{
    ref int _refField;

    public ref int Prop => ref _refField;

    public RS(int[] array)
    {
        _refField = ref array[0];
    }

    public RS(ref int i)
    {
        _refField = ref i;
    }

    public RS CreateRS() => ...;

    public ref int M1(RS rs)
    {
        // The call site arguments for Prop contribute here:
        //   - `rs` contributes no ref-safe-context as the corresponding parameter, 
        //      which is `this`, is `scoped ref`
        //   - `rs` contribute safe-context of *caller-context*
        // 
        // This is an lvalue invocation and the arguments contribute only safe-context 
        // values of *caller-context*. That means `local1` has ref-safe-context of 
        // *caller-context*
        ref int local1 = ref rs.Prop;

        // Okay: this is legal because `local` has ref-safe-context of *caller-context*
        return ref local1;

        // The arguments contribute here:
        //   - `this` contributes no ref-safe-context as the corresponding parameter
        //     is `scoped ref`
        //   - `this` contributes safe-context of *caller-context*
        //
        // This is an rvalue invocation and following those rules the safe-context of 
        // `local2` will be *caller-context*
        RS local2 = CreateRS();

        // Okay: this follows the same analysis as `ref rs.Prop` above
        return ref local2.Prop;

        // The arguments contribute here:
        //   - `local3` contributes ref-safe-context of *function-member*
        //   - `local3` contributes safe-context of *caller-context*
        // 
        // This is an rvalue invocation which returns a `ref struct` and following those 
        // rules the safe-context of `local4` will be *function-member*
        int local3 = 42;
        var local4 = new RS(ref local3);

        // Error: 
        // The arguments contribute here:
        //   - `local4` contributes no ref-safe-context as the corresponding parameter
        //     is `scoped ref`
        //   - `local4` contributes safe-context of *function-member*
        // 
        // This is an lvalue invocation and following those rules the ref-safe-context 
        // of the return is *function-member*
        return ref local4.Prop;
    }
}

Réaffectation des références et échappements non sécurisés

La raison de la ligne suivante dans les règles de réaffectation ref peut ne sembler pas évidente à première vue :

e1 doit avoir le même contexte sécurisé que e2

En effet, la durée de vie des valeurs désignées par les emplacements ref est invariable. L'indirection nous empêche de permettre n'importe quel type de variance ici, même pour des durées de vie plus étroites. Si le rétrécissement est autorisé, il ouvre la voie au code dangereux suivant :

void Example(ref Span<int> p)
{
    Span<int> local = stackalloc int[42];
    ref Span<int> refLocal = ref local;

    // Error:
    // The safe-context of refLocal is narrower than p. For a non-ref reassignment 
    // this would be allowed as its safe to assign wider lifetimes to narrower ones.
    // In the case of ref reassignment though this rule prevents it as the 
    // safe-context values are different.
    refLocal = ref p;

    // If it were allowed this would be legal as the safe-context of refLocal
    // is *caller-context* and that is satisfied by stackalloc. At the same time
    // it would be assigning through p and escaping the stackalloc to the calling
    // method
    // 
    // This is equivalent of saying p = stackalloc int[13]!!! 
    refLocal = stackalloc int[13];
}

Pour un passage de ref à non-ref struct, cette règle est évidemment satisfaite, car les valeurs ont toutes le même contexte sécurisé . Cette règle n'entre réellement en jeu que lorsque la valeur est un ref struct.

Ce comportement de ref sera également important dans un futur où nous autoriserons les champs ref à ref struct.

Locaux scopés

L’utilisation de scoped sur les variables locales sera particulièrement utile pour les motifs de code qui attribuent conditionnellement des valeurs avec un contexte sécurisé différent aux variables locales. Cela signifie que le code n'a plus besoin de s'appuyer sur des astuces d'initialisation comme = stackalloc byte[0] pour définir un safe-context local, mais peut désormais simplement utiliser scoped.

// Old way 
// Span<byte> span = stackalloc byte[0];
// New way 
scoped Span<byte> span;
int len = ...;
if (len < MaxStackLen)
{
    span = stackalloc byte[len];
}
else
{
    span = new byte[len];
}

Ce schéma se retrouve fréquemment dans le code de bas niveau. Lorsque le ref struct impliqué est Span<T>, l'astuce ci-dessus peut être utilisée. Elle n'est cependant pas applicable aux autres types ref struct et peut conduire à ce que le code de bas niveau doive recourir à unsafe pour contourner l'impossibilité de spécifier correctement la durée de vie.

valeurs de paramètres étendues

Une source de friction répétée dans le code de bas niveau est que l'échappement prédéfini pour les paramètres est permissif. Elles sont safe-context pour le caller-context. Il s'agit d'une valeur par défaut judicieuse, car elle est conforme aux modèles de codage de .NET dans son ensemble. Dans le code de bas niveau, cependant, il y a une plus grande utilisation de ref struct et ce défaut peut causer des frictions avec d'autres parties des règles de ref safe-context.

Le point de friction principal se produit parce que les arguments de méthode doivent correspondre à la règle. Cette règle entre généralement en jeu avec les méthodes d’instance sur ref struct où au moins un paramètre est également un ref struct. Il s'agit d'un schéma courant dans le code de bas niveau où les types ref struct utilisent couramment des paramètres Span<T> dans leurs méthodes. Par exemple, il se produira sur n'importe quel style d'écriture ref struct qui utilise Span<T> pour faire circuler des tampons.

Cette règle a pour but d'éviter des scénarios tels que celui-ci :

ref struct RS
{
    Span<int> _field;
    void Set(Span<int> p)
    {
        _field = p;
    }

    static void DangerousCode(ref RS p)
    {
        Span<int> span = stackalloc int[] { 42 };

        // Error: if allowed this would let the method return a reference to 
        // the stack
        p.Set(span);
    }
}

En fait, cette règle existe parce que le langage doit supposer que toutes les entrées d'une méthode s'échappent jusqu'à leur contexte sécurisé maximal autorisé . Lorsqu'il y a ref ou out paramètres, y compris les récepteurs, il est possible que les entrées s'échappent en tant que champs de ces valeurs ref (comme cela se produit dans RS.Set ci-dessus).

Dans la pratique, cependant, de nombreuses méthodes de ce type passent ref struct en tant que paramètres sans jamais avoir l'intention de les capturer dans la sortie. Il s'agit simplement d'une valeur utilisée dans la méthode actuelle. Exemple :

ref struct JsonReader
{
    Span<char> _buffer;
    int _position;

    internal bool TextEquals(ReadOnlySpan<char> text)
    {
        var current = _buffer.Slice(_position, text.Length);
        return current == text;
    }
}

class C
{
    static void M(ref JsonReader reader)
    {
        Span<char> span = stackalloc char[4];
        span[0] = 'd';
        span[1] = 'o';
        span[2] = 'g';

        // Error: The safe-context of `span` is function-member 
        // while `reader` is outside function-member hence this fails
        // by the above rule.
        if (reader.TextEquals(span))
        {
            ...
        }
    }
}

Afin de contourner ce problème, le code de bas niveau aura recours à des astuces unsafe pour mentir au compilateur sur la durée de vie de leur ref struct. Cela réduit considérablement la proposition de valeur de ref struct, qui est censée être un moyen d'éviter unsafe tout en continuant à écrire un code très performant.

C’est là que scoped est un outil efficace pour les paramètres ref struct, car il les supprime de l'évaluation comme étant retournés par la méthode, conformément à la règle mise à jour stipulant que les arguments de méthode doivent correspondre à la règle. Un paramètre ref struct qui est consommé, mais jamais retourné, peut être étiqueté comme scoped pour rendre les sites d'appel plus flexibles.

ref struct JsonReader
{
    Span<char> _buffer;
    int _position;

    internal bool TextEquals(scoped ReadOnlySpan<char> text)
    {
        var current = _buffer.Slice(_position, text.Length);
        return current == text;
    }
}

class C
{
    static void M(ref JsonReader reader)
    {
        Span<char> span = stackalloc char[4];
        span[0] = 'd';
        span[1] = 'o';
        span[2] = 'g';

        // Okay: the compiler never considers `span` as capturable here hence it doesn't
        // contribute to the method arguments must match rule
        if (reader.TextEquals(span))
        {
            ...
        }
    }
}

Empêcher l'affectation de ref délicate d'une mutation en lecture seule

Lorsqu'un ref est attribué à un champ readonly dans un constructeur ou un membre init, le type est ref et non ref readonly. Il s'agit d'un comportement de longue date qui permet d'écrire des codes comme ceux qui suivent :

struct S
{
    readonly int i; 

    public S(string s)
    {
        M(ref i);
    }

    static void M(ref int i) { }
}

Cela pourrait poser un problème potentiel si une telle ref pouvait être stockée dans un champ ref dans le même type. Cela permettrait une modification directe d’un readonly struct par un membre d’instance :

readonly ref struct S
{ 
    readonly int i; 
    readonly ref int r; 
    public S()
    {
        i = 0;
        // Error: `i` has a narrower scope than `r`
        r = ref i;
    }

    public void Oops()
    {
        r++;
    }
}

La proposition empêche cela toutefois parce qu’elle enfreint les règles de contexte sécurisé de référence. Tenez compte des éléments suivants :

  • Le ref-safe-context de this est function-member et le safe-context est caller-context. Ces deux éléments sont standard pour this dans un membre struct.
  • Le ref-safe-context de i est function-member. Il ne respecte pas les règles relatives aux durées de vie des champs. En particulier la règle 4.

À ce stade, la ligne r = ref i est illégale en vertu des règles de réaffectation de ref.

Ces règles n'ont pas été conçues pour empêcher ce comportement, mais le font en tant qu'effet secondaire. Il est important de garder cela à l'esprit pour toute mise à jour future des règles afin d'évaluer l'impact sur des scénarios comme celui-ci.

Affectation cyclique stupide

L'un des aspects sur lesquels cette conception s'est heurtée est la liberté avec laquelle une méthode peut renvoyer un ref. Permettre à tous les ref d'être renvoyés aussi librement que des valeurs normales est probablement ce que la plupart des développeurs attendent intuitivement. Toutefois, il permet des scénarios pathologiques que le compilateur doit prendre en compte lors du calcul de la sécurité des références. Tenez compte des éléments suivants :

ref struct S
{
    int field;
    ref int refField;

    static void SelfAssign(ref S s)
    {
        // Error: s.field can only escape the current method through a return statement
        s.refField = ref s.field;
    }
}

Il ne s'agit pas d'un modèle de code que nous attendons des développeurs. Pourtant, lorsqu'un ref peut être renvoyé avec la même durée de vie qu'une valeur, il est légal en vertu des règles. Le compilateur doit prendre en compte tous les cas légaux lors de l'évaluation d'un appel de méthode, ce qui rend ces API inutilisables.

void M(ref S s)
{
    ...
}

void Usage()
{
    // safe-context to caller-context
    S local = default; 

    // Error: compiler is forced to assume the worst and concludes a self assignment
    // is possible here and must issue an error.
    M(ref local);
}

Pour rendre ces API utilisables, le compilateur veille à ce que la durée de vie de ref pour un paramètre ref soit inférieure à la durée de vie de toute référence dans la valeur du paramètre associé. C'est la raison pour laquelle ref-safe-context pour ref to ref struct est return-only et out est caller-context. Cela empêche l'affectation cyclique en raison de la différence de durée de vie.

Il est à noter que le ref-safe-context de toute valeur de ref à ref struct est [UnscopedRef]promu en caller-context, ce qui permet une affectation cyclique et oblige à une utilisation virale de [UnscopedRef] en amont de la chaîne d'appel :

S F()
{
    S local = new();
    // Error: self assignment possible inside `S.M`.
    S.M(ref local);
    return local;
}

ref struct S
{
    int field;
    ref int refField;

    public static void M([UnscopedRef] ref S s)
    {
        // Allowed: s has both safe-context and ref-safe-context of caller-context
        s.refField = ref s.field;
    }
}

De même, [UnscopedRef] out permet une affectation cyclique parce que le paramètre a un contexte safe-context et un contexte ref-safe-context de return-only.

Promouvoir [UnscopedRef] ref dans le caller-context est utile lorsque le type n'est pas un ref struct (notez que nous voulons garder les règles simples et qu'elles ne font donc pas de distinction entre les refs vers les structures ref et les structures non-ref) :

int x = 1;
F(ref x).RefField = 2;
Console.WriteLine(x); // prints 2

static S F([UnscopedRef] ref int x)
{
    S local = new();
    local.M(ref x);
    return local;
}

ref struct S
{
    public ref int RefField;

    public void M([UnscopedRef] ref int data)
    {
        RefField = ref data;
    }
}

En termes d'annotations avancées, la conception de [UnscopedRef] crée ce qui suit :

ref struct S { }

// C# code
S Create1(ref S p)
S Create2([UnscopedRef] ref S p)

// Annotation equivalent
scoped<'b> S Create1(scoped<'a> ref scoped<'b> S)
scoped<'a> S Create2(scoped<'a> ref scoped<'b> S)
  where 'b >= 'a

le readonly ne peut pas être profond à travers les champs ref

Considérons l'exemple de code ci-dessous :

ref struct S
{
    ref int Field;

    readonly void Method()
    {
        // Legal or illegal?
        Field = 42;
    }
}

Lors de la conception des règles pour les champs ref sur les instances readonly, dans un contexte isolé, les règles peuvent être conçues de manière à ce que ce qui précède soit légal ou illégal. Essentiellement, readonly peut valablement être profond à travers un champ ref ou ne s'appliquer qu'au ref. Le fait de ne s'appliquer qu'à ref empêche la réaffectation des refs, mais permet une affectation normale qui modifie la valeur à laquelle il est fait référence.

Toutefois, cette conception ne vit pas en vase clos, elle établit des règles pour les types ayant déjà effectivement des champs ref. Le champ le plus important, Span<T>, dépend déjà fortement du fait que readonly n'est pas profond dans ce champ. Son scénario principal est la possibilité d'assigner au champ ref par l'intermédiaire d'une instance readonly.

readonly ref struct SpanOfOne
{
    readonly ref int Field;

    public ref int this[int index]
    {
        get
        {
            if (index != 1)
                throw new Exception();
            return ref Field;
        }
    }
}

Cela signifie que nous devons choisir l'interprétation superficielle de readonly.

Modélisation des constructeurs

Une question de conception subtile se pose : comment les corps des constructeurs sont-ils modélisés pour la sécurité de ref ? Essentiellement, comment le constructeur suivant est-il analysé ?

ref struct S
{
    ref int field;

    public S(ref int f)
    {
        field = ref f;
    }
}

Il existe grosso modo deux approches :

  1. Modéliser comme une méthode staticthis est un local où son safe-context est le caller-context
  2. Modéliser comme une méthode staticthis est un paramètre out.

En outre, un constructeur doit satisfaire aux invariants suivants :

  1. S'assurer que les paramètres ref peuvent être capturés comme des champs ref.
  2. S'assurer que ref vers les champs de this ne peut pas être échappé par les paramètres ref. Cela constituerait une violation de l'affectation délicate de ref.

L'objectif est de choisir la forme qui satisfait nos invariants sans introduire de règles spéciales pour les constructeurs. Étant donné que le meilleur modèle pour les constructeurs consiste à considérer this comme un paramètre out. La nature return-only de out nous permet de satisfaire tous les invariants ci-dessus sans aucun cas particulier :

public static void ctor(out S @this, ref int f)
{
    // The ref-safe-context of `ref f` is *return-only* which is also the 
    // safe-context of `this.field` hence this assignment is allowed
    @this.field = ref f;
}

Les arguments des méthodes doivent correspondre

La règle selon laquelle les arguments de méthode doivent correspondre est une source courante de confusion pour les développeurs. Il s'agit d'une règle qui comporte un certain nombre de cas particuliers difficiles à comprendre à moins d'être familiarisé avec le raisonnement qui sous-tend la règle. Pour mieux comprendre les raisons de la règle, nous allons simplifier ref-safe-context et contexte sécurisé pour simplement contexte.

Les méthodes peuvent renvoyer assez librement l'état qui leur a été transmis en tant que paramètres. Essentiellement, tout état atteignable qui n'est pas cadré peut être retourné (y compris le retour par ref). Il peut être retourné directement par une instruction return ou indirectement en l'assignant dans une valeur ref.

Les retours directs ne posent pas beaucoup de problèmes de ref safety. Le compilateur doit simplement examiner toutes les entrées retournables d'une méthode, puis il limite effectivement la valeur de retour au contexte minimum de l'entrée. Cette valeur de retour est ensuite traitée normalement.

Les retours indirects posent un problème important car tous les ref sont à la fois une entrée et une sortie de la méthode. Ces sorties ont déjà un contexte connu. Le compilateur ne peut pas en déduire de nouveaux, il doit les considérer à leur niveau actuel. Cela signifie que le compilateur doit regarder chaque ref assignable dans la méthode appelée, évaluer son contexte, et ensuite vérifier qu'aucune entrée retournable à la méthode n'a un contexte plus petit que ce ref. Si un tel cas existe, l'appel de la méthode doit être illégal car il pourrait violer la sécurité de ref.

Les arguments de la méthode doivent correspondre est le processus par lequel le compilateur affirme cette vérification de sécurité.

Une autre façon d'évaluer ce point, qui est souvent plus facile à considérer pour les développeurs, consiste à faire l'exercice suivant :

  1. Regardez la définition de la méthode et identifiez tous les endroits où l'état peut être indirectement retourné : a. Paramètres mutables ref pointant vers ref struct b. Paramètres ref mutables avec champs ref assignables par ref c. Les paramètres ref assignables ou les champs ref pointant vers ref struct (à considérer de manière récursive)
  2. Examiner le site d'appel a. Identifier les contextes qui correspondent aux endroits identifiés ci-dessus b. Identifier les contextes de toutes les entrées de la méthode qui peuvent être retournées (ne s'alignent pas sur les paramètres scoped).

Si l'une des valeurs de 2.b est inférieure à 2.a, l'appel de la méthode doit être illégal. Prenons quelques exemples pour illustrer les règles :

ref struct R { }

class Program
{
    static void F0(ref R a, scoped ref R b) => throw null;

    static void F1(ref R x, scoped R y)
    {
        F0(ref x, ref y);
    }
}

En examinant l'appel à F0, passons en revue les points (1) et (2). Les paramètres avec potentiel de retour indirect sont a et b, car les deux peuvent être directement assignés. Les arguments qui correspondent à ces paramètres sont les suivants :

  • a qui mappe à x qui a le contexte de caller-context
  • b qui mappe à y qui a un contexte de fonction-member

L'ensemble des données d'entrée retournables à la méthode sont

  • x avec escape-scope de caller-context
  • ref x avec escape-scope de caller-context
  • y avec escape-scope de function-member

La valeur ref y n'est pas retournable puisqu'elle est mappée à un scoped ref et n'est donc pas considérée comme une entrée. Toutefois, étant donné qu’au moins une entrée a une étendue d’échappement plus petite (argumenty) que l’une des sorties (x argument), l’appel à la méthode est illégal.

Une variante différente est la suivante :

ref struct R { }

class Program
{
    static void F0(ref R a, ref int b) => throw null;

    static void F1(ref R x)
    {
        int y = 42;
        F0(ref x, ref y);
    }
}

Encore une fois, les paramètres ayant le potentiel pour un retour indirect sont a et b, car les deux peuvent être directement assignés. Mais b peut être exclu, car il ne pointe pas vers un ref struct et ne peut donc pas être utilisé pour stocker un état ref. Nous avons donc :

  • a qui mappe à x qui a le contexte de caller-context

L'ensemble des entrées retournables de la méthode sont :

  • x avec context de caller-context
  • ref x avec context de caller-context
  • ref y avec context de function-member

Étant donné qu'il existe au moins une entrée dont l'étendue d'échappement (argument ref y) est plus petite que l'une des sorties (argument x), l'appel de la méthode est illégal.

C'est la logique que la règle selon laquelle les arguments de la méthode doivent correspondre cherche à englober. Il va plus loin en considérant scoped comme un moyen d'écarter les entrées de la prise en compte et readonly comme un moyen de retirer ref en tant que sortie (ne peut pas être affecté dans un readonly ref, il ne peut donc pas être une source de sortie). Ces cas particuliers ajoutent de la complexité aux règles, mais c'est dans l'intérêt du développeur. Le compilateur cherche à supprimer toutes les entrées et sorties dont il sait qu'elles ne peuvent pas contribuer au résultat, afin de donner aux développeurs un maximum de flexibilité lors de l'appel d'un membre. À l'instar de la résolution des surcharges, il vaut la peine de rendre nos règles plus complexes si cela permet d'offrir une plus grande flexibilité aux consommateurs.

Exemples de safe-context déduit d'expressions de déclaration

Lié à Inférer le safe-context des expressions de déclaration.

ref struct RS
{
    public RS(ref int x) { } // assumed to be able to capture 'x'

    static void M0(RS input, out RS output) => output = input;

    static void M1()
    {
        var i = 0;
        var rs1 = new RS(ref i); // safe-context of 'rs1' is function-member
        M0(rs1, out var rs2); // safe-context of 'rs2' is function-member
    }

    static void M2(RS rs1)
    {
        M0(rs1, out var rs2); // safe-context of 'rs2' is function-member
    }

    static void M3(RS rs1)
    {
        M0(rs1, out scoped var rs2); // 'scoped' modifier forces safe-context of 'rs2' to the current local context (function-member or narrower).
    }
}

Notez que le contexte local qui résulte du modificateur scoped est le plus étroit qui puisse être utilisé pour la variable - être plus étroit signifierait que l'expression se réfère à des variables qui ne sont déclarées que dans un contexte plus étroit que l'expression.