FluentIL – Parte 5 – DSL Improvements (Again!)

Publicado em 02/05/2011

0


Olá pessoal, como estamos?

Se há algo que eu aprendi nesses anos desenvolvendo soluções é: “seja o seu primeiro cliente”. Se está escrevendo uma API, consuma essa API. Somente dessa forma poderá ter clareza do quanto fez um bom trabalho, ou quanto precisa melhorar.

No post de hoje, aplico esse princípio a FluentIL. Na prática, escrevi um código trivial em C# e tentei reescrever, com Emitting, usando FluentIL. Resultado: Muitas oportunidades de melhoria.

Este post faz parte de uma série sobre desenvolvimento de uma DSL para simpliciação de Emitting em .NET. Todo o código fonte está disponível em https://github.com/elemarjr/FluentIL

Vamos aos fatos.

Código de referência

Antes de qualquer coisa, vamos ao nosso código de referência. Observe:

public string SayHello(string a)
{
    if (a == null)
        throw new ArgumentNullException("a");

    if (a == String.Empty)
        throw new ArgumentException("Argument 'a' cannot be empty");

    return ("Hello " + a);
}

Trata-se de um código muito simples de entender e escrever (Não há necessidade de explicar esse código, certo?!). Agora, vejamos o IL gerado pelo compilador:

.method public hidebysig instance string 
        SayHello(string a) cil managed
{
    // Code size       50 (0x32)
    .maxstack  8
    IL_0000:  ldarg.1
    IL_0001:  brtrue.s   IL_000e
    IL_0003:  ldstr      "a"
    IL_0008:  newobj     instance void [mscorlib]System.ArgumentNullException::.ctor(string)
    IL_000d:  throw
    IL_000e:  ldarg.1
    IL_000f:  ldsfld     string [mscorlib]System.String::Empty
    IL_0014:  call       bool [mscorlib]System.String::op_Equality(string,
                                                                    string)
    IL_0019:  brfalse.s  IL_0026
    IL_001b:  ldstr      "Argument 'a' cannot be empty"
    IL_0020:  newobj     instance void [mscorlib]System.ArgumentException::.ctor(string)
    IL_0025:  throw
    IL_0026:  ldstr      "Hello "
    IL_002b:  ldarg.1
    IL_002c:  call       string [mscorlib]System.String::Concat(string,
                                                                string)
    IL_0031:  ret
} // end of method Program::SayHello

Nada demais aqui também. Se você não sabe IL, recomendo a leitura da série introdutória que escrevi sobre o assunto. É digno de nota que o compilador “escreve” uma chamada direta para o operador de comparações da classe string e, além disso, utiliza automaticamente o método Concat. Bacana!

Simplificando o uso (e teste) do código gerado pelo Emitting

No post anterior, apresentei uma ampliação significativa na DSL. Com ela, é possível gerar tipos inteiramente novos usando o mecanismo (antes era possível gerar apenas métodos).

Hoje, utilizo o artifício de criar um tipo implementando uma interface. A interface que desenvolvi para o código acima foi:

public interface ISayHello2
{
    string SayHello(string a);
}

Parto do princípio que o método resultante deva ser submetido a alguns testes (confirmando funcionamento). Obseve:

[Test]
[ExpectedException(typeof(ArgumentNullException))]
public void SayHelloV1_PassingNull_ThrowsArgumentNullException()
{
    this.CreateSayHelloV1().SayHello(null);
}

[Test]
[ExpectedException(typeof(ArgumentException))]
public void SayHelloV1_PassingEmpty_ThrowsArgumentException()
{
    this.CreateSayHelloV1().SayHello("");
}

[Test]
public void SayHelloV1_PassingElemar_ReturnsHelloElemar()
{
    var result = this.CreateSayHelloV1().SayHello("Elemar");
    result.Should().Be("Hello Elemar");
}

Repare como extraí o método de emitting dos testes. “DRY na  veia, mano!”

Primeira versão com FluentIL (bad, bad smells)

É impressionante como um código simples, como o indicado acima, revelou deficiências na DSL já desenvolvida para FluentIL. Observe:

