Codingly

Si les types étaient des animaux, TypedReference serait un ornithorynque

Posted in Articles by Romain Verdier on janvier 15, 2009

Devinette : C’est un type valeur, mais on ne peut pas le caster en object. Il est impossible d’en déclarer des tableaux. On ne peut l’utiliser que pour typer les paramètres de méthodes et les variables locales. Il existe 4 mots clés non documentés en C# qui lui sont directement reliés, et autant d’opcodes en CIL. Il permet notamment le support des varargs, et exactement 8 personnes dans le monde se sont souciées plus de 5 min de son existence.

Je veux parler, bien évidemment, de TypedReference. Je vous propose de découvrir ce type à partir d’un exemple rigolo.

Méthodes varargs et interopérabilité

En C#, lorsqu’on veut définir une méthode à nombre de paramètres variables, on utilise la syntaxe suivante :

public static double Average(params int[] values)
{
    double sum = 0;
    foreach (var i in values)
        sum += i;
    return sum / values.Length;
}

Notez le mot clé params qui permet l’appel de la méthode en passant un tableau en paramètre, ou en listant plus librement les arguments :

var parameters = new int[]{45, 68, 97, 45};
ManagedClass.Average (parameters)
// ou bien
ManagedClass.Average (45, 68, 97, 45)

En C++ / CLI, la syntaxe est un peu différente mais le principe est le même :

double CodinglyInterop::CppCliLibrary::average(... array<int,1> ^values)
{
	double sum = 0;
	for each (int i  in values)
		sum += i;
	return sum/values->Length;
}

C’est pareil, en plus moche. On utilise « ... » au lieu de « params« , et l’appelant peut passer un tableau ou une liste d’arguments :

var parameters = new int[]{45, 68, 97, 45};
CppCliLibrary.average(parameters)
// ou bien
CppCliLibrary.average(45, 68, 97, 45)

Notez que l’interopérabilité entre C# et C++/CLI est native : les deux langages sont managés.

Et en C++ pas CLI, qu’est-ce que donnent les fonctions à nombre variable d’arguments ?

Bah c’est encore plus moche, et plus contraignant. Rappelez-vous :

extern "C" __declspec(dllexport) double average(int n, ...)
{
	double sum = 0;
	va_list args;
	va_start(args, n);
	for(int i=0 ;i < n; i++)
		sum += va_arg(args, int);
	va_end(args);
	return sum/n;
}

La principale différence est finalement assez subtile on n’utilise pas explicitement un tableau, mais on fournit un pointeur vers le début de la liste d’arguments. C’est à la méthode de se débrouiller (avec des macros) pour itérer, caster et s’arrêter lorsqu’il le faut. Généralement, un autre paramètre nommé de la méthode contient les infos nécessaires à cette opération. Dans l’exemple précédent, « n » est utilisé pour passer directement le nombre d’arguments de la liste. Dans printf, c’est la format string qui permet à la fonction de savoir combien d’arguments elle doit lire.

La question rigolote, puisque nous sommes dans le contexte d’un exemple rigolo, est la suivante :

Mais comment appeler les fonctions natives de ce genre depuis C# ? En utilisant pinvoke, probablement, mais plus précisément ?

Réponse : En pleurant. On est obligé de déclarer explicitement les imports pour chacune des utilisations que l’on va faire de la fonction dans notre code managé. En gros :

[DllImport ("NativeLibrary.dll")]
public static extern double average (int n, int i1);

[DllImport ("NativeLibrary.dll")]
public static extern double average (int n, int i1, int i2);

[DllImport ("NativeLibrary.dll")]
public static extern double average (int n, int i1, int i2, int i3);

[DllImport ("NativeLibrary.dll")]
public static extern double average (int n, int i1, int i2, int i3, int i4);

// Etc.

What a PITA, comme qui dirait… Mais c’est ici que l’ornithorynque nous sauve la vie. Car on peut écrire :

[DllImport ("NativeLibrary.dll", CallingConvention = CallingConvention.Cdecl)]
public static extern double average (int n, __arglist );

Et appeler la fonction ainsi :

NativeWrapper.average (4, __arglist (45, 68, 97, 45))

