W .NET istnieje wiele sposobów by napisać ten sam fragment kodu. Sam fakt, że na platformie można pisać już w kilku językach, powoduje iż napisanie prostego kalkulatora, może być ciekawe :) Ale nie zależnie jakiego języka użyjemy i tak nasz kod w końcu zostanie przekształcony do IL.

W tym krótkim poście :) stworzymy sobie kalkulator w języku C# ale, żeby się nie nudzić, zrobimy to za pomocą przestrzeni nazw System.Reflection.Emit.

A więc zaczynamy :) Tworzymy nowy projekt Console Application w VS i dodajemy następujące przestrzenie nazw:

using System.Reflection;
using System.Reflection.Emit;

Będą nam one potrzebny by stworzyć nasze własne assembly:

  • System.Reflection – udostępnia klasy, metody itp. do oglądania załadowanych typów.
  • System.Refection.Emit – udostępnia klasy, metody itp. do emitowania meta danych i MSIL.

Następnie w metodzie Main dodajemy następujący kod:

#region Opis Assembly
AssemblyName miniCalcAssemblyName = new AssemblyName();
miniCalcAssemblyName.Name = "GutekMiniCalc";
miniCalcAssemblyName.Version = new Version(1, 0, 0, 0);
#endregion Opis Assembly

AssemblyName umożliwia nam stworzenie unikatowych wartości assembly, takich jak jego nazwa, wersja czy kultura.

Następnie musimy pobrać klasę umożliwiającą nam tworzenie własnego assembly oraz stworzyć własny typ danych:

AssemblyBuilder miniCalcAssemblyBuilder = AppDomain.CurrentDomain.DefineDynamicAssembly(miniCalcAssemblyName, AssemblyBuilderAccess.RunAndSave);
ModuleBuilder mainCalcModBldr = miniCalcAssemblyBuilder.DefineDynamicModule("GutekMiniCalc", "GutekMiniCalc.dll");
TypeBuilder calcTypeBldr = mainCalcModBldr.DefineType("GutekMiniCalc.Calc", TypeAttributes.Public | TypeAttributes.Class | TypeAttributes.Sealed);

AssemblyBuilder umożliwia nam tworzenie własnego assembly, zaś AppDomain ją tworzy. ModuleBuilder umożliwia tworzenie modułów a TypeBuilder typów, które znajdują się w assembly. My tworzymy moduł GutekMiniCalc, który zawiera publiczna klasę Calc w przestrzeni nazw GutekMiniCalc.

Teraz pozostaje nam zdefiniowanie metod naszej nowej klasy Calc. Zaczynamy od prywatnego konstruktora:

#region Ctor
ConstructorBuilder privateConstructorBuilder = calcTypeBldr.DefineConstructor(MethodAttributes.Private, CallingConventions.Standard, null);
ILGenerator codeGen = privateConstructorBuilder.GetILGenerator();
codeGen.Emit(OpCodes.Ret);
#endregion Ctor

Tutaj pierwszy raz wykorzystujemy klasę ILGenerator, która umożliwia nam emitowanie kodu IL, dzięki któremu będziemy wstanie zapisać kod naszej klasy. Ze względu, że chcemy by nasz konstruktor nie zawierał żadnej metody, jedynie emitujemy kod IL odpowiedzialny za return.

Skoro mamy już naszą podstawę, pora zacząć implementację metod. Na początku trzy proste metody: Dodawania, Odejmowanie i Mnożenie:

#region Add
MethodBuilder addBldr = calcTypeBldr.DefineMethod("Add", MethodAttributes.Public | MethodAttributes.Static, typeof(Int32), new Type[] { typeof(Int32), typeof(Int32) });
codeGen = addBldr.GetILGenerator();
codeGen.DeclareLocal(typeof(Int32));
codeGen.Emit(OpCodes.Ldarg_0);
codeGen.Emit(OpCodes.Ldarg_1);
codeGen.Emit(OpCodes.Add);
codeGen.Emit(OpCodes.Stloc_0);
codeGen.Emit(OpCodes.Ldloc_0);
codeGen.Emit(OpCodes.Ret);
#endregion Add
 
