"Magic" null argument testing
By Michael DELVA on Monday 14 December 2009, 13:00 - C# - Permalink
TweetTrouvée ici, une astuce vraiment *TRES* tricky, qui permet de simplifier (?) le test sur la "nullité" d'un ou plusieurs arguments, le tout à base d'expression trees et de types anonymes...
Magique, le mot n'est pas de trop pour définir cette astuce. En utilisant les Expression Trees, les types anonymes, et les extension methods, tous 3 apportés par le C#3 pour permettre à LINQ d'exister, voici un morceau de code qui vous permet de remplacer ce genre de code :
public void Toto(Foo foo, Bar bar)
{
if (foo == null)
throw new ArgumentNullException("foo");
if (bar == null)
throw new ArgumentNullException("bar");
}
Par :
public void Toto(Foo foo, Bar bar)
{
new { foo, bar }.CheckNotNull();
}
Comment réussir un tel prodige? Grâce à ça :
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Linq.Expressions;
public static class Extensions
{
public static void CheckNotNull(this T container) where T : class
{
if (container == null)
throw new ArgumentNullException("container");
NullChecker.Check(container);
}
private static class NullChecker where T : class
{
private static readonly Func nullSeeker;
static NullChecker()
{
Expression body = Expression.Constant(null, typeof(string));
var param = Expression.Parameter(typeof(T), "obj");
foreach (PropertyInfo property in typeof(T).GetProperties())
{
Type propType = property.PropertyType;
if (propType.IsValueType && Nullable.GetUnderlyingType(propType) == null)
continue;
body = Expression.Condition(
Expression.Equal(
Expression.Property(param, property),
Expression.Constant(null, propType)),
Expression.Constant(property.Name, typeof(string)),
body);
}
nullSeeker = Expression.Lambda>(body, param).Compile();
}
internal static void Check(T item)
{
string nullArg = nullSeeker(item);
if (!string.IsNullOrEmpty(nullArg))
throw new ArgumentNullException(nullArg);
}
}
}
Sur le principe, c'est relativement "simple": On crée un type anonyme. Dans l'extension method, on va statiquement énumérer les arguments du constructeur du type de l'extension method, puis récupérer leur type et leur nom. On va ensuite itérer sur ces arguments pour créer un arbre d'expression dans lequel on va tester si le paramètre est une référence ou un ValueType, puis tester s'il est null. On compile l'arbre sous forme de delegate, et si celui-ci renvoie un string, c'est que l'argument dont le nom est renvoyé est null.
Ce qu'il y a de bien, c'est qu'on élimine le risque de se tromper dans l'écriture du nom du paramètre de ArgumentNullException. (A pondérer toutefois, car un outil comme Resharper par exemple permet de créer un template de code intelligent qui va utiliser automatiquement le nom du paramètre)
En négatif (ça serait trop facile sinon): la syntaxe un peu déroutante, et l'impact sur les performances. J'ai réalisé un petit benchmark où j'appelle 2 fonctions1 million de fois:
class Program
{
private const int iterations = 10000000;
static void Main(string[] args)
{
Stopwatch sw = new Stopwatch();
Console.Write("Normal Test : ");
sw.Start();
for (int i = 0; i < iterations; i++)
{
using (Stream stream = new MemoryStream())
NormalTest("Foo", "Bar", stream);
}
sw.Stop();
Console.Write(" " + sw.ElapsedMilliseconds);
sw.Reset();
Console.WriteLine();
Console.Write("Magic Test : ");
sw.Start();
for (int i = 0; i < iterations; i++)
{
using (Stream stream = new MemoryStream())
MagicTest("Foo", "Bar", stream);
}
sw.Stop();
Console.Write(" " + sw.ElapsedMilliseconds);
Console.Read();
}
private static void NormalTest(string text, string text2, Stream stream)
{
if (text == null)
throw new ArgumentNullException("text");
if (text2 == null)
throw new ArgumentNullException("text2");
if (stream == null)
throw new ArgumentNullException("stream");
UnicodeEncoding utf = new UnicodeEncoding();
byte[] b1 = utf.GetBytes(text);
stream.Write(b1, 0, b1.Length);
byte[] b2 = utf.GetBytes(text2);
stream.Write(b2, 0, b2.Length);
}
private static void MagicTest(string text, string text2, Stream stream)
{
new { text, text2, stream }.CheckNotNull();
UnicodeEncoding utf = new UnicodeEncoding();
byte[] b1 = utf.GetBytes(text);
stream.Write(b1, 0, b1.Length);
byte[] b2 = utf.GetBytes(text2);
stream.Write(b2, 0, b2.Length);
}
}
Résultat des courses: environ 5350ms pour la première fonction, contre environ 6280ms pour la deuxième. Je ne suis pas sûr que le benchmark soit des plus adéquats, mais ça reste à considérer dans le cas d'applications critiques où ça peut avoir un impact significatif.
A noter également que l'on peut parvenir à du code de vérification de ce genre, mais beaucoup plus lisible néanmoins, et fonctionnant à plus grande échelle, grâce aux Code Contracts, qui sera en plus natif en .NET 4.0.
En tout cas, je dois bien avouer que je trouve ça vraiment impressionant ce genre de code. Il serait vraiment grand temps que je me penche un peu plus sur tout ça (notamment les arbres d'expression), car tout cela est vraiment très intéressant!
Comments
Personnellement, je trouve que l'ajout d'autant de code hi-level (et qui donc, ne doit pas être de tout repos pour le CPU - quoique le test de perfs semble montrer peu de pertes) pour faire juste un travail d'assertion (et donc pour moi qui ne doit pas être dans un code de production) me semble un peu exagéré.
Certes, la méthode existe, et il est bon de la souligner. Mais je préfèrerai largement un plugin capable de générer des assertions sur des méthodes en fonctions des utilisations qu'on en fait...
Note : le test est ici plutôt favorable au "magic test", puisque l'AST n'est créé/compilé qu'une seule fois par version fermée du type générique NullChecker'1. Le delegate statique (nullChecker) sera simplement invoqué les fois suivantes, ce qui coûte à peine plus qu'un virtual call ou un call. (http://msdn.microsoft.com/en-us/mag...)
Plus la méthode est appelée souvent, plus le coût de compilation de l'AST (de loin l'opération la plus couteuse ici) est amorti. Comme d'hab, tout dépend du contexte et la plupart du temps par exemple, l'overhead sera négligeable pour une appli web classique.
@xna-connection: certes, comme je le laissais sous-entendre, c'est plus un exercice de style qu'autre chose. Je ne pense effectivement pas que ce soit une bonne idée que d'utiliser ça dans une application réelle. Quant au plugin que tu évoques, ça existe déjà, et j'en parle dans le post: il s'agit de Code Contracts, qui sera disponible avec .NET 4
@Romain: Merci pour le lien, je l'avais cherché lors de l'écriture du post pour parler de l'overhead éventuel à utiliser un delegate compilé. Ensuite effectivement, comme on ne crée qu'un delegate par type à tester, on a globalement vite fait le tour de tester les types principaux