Hourra, donc. Mais quel est ce mot clé bizarre, __arglist, qui n’est même pas reconnu par Resharper ? Et bien il s’agit d’un des mots clés non documentés du langage, qui permettent principalement de jouer avec les TypedReference. Pour mieux comprendre ce qui se passe, essayons de réécrire en C# la méthode Average, sans utiliser le mot clé params, et en se reposant donc uniquement sur les « références typées ». On s’attaquera ensuite à la définition.

public class ManagedEvil
{
    public static double Average(__arglist)
    {
        double sum = 0;
        int count = 0;
        var iterator = new ArgIterator(__arglist);
        do
        {
            TypedReference typedReference = iterator.GetNextArg();
            sum += __refvalue( typedReference,int);
            count++;
        } while (iterator.GetRemainingCount() > 0);
        return sum / count;
    }
}

On note :

  • L’apparition du type ArgIterator, dont on crée une instance à partir de la liste d’arguments.
  • L’élément courant retourné par l’itérateur est une référence typée : TypedReference.
  • Un nouveau mot clé, __refvalue, permet d’extraire la valeur pointée par la référence.

Et tout ça compile, tout ça fonctionne, même si l’intérêt est limité.

TypedReference, __makeref, __refvalue, __reftype

Arrêtons les expériences pour décortiquer un peu plus sérieusement ce TypedReference. La MSDN définit très clairement la TypedReference :

Describes objects that contain both a managed pointer to a location and a runtime representation of the type that may be stored at that location.

Retenons qu’il s’agit d’une structure contenant :

  • Un pointeur managé vers un espace mémoire
  • Le type de ce qui est pointé en mémoire

Le meilleur endroit pour trouver des infos au sujet de TypedReference est finalement la spécification ECMA 335 du CIL. Je vous laisse fouiller, mais sachez que les bases sont notamment posées dans le paragraphe suivant (cf. 8.6.1.3):

La signature d’une référence typée est en fait représentée en tant que type valeur de base, comme les entiers ou nombres à virgule flottante. Dans la bibliothèque de classes du framework, le type est représenté par System.TypedReference, tandis que dans le langage intermédiaire il est désigné par le mot clé typedref. Ce type doit uniquement être utilisé pour les paramètres et les variables locales. Il ne peut ni être boxé ni être utilisé pour typer un champ, un élément de tableau, ou une valeur de retour de fonction.

Pour que je ne puisse jamais justifier l’intérêt de cet article, les gens chez Microsoft ont bien fait attention à exposer ce type le moins possible. Il est pourtant assez utilisé en interne, notamment dans System.Array, System.Threading.Interlocked, les services d’intérop (InteropServices) ainsi que dans certains overloads cachés utilisant les varargs (String.Concat, Console.WriteLine). A vrai dire, il semblerait que la raison officielle de son existence soit justement le support par le CLR des listes d’arguments, comme nous l’avons vu plus tôt. Il existe cependant un autre endroit (parmi d’autres) où son utilisation peut s’avérer intéressante : la réflexion via les FieldInfo. Mais avant d’y venir, regardons plutôt comment utiliser, très mécaniquement, TypedReference. On peut soit utiliser les mots clés interdits, soit – lorsque c’est possible – utiliser les quelques méthodes statiques de la classe TypedReference elle-même.

Création d’une référence typée : __makeref

Les références typées peuvent être obtenues pour des variables de n’importe quel type (référence ou valeur) à l’aide du mot clé __makeref.

int i = 42;
TypedReference typedReference = __makeref (i);

Ce mot clé dont l’usage n’est pas documenté correspond à l’instruction mkrefany en CIL. Le code intermédiaire correspondant à l’exemple précédant est le suivant :

int i = 42;

L_0000: ldc.i4.s 0x2a	/* push sur la pile d'un int32 = 42						*/
L_0002: stloc.0			/* sauvegarde dans la variable locale i					*/

TypedReference typedReference = __makeref(i);

L_0003: ldloca.s i		/* push sur la pile de l'adresse de i					*/
L_0005: mkrefany int32	/* push sur la pile de la référence typée				*/
L_000a: stloc.1			/* sauvegarde dans la variable locale typedReference	*/