private ISayHello2 CreateSayHelloV1()
{
    var argumentNullExceptionConstructor = typeof(ArgumentNullException)
        .GetConstructor(new[] { typeof(string) });

    argumentNullExceptionConstructor.Should().Not.Be(null);

    var argumentExceptionConstructor = typeof(ArgumentException)
        .GetConstructor(new[] { typeof(string) });

    var stringEmptyField = typeof(string).GetField("Empty");

    var stringOp_EqualityMethod = typeof(string).GetMethod("op_Equality", 
        new[] { typeof(string), typeof(string) }
        );

    var stringConcatMethod = typeof(string).GetMethod("Concat", 
        new[] { typeof(string), typeof(string) });
    stringConcatMethod.Should().Not.Be(null);

    var t = IL.NewType().Implements<ISayHello2>()
        .WithMethod("SayHello")
            .WithParameter(typeof(string), "a")
            .Returns(typeof(string))

            .Ldarg("a")
            .Brtrue_S("NotNull")
            .Ldstr("a")
            .Emit(OpCodes.Newobj, argumentNullExceptionConstructor)
            .Emit(OpCodes.Throw)
            .MarkLabel("NotNull")

            .Ldarg("a")
            .Emit(OpCodes.Ldsfld, stringEmptyField)
            .Emit(OpCodes.Call, stringOp_EqualityMethod)
            .Brfalse_S("NotEmpty")

            .Ldstr("Argument 'a' cannot be empty")
            .Emit(OpCodes.Newobj, argumentExceptionConstructor)
            .Emit(OpCodes.Throw)

            .MarkLabel("NotEmpty")
            .Ldstr("Hello ")
            .Ldarg("a")
            .Emit(OpCodes.Call, stringConcatMethod)
            .Ret()
        .AsType;

    return (ISayHello2)Activator.CreateInstance(t);
}

Duas notas importantes sobre esse código:

  1. Há um número excessivo (maior do que eu esperava), de chamadas ao método Helper Emit (que deve ser evitado). Mais que isso, para muitas das linhas, não havia sobrecarga nem mesmo nesse método;
  2. Há um número demasiadamente alto de chamadas a API de reflection do .NET para recuperar informações sobre métodos e construtores.

O problema com o método Emit é que ele não determina uma relação explícita entre o OpCode que está sendo emitido e o “parâmetro” esperado.

Segunda versão com FluentIL (adeus métodos Emit)

A providêcia mais urgente (e simples de implementar) foi acrescer a DSL métodos para os OpCodes que exigiram chamada direta para o método Emit. Para isso, adicionei os seguintes métodos a DynamicMethodBody (responsável pela fluência no “código de método”):

public DynamicMethodBody Throw()
{
    return this.Emit(OpCodes.Throw);
}

public DynamicMethodBody Ldsfld(FieldInfo fieldInfo)
{
    return this.Emit(OpCodes.Ldsfld, fieldInfo);
}
        
public DynamicMethodBody Newobj(ConstructorInfo ctorInfo)
{
    return this.Emit(OpCodes.Newobj, ctorInfo);
}

public DynamicMethodBody Call(MethodInfo methodInfo)
{
    return this.Emit(OpCodes.Call, methodInfo);
}

O que ganhamos? Nosso “programador cliente” saberá exatamente o que precisa passar quando usar um desses OpCodes. Atualizando nosso exemplo de hoje, temos:

private ISayHello2 CreateSayHelloV2()
{
    var argumentNullExceptionConstructor = typeof(ArgumentNullException)
        .GetConstructor(new[] { typeof(string) });

    argumentNullExceptionConstructor.Should().Not.Be(null);

    var argumentExceptionConstructor = typeof(ArgumentException)
        .GetConstructor(new[] { typeof(string) });


    var stringEmptyField = typeof(string).GetField("Empty");

    var stringOp_EqualityMethod = typeof(string).GetMethod("op_Equality", 
        new[] { typeof(string), typeof(string) });

    var stringConcatMethod = typeof(string).GetMethod("Concat", 
        new[] { typeof(string), typeof(string) });
    stringConcatMethod.Should().Not.Be(null);

    var t = IL.NewType().Implements<ISayHello2>()
        .WithMethod("SayHello")
            .WithParameter(typeof(string), "a")
            .Returns(typeof(string))

            .Ldarg("a")
            .Brtrue_S("NotNull")
            .Ldstr("a")
            .Newobj(argumentNullExceptionConstructor)
            .Throw()
            .MarkLabel("NotNull")

            .Ldarg("a")
            .Ldsfld(stringEmptyField)
            .Call(stringOp_EqualityMethod)
            .Brfalse_S("NotEmpty")

            .Ldstr("Argument 'a' cannot be empty")
            .Newobj(argumentExceptionConstructor)
            .Throw()

            .MarkLabel("NotEmpty")
            .Ldstr("Hello ")
            .Ldarg("a")
            .Call(stringConcatMethod)

            .Ret()
        .AsType;

    return (ISayHello2)Activator.CreateInstance(t);
}

