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 :
- champs
ref
etscoped
[UnscopedRef]
Ces fonctionnalités restent des propositions ouvertes pour une future version de C# :
- champs
ref
àref struct
- 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 champsref
. - 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 commeByReference<T>
- Permettre aux types
struct
de renvoyer desref
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 dansstruct
.
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éthodesinit
. 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ètrein
peut être réaffecté à un champref
.-
readonly ref readonly
: une combinaison deref readonly
etreadonly 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.F
ref-safe-context comme suit :
- Si
F
est un champref
, son ref-safe-context est le ref-safe-context dee
.- Sinon, si
e
est un type de référence, son ref-safe-context est le caller-context.- 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 :
e2
doit avoir ref-safe-context au moins aussi grand que le ref-safe-context dee1
e1
doit avoir le même contexte sécurisé quee2
Note
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
- safe-context de tout argument dont le paramètre correspondant n'est pas
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 unestruct
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 ref
safe-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
ouin
aura un ref-safe-context de return-only. Ceci est fait en partie pourref 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 unref struct
aura un safe-context de return-only. Cela permet à return etout
d'être aussi expressifs l'un que l'autre. Il n'y a pas de problème d'affectation cyclique, carout
est implicitementscoped
, de sorte que le ref-safe-context est toujours plus petit que le safe-context. - Un paramètre
this
pour un constructeurstruct
aura un safe-context de return-only. Ceci est dû au fait qu'ils sont modélisés comme des paramètresout
.
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
:
- Si
p
estscoped ref
,expr
ne contribue pas au ref-safe-context lors de l'examen des arguments.- Si
p
estscoped
,expr
ne contribue pas au safe-context lors de l'examen des arguments.- Si
p
estout
alorsexpr
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 :
- le caller-context.
- Lorsque le retour est un
ref struct
, le safe-context apporté par toutes les expressions d'argument- Lorsque le retour est un
ref struct
, le ref-safe-context est constitué de tous les argumentsref
.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, ...)
, lorsqueM()
ne renvoie pas de ref-to-ref-struct, est le ref-safe-context le plus étroit des contextes suivants :
- le caller-context.
- Le safe-context apporté par toutes les expressions d'arguments.
- 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 :
- Le safe-context de l'appel au constructeur.
- Le safe-context et le ref-safe-context des arguments des indexeurs d'initialisateurs de membres qui peuvent s'échapper vers le récepteur.
- 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)
- 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.
- Tous les arguments
ref
des typesref 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 inclurein
etout
Pour toute invocation de méthode
e.M(a1, a2, ... aN)
- 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
- Tous les arguments
out
des typesref 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ètreref
ouin
- Ajouter
scoped
à un paramètreref struct
- Supprimer
[UnscopedRef]
d'un paramètreout
- Supprimer
[UnscopedRef]
d'un paramètreref
d'un typeref 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
ouout
de typeref struct
avec une incompatibilité d’ajout de[UnscopedRef]
(sans supprimerscoped
). (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 unref
ou unref readonly
, ou la méthode a un paramètreref
ouout
de typeref struct
. - La méthode possède au moins un paramètre
ref
,in
ouout
supplémentaire, ou un paramètre de typeref struct
.
- La méthode retourne un
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éthodeTryParse
), 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ètreout
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
ouref
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 champref struct
. - Un champ
ref
ne peut pas être déclaréstatic
,volatile
ouconst
- Un champ
ref
ne peut pas avoir un typeref 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 champref struct
. - Un
readonly ref struct
doit déclarer ses champsref
commereadonly ref
- Pour les valeurs by-ref, le modificateur
scoped
doit apparaître avantin
,out
ouref
. - 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.
- La bibliothèque de base contient l'indicateur de fonctionnalité indiquant la prise en charge des champs
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 signaturestatic TypedReference __makeref<T>(ref T value)
-
__refvalue
sera traitée comme une méthode avec la signaturestatic 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 dethis
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 membreinit
ou un constructeur sur unstruct
- 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.
- Un membre qui n'est pas déclaré sur un
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.
- Les paramètres
ref
/in
/out
sans espace peuvent échapper à une invocation de méthode en tant que champref
d'unref struct
en C#11, mais pas en C#7.2. - Les paramètres
out
sont implicitement scopés en C#11, et non scopés en C#7.2. ref
/ Les paramètresin
des typesref 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 soitversion
, 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 champsref
, 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 :
- le caller-context.
- Le safe-context apporté par toutes les expressions d'arguments.
- Lorsque le retour est un
ref struct
alors ref-safe-context contribué par tous les argumentsref
.
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 :
- Par retour de valeur
- Par retour
ref
- Par champ
ref
dansref struct
qui est retourné ou passé comme paramètreref
/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ètreref
ouout
- possède un paramètre
in
ouref
supplémentaire, à l'exclusion du récepteur
- Lorsque le
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 champref
n'est jamais considéré commeunmanaged
- 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 champref
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 desref struct
ont implicitement une durée de vie deT<$heap>
. Ceci est implicite, il n'est pas nécessaire d'écrireint<$heap>
dans chaque échantillon. - Pour un champ
ref
défini commeref<$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
.
- Toutes les durées de vie de
- Pour un
ref
défini commeref<$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 typeref<$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
- Pour un
- 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
pourT<...>
ref<$a> (T<$b>)expr
la durée de vie de la valeur est$b
pourT<...>
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<...>>
où $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 ouref
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 unref struct
- Lorsque le
ref struct
est un type de retour, un paramètreref
ouout
- A un paramètre
in
ouref
supplémentaire (à l'exclusion du récepteur)
- Lorsque le
Pour comprendre l'impact, il est utile de diviser les API en catégories :
- Les consommateurs doivent tenir compte du fait que
ref
est capturé en tant que champref
. Les constructeursSpan(ref T value)
en sont le meilleur exemple. - Ne veulent pas que les consommateurs tiennent compte du fait que
ref
est capturé en tant que champref
. Celles-ci se répartissent en deux catégories XXX- API non sûres. Il s'agit des APIS des types
Unsafe
etMemoryMarshal
, dontMemoryMarshal.CreateSpan
est le plus important. Ces API capturent leref
de manière non sécurisée, mais elles sont également connues comme étant des API non sécurisées. - 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 estAsnDecoder.ReadEnumeratedBytes
- API non sûres. Il s'agit des APIS des types
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 :
- 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.
- 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 :
-
Unsafe.AsRef<T>(in T value)
pourrait étendre son objectif existant en se transformant enscoped in T value
. Cela lui permettrait à la fois de supprimerin
etscoped
des paramètres. Il s'agit alors de la méthode universelle « remove ref safety » - Introduire une nouvelle méthode dont l'objectif est de supprimer
scoped
:ref T Unsafe.AsUnscoped<T>(scoped in T value)
. Cela supprime égalementin
, 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
estscoped ref
-
out
estscoped 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
commeref
:struct
,readonly ref struct
oureadonly member
this
en tant quescoped ref
:ref struct
oureadonly ref struct
avec le champref
à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 :
- champs
ref
etscoped
[UnscopedRef]
- champs
ref
àref struct
- Types restreints en cours de retrait
- 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.
Informations connexes
Problèmes
Les problèmes suivants sont tous liés à cette proposition :
- https://github.com/dotnet/csharplang/issues/1130
- https://github.com/dotnet/csharplang/issues/1147
- https://github.com/dotnet/csharplang/issues/992
- https://github.com/dotnet/csharplang/issues/1314
- https://github.com/dotnet/csharplang/issues/2208
- https://github.com/dotnet/runtime/issues/32060
- https://github.com/dotnet/runtime/issues/61135
- https://github.com/dotnet/csharplang/discussions/78
Propositions
Les propositions suivantes sont liées à cette proposition :
Échantillons existants
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.
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é quee2
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 pourthis
dans un membrestruct
. - 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 :
- Modéliser comme une méthode
static
oùthis
est un local où son safe-context est le caller-context - Modéliser comme une méthode
static
oùthis
est un paramètreout
.
En outre, un constructeur doit satisfaire aux invariants suivants :
- S'assurer que les paramètres
ref
peuvent être capturés comme des champsref
. - S'assurer que
ref
vers les champs dethis
ne peut pas être échappé par les paramètresref
. 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 :
- 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 versref struct
b. Paramètresref
mutables avec champsref
assignables par ref c. Les paramètresref
assignables ou les champsref
pointant versref struct
(à considérer de manière récursive) - 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-contextb
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-contextref 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.
C# feature specifications