#region Sub
MethodBuilder subBldr = calcTypeBldr.DefineMethod("Sub", MethodAttributes.Public | MethodAttributes.Static, typeof(Int32), new Type[] { typeof(Int32), typeof(Int32) });
codeGen = subBldr.GetILGenerator();
codeGen.DeclareLocal(typeof(Int32));
codeGen.Emit(OpCodes.Ldarg_0);
codeGen.Emit(OpCodes.Ldarg_1);
codeGen.Emit(OpCodes.Sub);
codeGen.Emit(OpCodes.Stloc_0);
codeGen.Emit(OpCodes.Ldloc_0);
codeGen.Emit(OpCodes.Ret);
#endregion Sub
 
#region Mul
MethodBuilder mulBldr = calcTypeBldr.DefineMethod("Mul", MethodAttributes.Public | MethodAttributes.Static, typeof(Int32), new Type[] { typeof(Int32), typeof(Int32) });
codeGen = mulBldr.GetILGenerator();
codeGen.DeclareLocal(typeof(Int32));
codeGen.Emit(OpCodes.Ldarg_0);
codeGen.Emit(OpCodes.Ldarg_1);
codeGen.Emit(OpCodes.Mul);
codeGen.Emit(OpCodes.Stloc_0);
codeGen.Emit(OpCodes.Ldloc_0);
codeGen.Emit(OpCodes.Ret);
#endregion Mul

Każda z tych metod składa się dosłownie z tych samych składników, jedyna różnica w ich implementacji znajduje się w jednej linijce emitowania kodu IL. Ale od początku. Tak jak mamy dostępne klasy AssemblyBuilder, ModuleBuilder, TypeBuilder, ConstructorBuilder tak też posiadamy MethodBuilder (jak i wiele innych, by wymienić dwie FieldBuilder czy PropertyBuilder, wszystkie te klasy są dostępne w przestrzeni nazw System.Reflection.Emit), która umożliwia nam tworzenie całych metod a za pomocą GetILGenerator(), także ich ciał. Każda z metod, które utworzyliśmy jest publiczna, statyczna, zwraca int i przyjmuje dwa parametry typu int.

Następne operacje to:

  • OpCodes.Ldarg_0 – umieszcza pierwszy argument metody na stos.
  • OpCodes.Ldarg_1 – umieszcza drugi argument metody na stos.
  • OpCodes.Mul – wykonuje operacje mnożenia (add – dodawania, sub – odejmowania).
  • OpCodes.Stloc_0 – wynik mnożenia, dodwania lub odejmowania, jest zdejmowany ze stosu i przypisywany do pierwszej lokalnej zmiennej, która utworzyliśmy za pomocą metody DeclareLocal. Zmienna ta jest typu int.
  • OpCodes.Ldloc_0 – umieszcza pierwszą zmienną lokalną na stos.
  • OpCodes.Ret – zwraca pierwszy argument załadowany na stos (jeżeli istnieje), jeżeli nie to wychodzi z metody bez zwracania wartości.

Nie było to aż tak skomplikowane :) Jednak teraz trzeba wykonać operację dzielenia. A jak wiadomo, nie wolno dzielić przez zero. Nasz kod wygląda tak:

#region Div
MethodBuilder divBldr = calcTypeBldr.DefineMethod("Div", MethodAttributes.Public | MethodAttributes.Static, typeof(Int32), new Type[] { typeof(Int32), typeof(Int32) });
codeGen = divBldr.GetILGenerator();
codeGen.DeclareLocal(typeof(Int32));
codeGen.DeclareLocal(typeof(Boolean));
Label l = codeGen.DefineLabel();
 
#region Osbluga wyjatku - dzielenie przez zero
codeGen.Emit(OpCodes.Ldarg_1);
codeGen.Emit(OpCodes.Ldc_I4_0);
codeGen.Emit(OpCodes.Ceq);
codeGen.Emit(OpCodes.Ldc_I4_0);
codeGen.Emit(OpCodes.Ceq);
codeGen.Emit(OpCodes.Stloc_1);
codeGen.Emit(OpCodes.Ldloc_1);
codeGen.Emit(OpCodes.Brtrue_S, l);
codeGen.Emit(OpCodes.Ldstr, "Pamietaj cholero, nigdy nie dziel przez zero!");
codeGen.Emit(OpCodes.Newobj, typeof(ArgumentException).GetConstructor(new Type[] { typeof(String) }));
codeGen.Emit(OpCodes.Throw);
#endregion Osbluga wyjatku
 