Melhorou. Mas ainda está em ruim! Intermediate Language realmente não é uma linguagem expressiva. Ainda temos muitas chamadas a API de reflection e é difícil identificar os desvios condicionais.

Terceira versão com FluentIL (bem-vindos, testes condicionais)

Basicamente, fazemos dois testes no códig IL. Um para testar se o parâmetro está nulo, outro para verificar se a string é vazia.

Como o objetivo de FluentIL é facilitar o Emitting e, além disso, essas comparações são muito freqüentes, decidi incorporar a DSL algumas condicionais (Começamos esse assunto na parte 2).

Os helpers criados para testar se uma string é vazia trazem a vantagem adicional de “ocultar” algum reflection. Observe:

public DynamicMethodBody IfEmptyString(bool not)
{
    var stringEmptyField = typeof(string).GetField("Empty");
    var stringOp_EqualityMethod = typeof(string).GetMethod(
        "op_Equality", new[] { typeof(string), typeof(string) });

    var emitter = new IfEmitter(this);
    _IfEmitters.Push(emitter);
    this
        .Ldsfld(stringEmptyField)
        .Call(stringOp_EqualityMethod);

    emitter.EmitBranch(not);
    return this;
}

public DynamicMethodBody IfEmptyString()
{
    return this.IfEmptyString(false);
}

public DynamicMethodBody IfNotEmptyString()
{
    return this.IfEmptyString(true);
}

Lindo! Importante destacar que o primeiro método (base para os demais) está em conformidade com a lógica que desenvolvemos para condicionais até aqui. Os métodos para testar se um valor é nulo seguem o mesmo pattern, observe:

public DynamicMethodBody IfNull(bool not)
{
    var emitter = new IfEmitter(this);
    _IfEmitters.Push(emitter);
    emitter.EmitBranch(!not);
    return this;
}

public DynamicMethodBody IfNull()
{
    return this.IfNull(false);
}

public DynamicMethodBody IfNotNull()
{
    return this.IfNull(true);
}

Todos esses métodos foram implementados dentro da classe DynamicMethodBody. Agora, observemos como fica nosso exemplo de hoje usando esses novos helpers.

private ISayHello2 CreateSayHelloV3()
{
    var argumentNullExceptionConstructor = typeof(ArgumentNullException)
        .GetConstructor(new[] { typeof(string) });

    argumentNullExceptionConstructor.Should().Not.Be(null);

    var argumentExceptionConstructor = typeof(ArgumentException)
        .GetConstructor(new[] { typeof(string) });

    var stringConcatMethod = typeof(string).GetMethod("Concat", 
        new[] { typeof(string), typeof(string) });
    stringConcatMethod.Should().Not.Be(null);

    var t = IL.NewType().Implements<ISayHello2>()
        .WithMethod("SayHello")
            .WithParameter(typeof(string), "a")
            .Returns(typeof(string))

            .Ldarg("a")
            .IfNull()
                .Ldstr("a")
                .Newobj(argumentNullExceptionConstructor)
                .Throw()
            .EndIf()

            .Ldarg("a")
            .IfEmptyString()
                .Ldstr("Argument 'a' cannot be empty")
                .Newobj(argumentExceptionConstructor)
                .Throw()
            .EndIf()

            .Ldstr("Hello ")
            .Ldarg("a")
            .Call(stringConcatMethod)

            .Ret()
        .AsType;

    return (ISayHello2)Activator.CreateInstance(t);
}