La méthode statique TypedReference.MakeTypedReference permet aussi de créer des références typées, mais pas directement à partir d’une variable. Nous verrons un exemple d’utilisation dans la dernière partie de l’article.

Récupérer et/ou modifier la valeur : __refvalue

Une fois qu’on a une référence typée, il est possible de lire et/ou d’écrire la valeur référencée en utilisant le mot clé __refvalue. Il est visuellement assimilable à un appel de fonction à deux paramètres, dont le premier serait la TypedReference, et le second le type de la valeur référencée. En l’utilisant à droite d’une affectation, on lit la valeur, et en l’utilisant à gauche d’une affectation, on écrit la valeur. Assez troublant…

int i = 42;
TypedReference typedReference = __makeref(i);
int j = __refvalue (typedReference, int);
__refvalue (typedReference, int) = 24;

Encore une fois, il est possible de mapper son utilisation avec un opcode spécifique, refanyval :

int i = 42;
TypedReference typedReference = __makeref(i);

L_0000: ldc.i4.s 0x2a	/* push sur la pile d'un int32 = 42						*/
L_0002: stloc.0			/* sauvegarde dans la variable locale i					*/
L_0003: ldloca.s i		/* push sur la pile de l'adresse de i					*/
L_0005: mkrefany int32	/* push sur la pile de la référence typée				*/
L_000a: stloc.1			/* sauvegarde dans la variable locale typedReference	*/

int j = __refvalue (typedReference, int);

L_000b: ldloc.1			/* push sur la pile de la typedReference				*/
L_000c: refanyval int32 /* push sur la pile de l'adresse de la référence        */
L_0011: ldind.i4		/* déréférencement et push sur la pile de la valeur		*/
L_0012: stloc.2			/* sauvegarde dans la variable locale j					*/

__refvalue (typedReference, int) = 24;

L_0013: ldloc.1			/* push sur la pile de la typedReference				*/
L_0014: refanyval int32 /* push sur la pile de l'adresse de la référence        */ 
L_0019: ldc.i4.s 0x18	/* push sur la pile d'un int32 = 24						*/
L_001b: stind.i4		/* sauvegarde de la valeur à l'adresse de la référence  */

La méthode TypedReference.ToObject permet aussi de déréférencer la TypedReference et d’obtenir sa valeur, boxée.

Récupérer le type : __reftype

Enfin, il est possible de récupérer l’information de type associée à la TypeReference, en utilisant un dernier mot clé __reftype.

int i = 42;
TypedReference typedReference = __makeref(i);
Type type = __reftype(typedReference);

Là encore, on peut faire correspondre __reftype à l’opcode refanytype :

int i = 42;
TypedReference typedReference = __makeref(i);

L_0000: ldc.i4.s 0x2a	/* push sur la pile d'un int32 = 42						*/
L_0002: stloc.0			/* sauvegarde dans la variable locale i					*/
L_0003: ldloca.s i		/* push sur la pile de l'adresse de i					*/
L_0005: mkrefany int32	/* push sur la pile de la référence typée				*/
L_000a: stloc.1			/* sauvegarde dans la variable locale typedReference	*/

Type type = __reftype(typedReference);

L_000b: ldloc.1			/* push sur la pile de la typedReference				*/
L_000c: refanytype		/* push sur la pile du type token de la référence		*/
L_000e: call class Type::GetTypeFromHandle(RuntimeTypeHandle)
L_0013: stloc.2			/* récupération et sauvegarde du Type à partir du token */

Notons que la méthode statique TypedReference.GetTargetType est l’équivalent autorisé de __reftype, et qu’il existe aussi la méthode TypedReference.TargetTypeToken qui retourne le handle du type sous la forme d’un RuntimeTypeHandle. La version light, en somme.

Rien de très impressionnant ; d’ailleurs vous pouvez retourner sur youtube car la suite n’est pas mieux. Mais je persiste !

Un autre exemple : GetValueDirect et SetValueDirect

Je vous disais tout à l’heure que j’avais trouvé un exemple d’utilisation dans lequel on pouvait faire intervenir les TypedReference. Il s’agit de la lecture écriture des champs par réflexion, et plus particulièrement des champs de type valeur. Et encore plus particulièrement lorsqu’ils sont imbriqués.