codeGen.MarkLabel(l);
codeGen.Emit(OpCodes.Ldarg_0);
codeGen.Emit(OpCodes.Ldarg_1);
codeGen.Emit(OpCodes.Div);
codeGen.Emit(OpCodes.Stloc_0);
codeGen.Emit(OpCodes.Ldloc_0);
codeGen.Emit(OpCodes.Ret);
#endregion Div

Nie licząc zmiany w środku metody, która dodaje obsługę błędu, kod wygląda prawie tak samo jak przy odejmowaniu, mnożeniu czy dodawaniu. Różnica polega na deklaracji kolejnej zmiennej typu bool, oraz deklaracji etykiety, którą później wykorzystamy za mocną MarkLabel. Powoduje to zaznaczenie, że stworzona etykieta odnosi się to danego miejsca w kodzie.

Obsługa błędu zaś wykonuje następujące operacje:

  • OpCodes.Ldarg_1 – umieszcza drugi argument metody na stos.
  • OpCodes.Ldc_I4_0 – umieszcza wartość zero na stos.
  • OpCodes.Ceq – porównuje dwie wartości, jeżeli są równe w, na stos wrzucana jest jedynka (1), jeżeli różne to wrzucane jest zero (0).
  • OpCodes.Ldc_I4_0 – umieszcza wartość 0 na stos.
  • OpCodes.Ceq – porównuje dwie wartości.
  • OpCodes.Stloc_1 – zdejmuje aktualną wartość ze stosu i umieszcza ją w drugiej zmiennej lokalnej.
  • OpCodes.Ldloc_1 – umieszcza drugą zmienną lokalną na stosie.
  • OpCodes.Brtrue_S – przerzuca kontrolę programu do etykiety jeżeli wartość aktualna na stosie jest równa true, nie jest null oraz nie jest zerem.
  • OpCodes.Ldstr – umieszcza referencję do obiektu ciągu znaków.
  • OpCodes.Newobj – tworzy nowy obiekt i jego referencje wrzuca na stos.
  • OpCodes.Throw – wyrzuca wyjątek aktualnie umieszczony na stosie.

Teraz pozostają nam jedynie dwie linijki kodu:

calcTypeBldr.CreateType();
miniCalcAssemblyBuilder.Save("GutekMiniCalc.dll");

Pierwsza tworzy typ danych, który utworzyliśmy. Jeżeli coś pominęliśmy w kodzie lub nie stworzyliśmy ciała metody, CreateType zwróci nam błąd. Zaś metoda Save, zapisuje nasze assembly. Po wykonaniu kodu w katalogu Debug lub Release znajdziecie naszą nową utworzoną DLL którą możecie obejrzeć sobie w .NET Reflector lub wykorzystać w kodzie.

Dlaczego w ogóle to napisałem? Dość dobre pytanie. Po pierwsze jest to niezłą ciekawostką. Nie wszyscy wiedzą, że można stworzyć własne assembly dynamicznie a następnie je wykorzystać w kodzie. Daje to nam możliwość generowania tego co potrzebujemy całkowicie w lotcie – czasami w zależności od wymagań klienta to może być koniecznością. Po drugie należy to do egzaminów MS jak i można natrafić na takie pytania w Mistrz.NET (jeżeli będzie kolejna edycji). Po trzecie i najważniejsze, nie wyobrażam sobie bycia programistą i nawet nie zainteresowania się językiem pośrednim. Warto wiedzieć co i jak się generuje, jakie konstrukcję językowowe tworzą takie same kody IL, a w skrajnych przypadkach może mieć to znaczący wpływa na wydajność aplikacji. Także wiedza ta niezbędna jest do tego by móc przejrzeć niektóre implementacje metod gdyż .NET Reflector nie zawsze jest wstanie wam pomóc. A to co przedstawiłem w tym artykule jest minimum tego co powinniście wiedzieć. Również dzięki wiedzy jak to zrobić możecie teraz w praktyce przetestować to czego się nauczycie na temat IL. Możecie stworzyć własny kod i przetestować czy działa. Wiedza już nie będzie ograniczona do wiedzy teoretycznej, a także praktycznej.