Perceba a redução significativa de código “visível” para Reflection. Além disso, perceba como a “lógica condicional” de nosso código ficou mais clara. Entretanto, observe como a criação de objetos ainda parece “turva”.

Quarta versão com FluentIL (simplificando a criação de objetos)

A quantidade de código escrito para reflection realmente me incomoda. Penso que Newobj poderia fornecer uma interface mais intuitiva. Observe:

public DynamicMethodBody Newobj(ConstructorInfo ctorInfo)
{
    return this.Emit(OpCodes.Newobj, ctorInfo);
}

public DynamicMethodBody Newobj<T>(params Type[] types)
{
    var ci = typeof(T).GetConstructor(types);
    return this.Newobj(ci);
}

O novo Newobj aceita mais informações sobre o tipo que está sendo criado. Consequentemente, consegue selecionar o construtor adequado. Isso remove a necessidade de nosso “programador cliente” se preocupar com GetConstructor. Observe:

private ISayHello2 CreateSayHelloV4()
{
    var stringConcatMethod = typeof(string).GetMethod("Concat", 
        new[] { typeof(string), typeof(string) });
    
    var t = IL.NewType().Implements<ISayHello2>()
        .WithMethod("SayHello")
            .WithParameter(typeof(string), "a")
            .Returns(typeof(string))

            .Ldarg("a")
            .IfNull()
                .Ldstr("a")
                .Newobj<ArgumentNullException>(typeof(string))
                .Throw()
            .EndIf()

            .Ldarg("a")
            .IfEmptyString()
                .Ldstr("Argument 'a' cannot be empty")
                .Newobj<ArgumentException>(typeof(string))
                .Throw()
            .EndIf()

            .Ldstr("Hello ")
            .Ldarg("a")
            .Call(stringConcatMethod)

            .Ret()
        .AsType;

    return (ISayHello2)Activator.CreateInstance(t);
}

Mais uma vez, perceba a redução de chamadas Reflection em nosso código. Perceba também como a DSL fica mais expressiva na criação de objetos. Entretanto, observe como o método Newobj parece um pedágio (é realmente necessário?!). Além disso, Call também parece estar colaborando muito pouco com a legibilidade de nosso código.

Quinta versão com FluentIL (sem pedágios, please)

O método Throw poderia fazer algo mais do que disparar um OpCode. Observe:

public DynamicMethodBody Throw()
{
    return this.Emit(OpCodes.Throw);
}

public DynamicMethodBody Throw<TException>(params Type[] types)
    where TException : Exception
{
    return this
        .Newobj<TException>(types)
        .Throw();
}

O que fizemos? Retiramos a necessidade do desenvolvedor chamar Newobj diretamente quando necessita disparar uma Exception. Por razões óbvias, adicionei uma constraint ao código restringindo o tipo a derivados de System.Exception.

Expandindo esse mesmo princípio, altero o método Call para que ele descubra o MethodInfo que deve ser utilizado. Observe:

public DynamicMethodBody Call(MethodInfo methodInfo)
{
    return this.Emit(OpCodes.Call, methodInfo);
}

public DynamicMethodBody Call<T>(string methodName, params Type[] types)
{
    var mi = typeof(T).GetMethod(methodName, types);
    return this.Call(mi);
}

Essas alterações nos permitem nossa quinta versão para o exemplo de hoje:

private ISayHello2 CreateSayHelloV5()
{
    var t = IL.NewType().Implements<ISayHello2>()
        .WithMethod("SayHello")
            .WithParameter(typeof(string), "a")
            .Returns(typeof(string))

            .Ldarg("a")
            .IfNull()
                .Ldstr("a")
                .Throw<ArgumentNullException>(typeof(string))
            .EndIf()

            .Ldarg("a")
            .IfEmptyString()
                .Ldstr("Argument 'a' cannot be empty")
                .Throw<ArgumentException>(typeof(string))
            .EndIf()

            .Ldstr("Hello ")
            .Ldarg("a")
            .Call<string>("Concat", typeof(string), typeof(string))

            .Ret()
        .AsType;

    return (ISayHello2)Activator.CreateInstance(t);
}

Agora sim! Algo legível e fluente.

Por hoje, era isso!

Smiley piscando

Etiquetado:,
Publicado em: Post