Prenons pour exemple ce modèle simpliste :

public struct Person
{
	public Address Address;
}

public struct Address
{
	public City City;
}

public struct City
{
	public int ZipCode;
}

Il est important de bien noter que Person, Address, City et ZipCode sont des value types. Si on veut inspecter une instance de Person par réflexion, jusqu’à lire le ZipCode de son adresse, on va écrire quelque chose comme :

// On a une instance de Person
var p = new Person();
p.Address.City.ZipCode = 75000;

// On récupère les FieldInfo
var addressField = typeof(Person).GetField("Address");
var cityField = typeof(Address).GetField("City");
var zipCodeField = typeof(City).GetField("ZipCode");

// On chaine les appels à FieldInfo.GetValue pour inspecter la Person
// et lire la valeur du ZipCode
var zipCode =  (int)zipCodeField.GetValue(cityField.GetValue(addressField.GetValue(p)));

Ca fonctionne, mais le boxing a tué mon hourra. GetValue prend un object en argument, et retourne un object, alors qu’on travaille ici sur des types valeurs… En lisant la dernière ligne de droite à gauche :

  • p va être boxé pour être passé en paramètre à GetValue
  • addressField.GetValue va boxer la valeur du champ Address pour le retourner sous la forme d’un object
  • cityField.GetValue va boxer la valeur du champ City pour le retourner sous la forme d’un object
  • zipCodeField.GetValue va boxer la valeur du champ ZipCode pour le retourner sous la forme d’un object
  • Le cast en int effectue l’ultime et nécessaire unboxing

Pas terrible…

Heureusement, il existe sur FieldInfo la méthode GetValueDirect, qui prend en paramètre une TypedReference !

Si on peut directement récupérer une référence typée sur le ZipCode, on peut éviter quelques emboitages :

var p = new Person();
p.Address.City.ZipCode = 75000;
FieldInfo zipCodeField = typeof(City).GetField("ZipCode");
var zipCode = (int)zipCodeField.GetValueDirect(__makeref (p.Address.City)); 

Seulement, on change les contraintes, car la création de la référence typée de cette façon implique alors que l’on connaisse Address et City au design time. Qu’à cela ne tienne : il existe la méthode statique TypedReference.MakeTypedReference évoquée plus tôt, qui permet de construire une référence typée à partir d’une target et d’un tableau de FieldInfo correspondant à l’inspection désirée de l’espace mémoire réservé par p :

var p = new Person ();
p.Address.City.ZipCode = 75000;
var addressField = typeof (Person).GetField ("Address");
var cityField = typeof (Address).GetField ("City");
var zipCodeField = typeof (City).GetField ("ZipCode");

TypedReference r = TypedReference.MakeTypedReference (p, new[] {addressField, cityField});
var zipCode = (int) zipCodeField.GetValueDirect (r);

Dans ce cas précis, les performances mesurées sont environ 4 fois meilleures en utilisant GetValueDirect à la place de GetValue. Hourra.

Quant à l’écriture, nous n’avons même pas le luxe du choix, puisque chainer les SetValue ne peut en aucun cas modifier la personne, cette fonction retournant à chaque fois une copie des valeurs… Il faut donc nécessairement utiliser SetValueDirect et une TypedReference.

Conclusion

La conclusion, que je vous dois brève :

Ce qui n’est pas documenté ne doit pas être utilisé. En gros, tout ce qui est lié aux arglists est à oublier. En revanche, la création et la manipulation de TypedReference sans passer par tous les __mots __clés __moches n’est pas réprouvée. Notez simplement que TypedReference n’est pas CLS Compliant.

Tagged with: , , ,

7 Réponses