Zachęcam was do rozwijania tego kalkulatora o dodatkowe opcje i walidację (np.: mnożenie dwóch int które przekraczają dopuszczalną maksymalną wartość dla int).

Program.zip (1.00 kb)

4 KOMENTARZE

  1. Mnie tutaj zabrakło info o tym, dlaczego korzystanie z Reflection.Emit ma być rozrywką. A tak ok, ciekawy przykład. pzdr

  2. Gratulacje ze chcialo ci sie siasc do ILa :P

    Dwie uwagi:
    1. Po co ci definiowanie zmiennej lokalnej w metodzie Add i innych? Troche rozzutny ten IL. A moze wziales go z ildasma/reflectora?
    2. Jesli potrzba bylo dynamiczne wygenerowanie DLLki to moze prosciej… CSharpCodeProvider. Wiecej o generacji w pierwszym zinie (o ile pamiec mnie nie myli).

    ———————————————-

    MethodInfo mi1, mi2, mi3;

    { // example 1
    var add = new DynamicMethod(“Add”, typeof(int), new[] { typeof(int), typeof(int) });
    var il = add.GetILGenerator();
    il.Emit(OpCodes.Ldarg_0);
    il.Emit(OpCodes.Ldarg_1);
    il.Emit(OpCodes.Add);
    il.Emit(OpCodes.Ret);

    mi1 = add;
    }
    { // example 2
    var add = new DynamicMethod(“Add”, typeof(int), new[] { typeof(int), typeof(int) });
    var il = add.GetILGenerator();
    il.DeclareLocal(typeof(int));
    il.Emit(OpCodes.Ldarg_0);
    il.Emit(OpCodes.Ldarg_1);
    il.Emit(OpCodes.Add);
    il.Emit(OpCodes.Stloc_0);
    il.Emit(OpCodes.Ldloc_0);
    il.Emit(OpCodes.Ret);

    mi2 = add;
    }
    { // example 3
    const string code = @”public class X { public static int Add(int x, int y) { return x+y; } }”;

    var parameters = new CompilerParameters {
    GenerateInMemory = true,
    GenerateExecutable = false,
    };
    Assembly a = new CSharpCodeProvider().CompileAssemblyFromSource(parameters, code).CompiledAssembly;
    mi3 = a.GetTypes()[0].GetMethod(“Add”);

    }

    int res1 = (int)mi1.Invoke(null, new object[] { 1, 3 });
    int res2 = (int)mi2.Invoke(null, new object[] { 1, 3 });
    int res3 = (int)mi3.Invoke(null, new object[] { 1, 3 });

  3. @ Tomasz

    Szczerze, pisanie w IL to juz musi byc zabawa, bo jak ktos pisze w tym kod calego programu, to mu osobiscie wspolczuje. Tak jak pisac w assemblerze teraz. Raczej zabawa – choc czasami i praca.

    @ Wojtek
    Masz racje mozna to zrobic szybciej jak zreszta zrobiles to w example 1, jednak chcialem pokazac sposob z deklaracja zmiennych i ze skokiem. Moglem zrobic kazdy inaczej, ale jak juz jeden napisalem to reszta skopiowalem ;) Pozatym takze liczy sie tak myslenia osoby piszacej w IL. Co do .NET Reflectora, masz racje, wlasnie sprawdzilem generuje on tak samo jak ja napisalem, czyli return x + y to utworzenie zmiennej i do niej przypisanie i jej zwrocenie. Jezeli w IL sie wymusi by kod byl zapisany tak jak u Ciebie example 1 to .NET Reflector nie wyswietla deklaracji zmiennej.

    Co do CSharpCodeProvider – jasne ze prosciej. ale takze nie wystepuje on narazie na egzaminach MS, oraz w pytaniach do Mistrza .NET. Jak zreszta pokazales w sample 3, nawet nie trzeba pisac kodu w IL, a ja raczej chcialem pokazac, ze ludzie moga “pisac w IL” w .NET. Chcialem takze by podstawowe operacje byly wyraznie opisane, gdyz z bardzo czesto one wystepuja.

    Tak pozatym, to dzieki za fajne przyklady! Dobrze wiedziec ze jest ktos to takze sie “bawi” IL :)

Comments are closed.