Subscribe to comments with RSS.

  1. Evilz said, on janvier 15, 2009 at 12:12

    Très intéressant !

    Mais je me demande comment tu es venu à creuser ce sujet ? C’était pour Cecil.Decompiler ?

    @+

  2. Romain Verdier said, on janvier 15, 2009 at 10:03

    En fait, je parlais avec un certain Jean-Baptiste E. de stackoverflow.com, et plus particulièrement de la question des « hidden features » de C#. Il me disait ne pas se souvenir avoir vu quelqu’un mentionner les mots clés bizarres __arglist, etc. Je croyais connaitre tous les mots clés du langage…

    Du coup, je me suis dit que ça ferait un bon post rigolo pour janvier. Mais il semblerait que je sois passé sans m’en rendre compte du « lol saviez-vous que ? » à « mangez mon gros post à faible valeur ajoutée ».

    Cependant, il faudra probablement qu’un jour nous gérions ça aussi dans Cecil.Decompiler, comme ce bon Reflector !

  3. Safia said, on janvier 16, 2009 at 3:03

    Cet article est complètement redoutable. (k)

  4. loic said, on janvier 16, 2009 at 9:26

    Quitte à perdre son temps, autant le perdre à lire de longs articles inutiles, j’adore ce blog, bravo

    J’aimerai, tant qu’à y être ajouter une ligne ou deux pour parfaire l’inutilité avouée de ce post:
    « Bah c’est encore plus moche » [va_xxx en C++ pas CLI]; oui, je te l’accorde c’est laid… mais C++ s’évite un downcast disgracieux dans le cas ou les paramètres sont de types différents, bien que ne connaissant C# qu’au travers de ce blog – et si je ne m’abuse – en C#, le type passé dans ces cas la est souvent params Object[]… downcast obligatoire.
    Je ne sais pas si le mot clé __refvalue s’évite ce downcast (il le pourrait) mais ça pourrait être l’un de ses -nombreux- intérêts. (?)

    Pour parfaire l’inutilité de mon commentaire -que j’avais d’ailleurs promis – et pour rester dans l’esprit de l’article original, je note que souvent, dans les cas où le type des paramètres varie, le test sur le type doit être fait directement ou indirectement; ça ou un downcast…

    je vous laisse

    adieu

  5. mcoolive said, on février 27, 2009 at 9:59

    Salut Romain,

    J’ai une remarque / correction à apporter au sujet des varargs en C++.

    > Bah c’est encore plus moche, et plus contraignant. Rappelez-vous :
    > …
    > La principale différence est finalement assez subtile on n’utilise pas explicitement
    > un tableau, mais on fournit un pointeur vers le début de la liste d’arguments.

    Déjà je trouve la syntaxe du C++/CLI bien plus moche, mais ce n’est qu’une affaire de goût.

    Je voulais insister sur le fait que même si la syntaxe est proche, le varargs du C++ et du .NET sont deux choses très différentes.

    En C++ (ISO) varargs n’est pas un tableau du tout ! Le méthode joue avec le pointeur de pile (SP = stack pointer). En particulier on ne parcourt pas les arguments par une arithmétique simple du genre adrr = base + n * taille. Il faut sommer la taille des objets stockés sur la pile plus du padding sur certaine architecture pour des histoires d’alignement d’adresse et tout ça…
    L’utilisation de macros qui masquent cette réalité est requise justement parce que le calcul dépend de la machine qui exécutera la chose.
    Bref, ce n’est pas un tableau.

    Le varargs en Java ou en C# a repris l’idée de fonctions à nombre variable d’arguments, mais on lieu de placer des choses de tailles diverses sur la pile, on passe TOUJOURS un tableau. C’est-à-dire qu’on stocke sur la pile une référence sur un objet bien fichu et typé. Ces langages (Java, C#, etc.) permettent d’écrire librement les arguments mais on devine bien que le compilateur va réécrire ce bout de code pour instancier un tableau. C’est du sucre syntaxique.

    Cyril Martin (alias mcoolive).

  6. Jb Evain said, on février 27, 2009 at 12:23

    Cyril: C’est faux, vous confondez la fonctionnalité params de C#, qui est du sucre syntaxique et qui passe effectivement un tableau, et varargs, qui est bien le même méchanisme qu’en C/C++, où l’on parcours les éléments en incrémentant le pointeur en fonction de la taille des éléments.

  7. bamboo said, on septembre 21, 2010 at 4:19

    Rien à voir :

    double sum = 0;
    foreach (var i in values)
    sum += i;
    return sum / values.Length;

    on fait sum += i / values.Length ! Sinon sum dépasse double.max et les fusées s’écrasent…
    (c) Barrios

    ;)


Répondre à loic Annuler la réponse.