Emisja IL: DynamicMethod

W przyszłym poście zajmę się słowem kluczowym dynamic od strony IL. Najpierw jednak chciałbym zaprezentować klasę DynamicMethod, ułatwiającą tworzenie własnych metod w oparciu o emisję IL. Jest ona bardzo prosta w użyciu i bez żadnej, skomplikowanej konfiguracji wystarczy po prostu wygenerować IL (co jest oczywiście trudnym zadaniem). Przykład:

MethodInfo writeLineMethod = typeof(Console).GetMethod("WriteLine", types: new Type[] { typeof(string) });

var dynamicMethod = new DynamicMethod("SayHello", typeof (string), new Type[] {}, typeof (Program));

ILGenerator generator = dynamicMethod.GetILGenerator();
generator.Emit(OpCodes.Ldstr, "Hello World !");
generator.EmitCall(OpCodes.Call, writeLineMethod, null);
generator.Emit(OpCodes.Ldstr, "Hello World 2 !");
generator.Emit(OpCodes.Ret);

var sayHelloMethod =(Func<string>)dynamicMethod.CreateDelegate(typeof(Func<string>));

string result = sayHelloMethod();

Wyemitowany IL wyświetli i zwróci tekst. Za pomocą Ldstr ładujemy na stos pierwszy napis, potem wykonujemy metodę Call, która pobierze ze stosu wspomniany napis. Na końcu zwracamy kolejny napis. Język IL opisywałem w kilkunastu postach więc jeśli instrukcje nie są jasne to zachęcam do poszperania na blogu.

Kolejne pytanie, po co nam to? Na pewno przyda się w analizowaniu internali i płynących z tego czasami optymalizacji. Jedną z tych optymalizacji jest zastąpienie mechanizmu refleksji dynamic method. Powyższy kod to nic innego jak wywołanie metody, do której nie mamy bezpośrednio dostępu.

Uproszczając przykład możemy:

MethodInfo toUpper = typeof (string).GetMethod("ToUpper", new Type[0]);            
var dynamicMethod = new DynamicMethod("ToUpperWrapper", typeof (string), new Type[] {typeof (string)});

ILGenerator il = dynamicMethod.GetILGenerator();
il.Emit(OpCodes.Ldarg_0);
il.Emit(OpCodes.Call, toUpper);
il.Emit(OpCodes.Ret);

var toUpperWrapper = (Func<string, string>) dynamicMethod.CreateDelegate(typeof (Func<string, string>));

string result = toUpperWrapper("Hello world");

Kod pokazuje jak wywołać metodę ToUpper tak jakbyśmy korzystali z reflection. Z tym, że jest to o WIELE szybsze i jeśli tylko musimy korzystać w aplikacji z reflection, lepiej to zrobić za pomocą DynamicMethod albo dynamic.

Napiszmy prosty benchmark:

class Program
{
   private const int N = 1000000000;
   private static void TestReflectionInvoke()
   {
       MethodInfo toUpper = typeof(string).GetMethod("ToUpper", new Type[0]);

       var stopwatch = Stopwatch.StartNew();
       object test = "Hello World";

       for (int i = 0; i < N; i++)
           test = toUpper.Invoke(test, null);

       stopwatch.Stop();
       Console.WriteLine("Reflection:{0}", stopwatch.ElapsedTicks);
   }
   private static void TestDynamicMethod()
   {                       
       MethodInfo toUpper = typeof (string).GetMethod("ToUpper", new Type[0]);
       
       var stopwatch = Stopwatch.StartNew();
       var dynamicMethod = new DynamicMethod("ToUpperWrapper", typeof (string), new Type[] {typeof (string)});

       ILGenerator il = dynamicMethod.GetILGenerator();
       il.Emit(OpCodes.Ldarg_0);
       il.Emit(OpCodes.Call, toUpper);
       il.Emit(OpCodes.Ret);
       
       var toUpperWrapper = (Func<string, string>) dynamicMethod.CreateDelegate(typeof (Func<string, string>));

       object test = "Hello World";

       for (int i = 0; i < N; i++)
           test = toUpperWrapper("Hello world");
   
       Console.WriteLine("Dynamic method:{0}", stopwatch.ElapsedTicks);
   }

   static void Main(string[] args)
   {
       TestDynamicMethod();
       TestReflectionInvoke();
   }

}

Różnica jest znacząca, na korzyść DynamicMethod:

image

Dobre praktyki – wydajność async\await dla skomplikowanych wyrażeń

O dobrych praktykach async\await pisałem już wielokrotnie. Dzisiaj zacznijmy od code review:

private static async void Test()
{
  int result = SyncFunction1() + SyncFunction2()*await TestAsync() + SyncFunction3();
}

private static int SyncFunction1()
{
  // jakas logika
  return 1;
}
private static int SyncFunction2()
{
  // jakas logika
  return 1;
}

private static int SyncFunction3()
{
  // jakas logika
  return 1;
}
private static async Task<int> TestAsync()
{
  return 5;
}

W powyższym kodzie mamy skomplikowane wyrażenie, które wygląda na synchroniczne:

int result = SyncFunction1() + SyncFunction2()*await TestAsync() + SyncFunction3();

Wiemy, że tak naprawdę musi zostać wygenerowana maszyna stanów. Powyższy przykład zawiera również wyrażenie, które jest przetwarzane standardowo od lewej do prawej strony, z uwzględnieniem nawiasów i kolejności wykonywania operatorów. Gdyby nie kod asynchroniczny, byłoby to łatwe – po prostu na stosie kolejne wyniki byłyby przechowywane.

Standardowy stos w async nie może zostać użyty bo po wskoczeniu w metodę asynchroniczną całość zostałaby wyczyszczona.

Z tego względu w .NET zaimplementowano tzw. stack spilling. Polega to na przeniesieniu wartości, które normalnie znajdowałby się na stosie, na stertę (stack->heap). Ma to oczywiście pewne konsekwencje takie jak dodatkowa alokacja obiektów czy boxing. Każdy zaalokowany obiekt referencyjny posiada kilka dodatkowych pól w przeciwieństwie do bardziej oszczędnych struktur. Ponadto GC będzie miał dodatkową robotę i powoduje to najwięcej strat. Przyjrzyjmy się wygenerowanej maszynie stanów:

[CompilerGenerated]
[StructLayout(LayoutKind.Auto)]
private struct <Test>d__0 : IAsyncStateMachine
{
public int <>1__state;
public AsyncVoidMethodBuilder <>t__builder;
public int <result>5__1;
private TaskAwaiter<int> <>u__$awaiter2;
private object <>t__stack;
void IAsyncStateMachine.MoveNext()
{
    try
    {
        int num = this.<>1__state;
        if (num != -3)
        {
            int arg_BF_0;
            int arg_BE_0;
            TaskAwaiter<int> taskAwaiter;
            if (num != 0)
            {
                int expr_1E = arg_BF_0 = Program.SyncFunction1();
                int expr_23 = arg_BE_0 = Program.SyncFunction2();
                taskAwaiter = Program.TestAsync().GetAwaiter();
                if (!taskAwaiter.IsCompleted)
                {
                    Tuple<int, int> tuple = new Tuple<int, int>(expr_1E, expr_23);
                    this.<>t__stack = tuple;
                    this.<>1__state = 0;
                    this.<>u__$awaiter2 = taskAwaiter;
                    this.<>t__builder.AwaitUnsafeOnCompleted<TaskAwaiter<int>, Program.<Test>d__0>(ref taskAwaiter, ref this);
                    return;
                }
            }
            else
            {
                Tuple<int, int> tuple = (Tuple<int, int>)this.<>t__stack;
                arg_BF_0 = tuple.Item1;
                arg_BE_0 = tuple.Item2;
                this.<>t__stack = null;
                taskAwaiter = this.<>u__$awaiter2;
                this.<>u__$awaiter2 = default(TaskAwaiter<int>);
                this.<>1__state = -1;
            }
            int arg_BE_1 = taskAwaiter.GetResult();
            taskAwaiter = default(TaskAwaiter<int>);
            int num2 = arg_BF_0 + arg_BE_0 * arg_BE_1 + Program.SyncFunction3();
            this.<result>5__1 = num2;
        }
    }
    catch (Exception exception)
    {
        this.<>1__state = -2;
        this.<>t__builder.SetException(exception);
        return;
    }
    this.<>1__state = -2;
    this.<>t__builder.SetResult();
}
[DebuggerHidden]
void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine param0)
{
    this.<>t__builder.SetStateMachine(param0);
}
}

Na początku widzimy deklaracje pól wewnętrznych maszyny:

public int <>1__state;
public AsyncVoidMethodBuilder <>t__builder;
public int <result>5__1;
private TaskAwaiter<int> <>u__$awaiter2;
private object <>t__stack;

Wszystkie zmienne (numer stanu, buiilder, awaiter) były już wcześniej omawiane na blogu. Nowością jest _stack czyli wspomniany stos przechowywany na stercie (proszę zauważyć, że jest to zawsze typ referencyjny).

W programowaniu asynchronicznym, jeśli potrzebujemy użyć stosu (evaluation stack) umieszczamy go właśnie w tym polu (__stack). W większości wypadków będzie to lista wartości i wtedy do __stack przypisuje się Tuple<…> ze wszystkimi częściowymi wynikami.

W pierwszym stanie wykonujemy wszystko co jest po lewej stronie await, czyli SyncFunction1 oraz SyncFunction2:

int expr_1E = arg_BF_0 = Program.SyncFunction1();
int expr_23 = arg_BE_0 = Program.SyncFunction2();
taskAwaiter = Program.TestAsync().GetAwaiter();
if (!taskAwaiter.IsCompleted)
{
    Tuple<int, int> tuple = new Tuple<int, int>(expr_1E, expr_23);
    this.<>t__stack = tuple;
    this.<>1__state = 0;
    this.<>u__$awaiter2 = taskAwaiter;
    this.<>t__builder.AwaitUnsafeOnCompleted<TaskAwaiter<int>, Program.<Test>d__0>(ref taskAwaiter, ref this);
    return;
}

Wynik na stosie jest przechowywany jako Tuple<int,int>:

Tuple<int, int> tuple = new Tuple<int, int>(expr_1E, expr_23);
this.<>t__stack = tuple;

Jest to pierwsza “zbędna” alokacja. W kolejnym stanie zdejmujemy wartości ze stosu, uzyskując wynik SyncFunction1 oraz SyncFunction2. Na tym etapie mamy   również wynik asynchronicznej funkcji TestAsync:

Tuple<int, int> tuple = (Tuple<int, int>)this.<>t__stack;
    arg_BF_0 = tuple.Item1;
    arg_BE_0 = tuple.Item2;
    this.<>t__stack = null;
    taskAwaiter = this.<>u__$awaiter2;
    this.<>u__$awaiter2 = default(TaskAwaiter<int>);
    this.<>1__state = -1;
}
int arg_BE_1 = taskAwaiter.GetResult();
taskAwaiter = default(TaskAwaiter<int>);
int num2 = arg_BF_0 + arg_BE_0 * arg_BE_1 + Program.SyncFunction3();
this.<result>5__1 = num2;

Przyjrzyjmy się teraz sytuacji, gdzie niezbędne jest posiadanie więcej niż tylko dwóch stanów:

int result = await TestAsync()*SyncFunction1() + SyncFunction2()*(await TestAsync() + SyncFunction3()*await TestAsync());

Jeśli zajrzymy do IlSpy, zobaczymy masę alokacji m.in:

Tuple<int, int> tuple = new Tuple<int, int>(expr_AA, expr_AB);

Tuple<int, int, int, int> tuple2 = new Tuple<int, int, int, int>(arg_162_0, arg_162_1, expr_139, expr_146);

“Prosty” przykład, może poskutkować wieloma alokacji. Z tego względu, czasami lepiej wywołania asynchroniczne uszeregować poza wyrażeniem tzn.:

int resultAsync = await TestAsync();
int result = SyncFunction1() + SyncFunction2()*resultAsync;

Jeśli możemy kilka metod asynchronicznych wykonać w tym samym czasie, wtedy dużo lepiej jest użyć Task.WhenAll.

Powyższy kod nie doprowadzi do stack spilling, a wyniki tymczasowe będą przechowywane w zwyczajnych zmiennych lokalnych. Skutek będzie taki, że maszyna stanów będzie posiadała wiele pól, ale jest to lepsze w wielu przypadkach niż wspomniane alokacje (czas + overhead). Z drugiej strony, pola w maszynie stanów będą w pamięci aż do jej zakończenia, co może być czasochłonne (programowanie asynchroniczne). Wartości ze sterty zawsze można zwolnić, np. podczas przechodzenia z jednego stanu w drugi.

IL Assembly: wywoływanie metod wirtualnych na typach generycznych

Dzisiaj kolejny wpis o generics internals. Wiemy już jak zostały one zaimplementowane w .NET i czym różni się generics z typem referencyjnym od value.  Kolejny etap to zapoznanie się z OpCodes.Constrained. Zobaczmy następujący kod:

class CustomStack<T>
{
   public void DoSomething(T value)
   {
       string str=value.ToString();
   }
}

Prosta metoda, przyjmująca typ generyczny. Dlaczego stanowi ona problem i wyzwanie dla twórców języka?

Jak wiemy, ToString to metoda zaimplementowana w klasie Object. Każdy zatem obiekt posiada ją. Wiemy również, że value types również dziedziczą po object (pośrednio). Nie ma zatem znaczenia czy mamy do czynienia z Integer czy z własną klasą – zawsze mamy do dyspozycji ToString. Oczywiście powoduje to problemy dla value type. Wywołując ToString powodujemy boxing, ponieważ należy skonwertować value type do Object, który jest typem referencyjnym.

Jak zatem zachowa się powyższa metoda? Na etapie kompilacji nie wiadomo czy ciało metody będzie operowało na value czy referencyjnym typie. Wiemy również, że ciało metody nie jest kopiowanie dla każdego typu generycznego a jest współdzielone – wyłącznie layout dla typów value jest różny, a sama logika (ciała metod) są zawsze współdzielone. Z tego względu, jak IL będzie wyglądać? Skąd CLR ma wiedzieć czy dokonać boxingu czy można ToString bezpośrednio wykonać? Zajrzyjmy do IL Assembly. Sama klasa będzie zaprezentowana następująco:

.class private auto ansi beforefieldinit ConsoleApplication16.CustomStack`1<T>
    extends [mscorlib]System.Object
{
    // Methods
    .method public hidebysig 
        instance void DoSomething (
            !T 'value'
        ) cil managed 
    {
        // Method begins at RVA 0x2094
        // Code size 16 (0x10)
        .maxstack 1
        .locals init (
            [0] string str
        )

        IL_0000: nop
        IL_0001: ldarga.s 'value'
        IL_0003: constrained. !T
        IL_0009: callvirt instance string [mscorlib]System.Object::ToString()
        IL_000e: stloc.0
        IL_000f: ret
    } // end of method CustomStack`1::DoSomething

    .method public hidebysig specialname rtspecialname 
        instance void .ctor () cil managed 
    {
        // Method begins at RVA 0x20b0
        // Code size 7 (0x7)
        .maxstack 8

        IL_0000: ldarg.0
        IL_0001: call instance void [mscorlib]System.Object::.ctor()
        IL_0006: ret
    } // end of method CustomStack`1::.ctor

} // end of class ConsoleApplication16.CustomStack`1

Z IL widać jeszcze raz, że typy generyczne to nie sztuczka kompilatora, ale rozpoznawana konstrukcja w CLR. Przejdźmy teraz do metody, która nas najbardziej interesuje:

// Code size 16 (0x10)
.maxstack 1
.locals init (
    [0] string str
)

IL_0000: nop
IL_0001: ldarga.s 'value'
IL_0003: constrained. !T
IL_0009: callvirt instance string [mscorlib]System.Object::ToString()
IL_000e: stloc.0
IL_000f: ret

IL_0003 to contrained. !T.  Gdy callvirt wywołuje jakaś metodę, i wcześniej mamy contrained to zostanie boxing wykonany jeśli mamy do czynienia z value type. Innymi słowy, dla typów referencyjnych nic nie zmieni się (nie ma takiej potrzeby) a dla value najpierw wartość zostanie poddana boxingu.  Oczywiście mowa tylko o metodach wirtualnych. Jeśli wykonujemy metodę na typu value, która jest w nim zaimplementowana, wtedy nie ma takiej potrzeby.

Typy generyczne w .NET–implementacja wewnętrzna

Typy generyczne w .NET to nie żadna imitacja, a prawdziwa implementacja (w przeciwieństwie do Java), zoptymalizowana pod wieloma kątami.

Typy referencyjne są zaimplementowane w inny sposób niż value types. Załóżmy, że mamy następujący kod w c#:

class Program
{
   static void Main(string[] args)
   {
       var s1 = new CustomStack<int>();
       var s2 = new CustomStack<double>();
   }
}

class CustomStack<T>
{
   public T Value { get; set; }
   public object OtherProperty { get; set; }
   
   public string AnotherProperty { get; set; }
}

W przypadku value type zostaną wygenerowane dwie różne specjalizacje dla CustomStack. Każda próba stworzenia instancji CustomStack z innym typem, spowoduje utworzenie nowej specjalizacji. Sprawa wygląda jednak inaczej dla typów referencyjnych. Rozszerzmy przykład o:

var s1 = new CustomStack<int>();
var s2 = new CustomStack<double>();

var s3 = new CustomStack<Person>();
var s4 = new CustomStack<Customer>();

Specjalizacje dla Person i Customer będą dzielić ze sobą layout. Nie ważne jak wiele stworzymy specjalizacji, zawsze będą współdzielić ten sam layout. W .NET nie grozi nam eksplozja typów, która miałaby miejsce, gdy nie powyższa optymalizacja.

Sprawdźmy jednak to sami za pomocą debuggera windbg.exe. Zaczynamy standardowo od załadowania SOS:

.load C:\Windows\Microsoft.NET\Framework\v4.0.30319\sos.dll

Następnie przyjrzyjmy się aktualnie załadowanym typom za pomocą:

!dumpheap -stat

Wynik:

image

Te co nas interesują  to:

003f3d08        1           20 ConsoleApplication16.CustomStack`1[[ConsoleApplication16.Customer, ConsoleApplication16]]
003f3c4c        1           20 ConsoleApplication16.CustomStack`1[[ConsoleApplication16.Person, ConsoleApplication16]]
003f399c        1           20 ConsoleApplication16.CustomStack`1[[System.Int32, mscorlib]]
003f3a80        1           24 ConsoleApplication16.CustomStack`1[[System.Double, mscorlib]]

Najpierw udowodnimy, że typy value nie współdzielą layoutu. Za pomocą powyższych adresów możemy wylistować method table używając następujących poleceń:

!dumpmt 003f3a80        
!dumpmt 003f399c               

Wynik:

// specjalizacja dla int
0:000> !dumpmt 003f399c        
EEClass:         003f1580
Module:          003f2ed4
Name:            ConsoleApplication16.CustomStack`1[[System.Int32, mscorlib]]
mdToken:         02000005
File:            C:\Users\Piotr\Documents\Visual Studio 2013\Projects\ConsoleApplication16\ConsoleApplication16\bin\Debug\ConsoleApplication16.exe
BaseSize:        0x14
ComponentSize:   0x0
Slots in VTable: 11
Number of IFaces in IFaceMap: 0

// specjalizacja dla double
0:000> !dumpmt 003f3a80        
EEClass:         003f15e0
Module:          003f2ed4
Name:            ConsoleApplication16.CustomStack`1[[System.Double, mscorlib]]
mdToken:         02000005
File:            C:\Users\Piotr\Documents\Visual Studio 2013\Projects\ConsoleApplication16\ConsoleApplication16\bin\Debug\ConsoleApplication16.exe
BaseSize:        0x18
ComponentSize:   0x0
Slots in VTable: 11
Number of IFaces in IFaceMap: 0

To co rzuca się w oczy to adres EEClass – jest inny dla int niż dla double. W przyszłości chcę napisać post o internalach typów, ale na razie wystarczy wiedzieć, że EEClass opisuje listę metod (layout) dla danego typu. Przyjrzyjmy się zatem EEClass dla specjalizacji double.

Polecenie:

!dumpclass 003f15e0

Wynik:

Class Name:      ConsoleApplication16.CustomStack`1[[System.Double, mscorlib]]
mdToken:         02000005
File:            C:\Users\Piotr\Documents\Visual Studio 2013\Projects\ConsoleApplication16\ConsoleApplication16\bin\Debug\ConsoleApplication16.exe
Parent Class:    73713f70
Module:          003f2ed4
Method Table:    003f3a80
Vtable Slots:    4
Total Method Slots:  b
Class Attributes:    100000  
Transparency:        Critical
NumInstanceFields:   3
NumStaticFields:     0
      MT    Field   Offset                 Type VT     Attr    Value Name
73b1b454  4000001        4        System.Double  1 instance           <Value>k__BackingField
73b22554  4000002        c        System.Object  0 instance           <OtherProperty>k__BackingField
73b221b4  4000003       10        System.String  0 instance           <AnotherProperty>k__BackingField

Wyraźnie widzimy, że layout zawiera pole double:

73b1b454  4000001        4        System.Double  1 instance           <Value>k__BackingField

EEClass dla specjalizacji integer, będzie zawierało analogiczny layout ale z innym typem dla Value:

      MT    Field   Offset                 Type VT     Attr    Value Name
73b23b04  4000001        c         System.Int32  1 instance           <Value>k__BackingField
73b22554  4000002        4        System.Object  0 instance           <OtherProperty>k__BackingField
73b221b4  4000003        8        System.String  0 instance           <AnotherProperty>k__BackingField

Powróćmy teraz do typów referencyjnych. Analogicznie za pomocą !dumpmt wyświetlamy method table dla nich:

// specjalizacja Customer
0:000> !dumpmt 003f3d08        
EEClass:         003f1694
Module:          003f2ed4
Name:            ConsoleApplication16.CustomStack`1[[ConsoleApplication16.Customer, ConsoleApplication16]]
mdToken:         02000005
File:            C:\Users\Piotr\Documents\Visual Studio 2013\Projects\ConsoleApplication16\ConsoleApplication16\bin\Debug\ConsoleApplication16.exe
BaseSize:        0x14
ComponentSize:   0x0
Slots in VTable: 11
Number of IFaces in IFaceMap: 0
// specjalizacja Person
0:000> !dumpmt 003f3c4c        
EEClass:         003f1694
Module:          003f2ed4
Name:            ConsoleApplication16.CustomStack`1[[ConsoleApplication16.Person, ConsoleApplication16]]
mdToken:         02000005
File:            C:\Users\Piotr\Documents\Visual Studio 2013\Projects\ConsoleApplication16\ConsoleApplication16\bin\Debug\ConsoleApplication16.exe
BaseSize:        0x14
ComponentSize:   0x0
Slots in VTable: 11
Number of IFaces in IFaceMap: 0

Od razu widać, że EEClass jest taki sam i znajduje się pod adresem 003f1694. Poleceniem !dumpclass 003f1694 wyświetlamy jej zawartość:

      MT    Field   Offset                 Type VT     Attr    Value Name
73b24d7c  4000001        4       System.__Canon  0 instance           <Value>k__BackingField
73b22554  4000002        8        System.Object  0 instance           <OtherProperty>k__BackingField
73b221b4  4000003        c        System.String  0 instance           <AnotherProperty>k__BackingField

Co widzimy? Przede wszystkim System.__Canon jako typ dla Value. Innymi słowy, dla typów referencyjnych jest używany ten sam layout i zamiast konkretnego typu mamy System.__Canon.

To jeszcze nie koniec- w kolejnym wpisie zobaczymy kilka ciekawostek z wywoływania metod w generics…

SpecFlow: konwersja argumentów

O SpecFlow pisałem już kilkakrotnie. Zwykłe w pliku ze scenariuszami, podajemy argumenty. Na przykład:

Scenario: Add two numbers
    Given I have entered 50 into the calculator
    And I have entered 70 into the calculator
    When I press add
    Then the result should be 120 on the screen

Następnie w pliku c# możemy użyć regex:

[Given(@"I have entered (.*) into the calculator")]
public void GivenIHaveEnteredIntoTheCalculator(int p0)
{
  ScenarioContext.Current.Pending();
}

Czasami jednak, mamy bardziej skomplikowane wyrażenia i za każdym razem regex po prostu byłby niewygodny. Na szczęście możemy napisać własny konwertery.

Załóżmy, że mamy scenariusz typu “Given it happened 50 days ago.”.  Chcemy liczbę 50 skonwertować do DateTime .AddDays(-50). SpecFlow umożliwia własne transformacje:

[Binding]
public class SpecFlowFeature1Steps
{
   [StepArgumentTransformation(@"(\d+) days ago")]
   public DateTime ToDateTime(int days)
   {
       return DateTime.Now.AddDays(-days);
   }
   [Given(@"it happened (.*)")]
   public void GivenItHappenedDaysAgo_(DateTime dateTime)
   {
       ScenarioContext.Current.Pending();
   }
}

Wystarczy metodę konwertującą oznaczyć atrybutem StepArgummentTransformation.

Czasami w pliku scenariusza umieszczamy tabele danych np.:

Given I entered data about the following employees:
    | FirstName | LastName  | Title             |
    | Piotr     | Zielinski | Software Engineer |
    | A         | B         | C                 |

Klasyczny sposób dostępu do takich danych wyglądałby następująco:

[Given(@"I entered data about the following employees:")]
public void GivenIEnteredDataAboutTheFollowingEmployees(Table table)
{
  foreach (TableRow row in table.Rows)
  {
      string firstName = row["FirstName"];
      string lastName = row["LastName"];
  }
}

Podejście niezbyt eleganckie – zwykle chcemy operować na prawdziwych obiektach. Prawdopodobnie gdzieś w systemie mamy następujący obiekt:

public class Employee
{
   public string FirstName { get; set; }
   public string LastName { get; set; }
   public string Title { get; set; }
}

Wtedy za pomocą CreateSet możemy:

[Given(@"I entered data about the following employees:")]
public void GivenIEnteredDataAboutTheFollowingEmployees(Table table)
{
  IEnumerable<Employee> employees = table.CreateSet<Employee>();
}

Konwersja jest dokonywana na podstawie nazw. Kolumna FirstName jest kojarzona z właściwością o takiej samej nazwie. Rozmiar liter jednak nie ma znaczenia i zarówno FIRSTNAME jak i firstname zostaną skojarzone z właściwością FirstName. Również nie musimy martwić się o spacje czyli “first name” w pliku ze scenariuszami zostanie prawidłowo zinterpretowane. Dobrze o tym wiedzieć ponieważ scenariusze powinny wyglądać jak normalne zdania po angielsku a nie kod.

W jednym z postów pisałem o podobnym framework’u, a mianowicie SpecsFor. Zawierał on m.in expectedObjects – bardzo przydatną bibliotekę w walidacji obiektów oraz kolekcji obiektów. Zamiast samemu porównywać właściwość po właściwości, wystarczyło, że wywołaliśmy jedną metodę. Analogiczną rzecz możemy zrobić z tabelami w SpecFlow:

var expectedEmployees=new Employee[2];
expectedEmployees[0] = new Employee() {FirstName = "Piotr"};

table.CompareToSet(expectedEmployees);

Powyższy kod wywoła następujący wyjątek:

An exception of type 'TechTalk.SpecFlow.Assist.ComparisonException' occurred in TechTalk.SpecFlow.dll but was not handled in user code

Additional information: 

The table and the set not match.

Istnieje również funkcja do porównywania pojedynczych instancji:

table.CompareToInstance(employee);

Code Review: Przekazywanie metody jako parametr

Ostatnio znalazłem fajny przykład pokazujący jak można nieoczekiwanie pogorszyć wydajność aplikacji. Załóżmy, że mamy metodę, która jako parametr wejściowy przyjmuje funkcję:

private static int Find(Predicate<int> filter)
{
  // jakas logika - nie wazne...

  if (filter(0))
      return 1;
  else
      return -1;
}

Nie zwracajmy uwagi na logikę i bezsensowne wartości. Chodzi mi o to, że przekazujemy jakiś wskaźnik na metodę, tutaj konkretnie jest to Predicate<int> np.:

private static bool CustomFilter(int value)
{
  return true;
}

Następnie w pętli, chcemy wykonać serie przeszukiwań na różnych danych:

for (int i = 0; i < 10000000; i++)
{
    Find(CustomFilter);
}

Powyższy kod wygląda niewinnie, jednak zajrzyjmy do IL Asm:

IL_0000: ldc.i4.0
IL_0001: stloc.0
IL_0002: br.s IL_001a
// loop start (head: IL_001a)
    IL_0004: ldnull
    IL_0005: ldftn bool ConsoleApplication15.Program::CustomFilter(int32)
    IL_000b: newobj instance void class [mscorlib]System.Predicate`1<int32>::.ctor(object, native int)
    IL_0010: call int32 ConsoleApplication15.Program::Find(class [mscorlib]System.Predicate`1<int32>)
    IL_0015: pop
    IL_0016: ldloc.0
    IL_0017: ldc.i4.1
    IL_0018: add
    IL_0019: stloc.0

    IL_001a: ldloc.0
    IL_001b: ldc.i4 10000000
    IL_0020: blt.s IL_0004
// end loop

Jak widzimy, w każdej iteracji, tuż przed wywołaniem Find jest tworzona nowa delegata:

IL_000b: newobj instance void class [mscorlib]System.Predicate`1<int32>::.ctor(object, native int)
IL_0010: call int32 ConsoleApplication15.Program::Find(class [mscorlib]System.Predicate`1<int32>)

Poprawne rozwiązanie to:

Predicate<int> customFilter = CustomFilter;

for (int i = 0; i < 10000000; i++)
{
 Find(customFilter);
}

IL:

IL_0000: ldnull
IL_0001: ldftn bool ConsoleApplication15.Program::CustomFilter(int32)
IL_0007: newobj instance void class [mscorlib]System.Predicate`1<int32>::.ctor(object, native int)
IL_000c: stloc.0
IL_000d: ldc.i4.0
IL_000e: stloc.1
IL_000f: br.s IL_001c
// loop start (head: IL_001c)
    IL_0011: ldloc.0
    IL_0012: call int32 ConsoleApplication15.Program::Find(class [mscorlib]System.Predicate`1<int32>)
    IL_0017: pop
    IL_0018: ldloc.1
    IL_0019: ldc.i4.1
    IL_001a: add
    IL_001b: stloc.1

    IL_001c: ldloc.1
    IL_001d: ldc.i4 10000000
    IL_0022: blt.s IL_0011
// end loop

W tym przypadku, nowy obiekt jest tworzony tylko raz – przed pętlą. Napiszmy również prosty benchmark:

class Program
{
   private static void Main(string[] args)
   {
       Stopwatch stopwatch = Stopwatch.StartNew();
       TestGoodSolution();
       Console.WriteLine(stopwatch.ElapsedTicks);

       stopwatch = Stopwatch.StartNew();
       TestBadSolution();
       Console.WriteLine(stopwatch.ElapsedTicks);
   }

   private static void TestBadSolution()
   {
       for (int i = 0; i < 10000000; i++)
       {
           Find(CustomFilter);
       }
   }

   private static void TestGoodSolution()
   {
       Predicate<int> customFilter = CustomFilter;

       for (int i = 0; i < 10000000; i++)
       {
           Find(customFilter);
       }
   }

   private static bool CustomFilter(int value)
   {
       return true;
   }

   private static int Find(Predicate<int> filter)
   {
       // jakas logika - nie wazne...

       if (filter(0))
           return 1;
       else
           return -1;
   }
}

Wynik:

image

Różnica spora a w rzeczywistości jest jeszcze większa. Pamiętajmy, że powyższy benchmark nie bierze pod uwagi Garbage Collector. Jeśli pętla jest wykonywana kilka tysięcy razey to również taka liczba obiektów zostanie zainicjalizowana i potem zwolniona przez GC, co może być bardzo kłopotliwe (promocja do następnej generacji itp.).

IL Assembly: IF vs. Switch, wydajność

Dzisiaj kolejny temat, który bardzo często budzi wątpliwości. Część programistów preferuje zawsze IF, część z kolei switch. Niektórzy argumentują wybór wydajnością.

Zawsze powinniśmy kierować się czytelnością konkretnego kodu. W poście pokażę jak wygląda sprawa wydajności poprzez oczywiście IL Assembly. Sprawa jest dość skomplikowana i wiele zależy od konkretnych wartości jakie mamy w switch.

Zacznijmy od porównania prostego IF\Switch operującego na liczbach całkowitych:

static string TestSwitch(int value)
{
  switch (value)
  {
      case 0:
          return "zero";
      case 1:
          return "one";
      case 2:
          return "two";
      default:
          return "unknown";
  }
}
static string TestIf(int value)
{
  if (value == 0)
  {
      return "zero";
  }
  else if (value == 1)
  {
      return "one";
  }
  else if (value == 2)
  {
      return "two";
  }
  else
  {
      return "unknown";
  }
}

Obie metody robią dokładnie to samo – w zależności od przekazanego interger’a zwrócą konkretny string. Spróbujmy napisać prosty benchmark:

const int n = 10000000;

Stopwatch stopwatch1 = Stopwatch.StartNew();
for (int i = 0; i < n; i++)
    TestSwitch(i);

Console.WriteLine(stopwatch1.ElapsedTicks);

Stopwatch stopwatch = Stopwatch.StartNew();
for (int i = 0; i < n; i++)
    TestIf(i);

Console.WriteLine(stopwatch.ElapsedTicks);

Wynik będzie następujący:

image

Oznacza to, że switch jest trochę szybszy niż IF. Różnica jest na tyle jednak spora, że wypada zadać pytanie dlaczego? IL posiada różny zestaw instrukcji dla switch i IF. IF jest uzyskiwany za pomocą instrukcji skoków o których pisałem już wcześniej (m.in. BR).

Zajrzyjmy najpierw do IL asm dla IF:

IL_0000: ldarg.0
IL_0001: brtrue.s IL_0009

IL_0003: ldstr "zero"
IL_0008: ret

IL_0009: ldarg.0
IL_000a: ldc.i4.1
IL_000b: bne.un.s IL_0013

IL_000d: ldstr "one"
IL_0012: ret

IL_0013: ldarg.0
IL_0014: ldc.i4.2
IL_0015: bne.un.s IL_001d

IL_0017: ldstr "two"
IL_001c: ret

IL_001d: ldstr "unknown"
IL_0022: ret

Nie ma tutaj nic nowego – wszystko jest osiągnięte za pomocą warunkowych instrukcji skoków. Przyjrzyjmy się teraz switch:

IL_0000: ldarg.0
IL_0001: stloc.0
IL_0002: ldloc.0
IL_0003: switch (IL_0016, IL_001c, IL_0022)

IL_0014: br.s IL_0028

IL_0016: ldstr "zero"
IL_001b: ret

IL_001c: ldstr "one"
IL_0021: ret

IL_0022: ldstr "two"
IL_0027: ret

IL_0028: ldstr "unknown"
IL_002d: ret

Mamy tutaj specjalną instrukcję – switch. Jako parametry przyjmuje ona adresy konkretnych gałęzi. Jak dokonywane jest porównanie? To bardzo ciekawa sprawa…

W switch widzimy wyłącznie adresy gałęzi a nie warunki. switch bierze wartość ze stosu i jeśli jest ona równa zero wtedy wykonuje pierwszą gałąź, jeśli jest ona równa jeden to druga gałąź jest wykonywana itd.  Innymi słowy, wszystkie warunki zaczynają się od zero. Co się zatem stanie, gdy nasza logika ma inne warunki? Zmodyfikujmy przykład do:

switch (value)
{
 case 10:
     return "10";
 case 11:
     return "11";
 case 12:
     return "12";
 default:
     return "unknown";
}

IL:

IL_0000: ldarg.0
IL_0001: stloc.0
IL_0002: ldloc.0
IL_0003: ldc.i4.s 10
IL_0005: sub
IL_0006: switch (IL_0019, IL_001f, IL_0025)

IL_0017: br.s IL_002b

IL_0019: ldstr "10"
IL_001e: ret

IL_001f: ldstr "11"
IL_0024: ret

IL_0025: ldstr "12"
IL_002a: ret

IL_002b: ldstr "unknown"
IL_0030: ret

Sam switch wygląda tak samo. Różnica polega na tym, że odejmujemy od wartości liczbę 10, tak aby pierwsza gałąź zaczynała się wciąż od zero:

IL_0000: ldarg.0
IL_0001: stloc.0
IL_0002: ldloc.0
IL_0003: ldc.i4.s 10
IL_0005: sub

Innymi słowy zamiast porównywać wartość z 10,11,12, odejmujemy 10 i porównujemy z 0,1,2 – efekt końcowy dokładnie taki sam.

Kolejne pytanie, to co jeśli mamy wartości nieuporządkowane np. 10,12,14:

switch (value)
{
 case 10:
     return "10";
 case 12:
     return "12";
 case 14:
     return "14";
 default:
     return "unknown";
}

IL_0000: ldarg.0
IL_0001: stloc.0
IL_0002: ldloc.0
IL_0003: ldc.i4.s 10
IL_0005: sub
IL_0006: switch (IL_0021, IL_0033, IL_0027, IL_0033, IL_002d)

IL_001f: br.s IL_0033

IL_0021: ldstr "10"
IL_0026: ret

IL_0027: ldstr "12"
IL_002c: ret

IL_002d: ldstr "14"
IL_0032: ret

IL_0033: ldstr "unknown"
IL_0038: ret
} // end of method Progra

Znów odejmujemy 10, aby podstawą była wartość zero. Nasze warunki to 10,12,14 zatem  mamy przerwę między kolejnymi warunkami. Pamiętamy również, że instrukcja switch przyjmuje, że pierwszy warunek to “zero”, drugi “jeden” itd. Wygenerowany switch to:

IL_0006: switch (IL_0021, IL_0033, IL_0027, IL_0033, IL_002d)

Po prostu, kompilator wypełnił lukę adresem IL_0033, który wskazuje na klauzule default. Co jeśli mamy większe przerwy np.:

switch (value)
{
 case 100:
     return "100";
 case 200:
     return "200";
 case 300:
     return "300";
 default:
     return "unknown";
}

Zapełnianie switch adresami defualt byłoby mało wydajne – oznaczałoby to ponad 200 duplikatów. Zaglądając do IL przekonamy się, że kompilator wie co robi:


IL_0000: ldarg.0
IL_0001: stloc.0
IL_0002: ldloc.0
IL_0003: ldc.i4.s 100
IL_0005: beq.s IL_0019

IL_0007: ldloc.0
IL_0008: ldc.i4 200
IL_000d: beq.s IL_001f

IL_000f: ldloc.0
IL_0010: ldc.i4 300
IL_0015: beq.s IL_0025

IL_0017: br.s IL_002b

IL_0019: ldstr "100"
IL_001e: ret

IL_001f: ldstr "200"
IL_0024: ret

IL_0025: ldstr "300"
IL_002a: ret

IL_002b: ldstr "unknown"
IL_0030: ret

Switch został zamieniony do klasycznego IF’a. Nie będzie więc żadnej różnicy w wydajności. Dla wartości, które są dosyć daleko od siebie, użycie switch’a oznacza dokładnie to samo co IF.

Kompilator jest jednak bardzo sprytny. Załóżmy, że mamy następujący IF:

switch (value)
{
    case 100:
        return "100";
    case 101:
        return "101";
    case 102:
        return "102";

    case 300:
        return "300";
    case 301:
        return "301";
    case 302:
        return "302";

    case 400:
        return "400";
    case 401:
        return "401";
    case 402:
        return "402";
    default:
        return "unknown";
}

Wartości od siebie różnią się skrajnie. Możemy jednak dostrzec pewne klastry. Kompilator wykryje to i stworzy trzy klastry oparte na switch:

IL_0000: ldarg.0
IL_0001: stloc.0
IL_0002: ldloc.0
IL_0003: ldc.i4.s 100
IL_0005: sub
IL_0006: switch (IL_0049, IL_004f, IL_0055)

IL_0017: ldloc.0
IL_0018: ldc.i4 300
IL_001d: sub
IL_001e: switch (IL_005b, IL_0061, IL_0067)

IL_002f: ldloc.0
IL_0030: ldc.i4 400
IL_0035: sub
IL_0036: switch (IL_006d, IL_0073, IL_0079)

IL_0047: br.s IL_007f

IL_0049: ldstr "100"
IL_004e: ret

IL_004f: ldstr "200"
IL_0054: ret

IL_0055: ldstr "200"
IL_005a: ret

IL_005b: ldstr "300"
IL_0060: ret

IL_0061: ldstr "300"
IL_0066: ret

IL_0067: ldstr "300"
IL_006c: ret

IL_006d: ldstr "300"
IL_0072: ret

IL_0073: ldstr "300"
IL_0078: ret

IL_0079: ldstr "300"
IL_007e: ret

IL_007f: ldstr "unknown"
IL_0084: ret

Nie musimy wypełniać tutaj żadnej luki w switch, bo każdy klaster jest spójny.

Enumy na switch działają tak samo jak integer’y bo tak naprawdę niczym nie różnią się (enum to integer).

Przetwarzanie string’ów to jednak zupełnie inna historia. Przykład:

switch (text)
{
 case "1":
     return 1;
 case "2":
     return 2;
 case "3":
     return 3;

 default:
     return -1;
}

Wygenerowany IL:

IL_0000: ldarg.0
IL_0001: dup
IL_0002: stloc.0
IL_0003: brfalse.s IL_0034

IL_0005: ldloc.0
IL_0006: ldstr "1"
IL_000b: call bool [mscorlib]System.String::op_Equality(string, string)
IL_0010: brtrue.s IL_002e

IL_0012: ldloc.0
IL_0013: ldstr "2"
IL_0018: call bool [mscorlib]System.String::op_Equality(string, string)
IL_001d: brtrue.s IL_0030

IL_001f: ldloc.0
IL_0020: ldstr "3"
IL_0025: call bool [mscorlib]System.String::op_Equality(string, string)
IL_002a: brtrue.s IL_0032

IL_002c: br.s IL_0034

IL_002e: ldc.i4.1
IL_002f: ret

IL_0030: ldc.i4.2
IL_0031: ret

IL_0032: ldc.i4.3
IL_0033: ret

IL_0034: ldc.i4.m1
IL_0035: ret

Jak widzimy, kod niczym nie różni się od IF’ow – wydajność będzie taka sama. Switch dla napisów został skonwertowany do IF’ow. Sprawa jednak będzie wyglądać trochę inaczej, jeśli dołożymy kilka nowych warunków:

switch (text)
{
 case "1":
     return 1;
 case "2":
     return 2;
 case "3":
     return 3;
 case "4":
     return 4;
 case "5":
     return 5;
 case "6":
     return 6;
 case "7":
     return 7;
 case "8":
     return 8;

 default:
     return -1;
}

IL:

IL_0000: ldarg.0
IL_0001: dup
IL_0002: stloc.0
IL_0003: brfalse IL_00c7

IL_0008: volatile.
IL_000a: ldsfld class [mscorlib]System.Collections.Generic.Dictionary`2<string, int32> '<PrivateImplementationDetails>{F94F7878-6940-44E5-A06D-C84605A56EC1}'::'$$method0x6000002-1'
IL_000f: brtrue.s IL_007e

IL_0011: ldc.i4.8
IL_0012: newobj instance void class [mscorlib]System.Collections.Generic.Dictionary`2<string, int32>::.ctor(int32)
IL_0017: dup
IL_0018: ldstr "1"
IL_001d: ldc.i4.0
IL_001e: call instance void class [mscorlib]System.Collections.Generic.Dictionary`2<string, int32>::Add(!0, !1)
IL_0023: dup
IL_0024: ldstr "2"
IL_0029: ldc.i4.1
IL_002a: call instance void class [mscorlib]System.Collections.Generic.Dictionary`2<string, int32>::Add(!0, !1)
IL_002f: dup
IL_0030: ldstr "3"
IL_0035: ldc.i4.2
IL_0036: call instance void class [mscorlib]System.Collections.Generic.Dictionary`2<string, int32>::Add(!0, !1)
IL_003b: dup
IL_003c: ldstr "4"
IL_0041: ldc.i4.3
IL_0042: call instance void class [mscorlib]System.Collections.Generic.Dictionary`2<string, int32>::Add(!0, !1)
IL_0047: dup
IL_0048: ldstr "5"
IL_004d: ldc.i4.4
IL_004e: call instance void class [mscorlib]System.Collections.Generic.Dictionary`2<string, int32>::Add(!0, !1)
IL_0053: dup
IL_0054: ldstr "6"
IL_0059: ldc.i4.5
IL_005a: call instance void class [mscorlib]System.Collections.Generic.Dictionary`2<string, int32>::Add(!0, !1)
IL_005f: dup
IL_0060: ldstr "7"
IL_0065: ldc.i4.6
IL_0066: call instance void class [mscorlib]System.Collections.Generic.Dictionary`2<string, int32>::Add(!0, !1)
IL_006b: dup
IL_006c: ldstr "8"
IL_0071: ldc.i4.7
IL_0072: call instance void class [mscorlib]System.Collections.Generic.Dictionary`2<string, int32>::Add(!0, !1)
IL_0077: volatile.
IL_0079: stsfld class [mscorlib]System.Collections.Generic.Dictionary`2<string, int32> '<PrivateImplementationDetails>{F94F7878-6940-44E5-A06D-C84605A56EC1}'::'$$method0x6000002-1'

IL_007e: volatile.
IL_0080: ldsfld class [mscorlib]System.Collections.Generic.Dictionary`2<string, int32> '<PrivateImplementationDetails>{F94F7878-6940-44E5-A06D-C84605A56EC1}'::'$$method0x6000002-1'
IL_0085: ldloc.0
IL_0086: ldloca.s CS$0$0001
IL_0088: call instance bool class [mscorlib]System.Collections.Generic.Dictionary`2<string, int32>::TryGetValue(!0, !1&)
IL_008d: brfalse.s IL_00c7

IL_008f: ldloc.1
IL_0090: switch (IL_00b7, IL_00b9, IL_00bb, IL_00bd, IL_00bf, IL_00c1, IL_00c3, IL_00c5)

IL_00b5: br.s IL_00c7

IL_00b7: ldc.i4.1
IL_00b8: ret

IL_00b9: ldc.i4.2
IL_00ba: ret

IL_00bb: ldc.i4.3
IL_00bc: ret

IL_00bd: ldc.i4.4
IL_00be: ret

IL_00bf: ldc.i4.5
IL_00c0: ret

IL_00c1: ldc.i4.6
IL_00c2: ret

IL_00c3: ldc.i4.7
IL_00c4: ret

IL_00c5: ldc.i4.8
IL_00c6: ret

IL_00c7: ldc.i4.m1
IL_00c8: ret

Jak widzimy, kod opiera swoje działanie na słowniku danych. Kluczem jest oczywiście warunek, a wartością indeks gałęzi. Następnie wywołujemy TryGetValue na słowniku, przekazując parametr wejściowy (string). Uzyskamy w ten sposób numer gałęzi. Potem wystarczy, że wywołamy switch (indeks gałęzi zaczyna się od zero) i jak w poprzednich przykładach, zwrócimy odpowiednie wartości:

IL_0090: switch (IL_00b7, IL_00ba, IL_00bc, IL_00be, IL_00c0, IL_00c2, IL_00c4, IL_00c6)

IL_00b5: br.s IL_00c8

IL_00b7: ldc.i4.s 1
IL_00b9: ret

IL_00ba: ldc.i4.2
IL_00bb: ret

IL_00bc: ldc.i4.3
IL_00bd: ret

IL_00be: ldc.i4.4
IL_00bf: ret

IL_00c0: ldc.i4.5
IL_00c1: ret

IL_00c2: ldc.i4.6
IL_00c3: ret

IL_00c4: ldc.i4.7
IL_00c5: ret

IL_00c6: ldc.i4.8
IL_00c7: ret

IL_00c8: ldc.i4.m1
IL_00c9: ret

Powyższy przykład można byłoby zoptymalizować ponieważ indeksy gałęzi pokrywają się z wartościami jakie chcemy zwrócić. W praktyce jednak te dwie rzeczy są kompletnie od siebie niezależne.

Na koniec pozostaje napisać kolejny benchmark, porównujący IF dla napisów oraz SWITCH, który wygeneruje wewnętrzny słownik:

class Program
{
   private static void Main(string[] args)
   {
       const int n = 10000000;

       Stopwatch stopwatch1 = Stopwatch.StartNew();
       for (int i = 0; i < n; i++)
           TestSwitch(i.ToString());
       Console.WriteLine(stopwatch1.ElapsedTicks);


       Stopwatch stopwatch = Stopwatch.StartNew();
       for (int i = 0; i < n; i++)
           TestIf(i.ToString());
       Console.WriteLine(stopwatch.ElapsedTicks);
   }
   static int TestSwitch(string text)
   {
       switch (text)
       {
           case "1":
               return 1;
           case "2":
               return 2;
           case "3":
               return 3;
           case "4":
               return 4;
           case "5":
               return 5;
           case "6":
               return 6;
           case "7":
               return 7;
           case "8":
               return 8;

           default:
               return -1;
       }
   }
   static int TestIf(string text)
   {
       if (text == "1")
       {
           return 1;
       }
       else if (text == "2")
       {
           return 2;
       }
       else if (text == "3")
       {
           return 3;
       }
       else if (text == "4")
       {
           return 4;
       }
       else if (text == "5")
       {
           return 5;
       }
       else if (text == "6")
       {
           return 6;
       }
       else if (text == "7")
       {
           return 7;
       }
       else if (text == "8")
       {
           return 8;
       }
       else
       {
           return -1;
       }
   }
}

Wynik:

image

Jak widać nie ma większej różnicy dla przedstawionych warunków. Jeśli mamy ich dużo więcej, wtedy i tak nie chcemy przecież korzystać z IF lub SWITCH. Przewaga SWITCH ma miejsce wyłącznie w bardzo specyficznych warunkach, w których wartości są bardzo blisko siebie (nie ma przerw). Klasycznym przykładem są ENUM’y, które zwykle zawierają sekwencyjne wartości.

IL assembly: foreach vs. for, wydajność

Dziś kolejny wpis na temat mikro-optymalizacji. Oczywiście dla większości aplikacji biznesowych taka różnica w wydajności nie ma kluczowego znaczenia. Myślę jednak, że jest to ciekawe z punktu widzenia IL i jak naprawdę działa język c#. Jeśli ktoś z kolei piszę np. grę albo aplikację czasu rzeczywistego, wtedy ma to już znaczenie, co robimy w każdej sekundzie.

Zacznijmy od razu od wniosku: foreach w niektórych przypadkach jest znacząco wolniejszy od klasycznego for. Nie powinno to dziwić – w końcu iterator dodaję kolejną warstwę abstrakcji.

Spójrzmy na poniższy program, który posłuży nam jako dowód:

internal class Program
{
   private static void Main(string[] args)
   {
       int[] data = Enumerable.Range(0, 1000000).ToArray();

       Stopwatch stopWatch1 = Stopwatch.StartNew();
       TestForeachWithArray(data);
       Console.WriteLine(stopWatch1.ElapsedTicks);

       Stopwatch stopWatch2 = Stopwatch.StartNew();
       TestForeachWithEnumerable(data);
       Console.WriteLine(stopWatch2.ElapsedTicks);

       Stopwatch stopWatch3 = Stopwatch.StartNew();
       TestForWithArray(data);
       Console.WriteLine(stopWatch3.ElapsedTicks);
   }

   private static void TestForeachWithArray(int[] data)
   {
       foreach (int item in data)
       {

       }
   }

   private static void TestForeachWithEnumerable(IEnumerable<int> data)
   {
       foreach (int item in data)
       {

       }
   }

   private static void TestForWithArray(int[] data)
   {
       for (int i = 0; i < data.Length; i++)
       {
           int item = data[i];
       }
   }
}

Wynik:

image

Program przedstawia 3 testy. Pierwszy korzysta z foreach, ale operuje na zwykłej tablicy danych. Drugi również jest oparty na foreach, ale przechodzi przez typ enumerable. Ostatni test korzysta ze zwykłej pętli for i tablicy danych. Wynik foreach+tablica jest bardzo podobny do for+tablica. Połączenie jednak foreach i enumerable jest dużo wolniejsze. Dzieje się to mimo faktu, że enumerable wskazuje tak naprawdę na tablicę więc teoretycznie nie powinno mieć to znaczenia.

Pozostaje wyjaśnić dlaczego wyłącznie foreach+enumerable jest taki wolny. Ostatnie wpisy były o IL assembly, stąd nie powinno budzić zaskoczenia, że ILSpy czy Reflector będzie pierwszym narzędziem jakim posłużymy się.

IL dla TestForeachWithArray:

IL_0000: ldarg.0
IL_0001: stloc.0
IL_0002: ldc.i4.0
IL_0003: stloc.1
IL_0004: br.s IL_000e
// loop start (head: IL_000e)
    IL_0006: ldloc.0
    IL_0007: ldloc.1
    IL_0008: ldelem.i4
    IL_0009: pop
    IL_000a: ldloc.1
    IL_000b: ldc.i4.1
    IL_000c: add
    IL_000d: stloc.1

    IL_000e: ldloc.1
    IL_000f: ldloc.0
    IL_0010: ldlen
    IL_0011: conv.i4
    IL_0012: blt.s IL_0006
// end loop

TestForWithArray:

IL_0000: ldc.i4.0
IL_0001: stloc.0
IL_0002: br.s IL_000c
// loop start (head: IL_000c)
    IL_0004: ldarg.0
    IL_0005: ldloc.0
    IL_0006: ldelem.i4
    IL_0007: pop
    IL_0008: ldloc.0
    IL_0009: ldc.i4.1
    IL_000a: add
    IL_000b: stloc.0

    IL_000c: ldloc.0
    IL_000d: ldarg.0
    IL_000e: ldlen
    IL_000f: conv.i4
    IL_0010: blt.s IL_0004

Zanim przejdziemy do TestForeachWithEnumerable, przeanalizujmy powyższe dwa fragmenty kodu. Wyglądają one praktycznie tak samo. Obydwa korzystają z instrukcji skoku blt. Tak naprawdę kompilacja (pierwsza faza dokonywana przez VS) zamieni foreach na zwykł for, jeśli operujemy na tablicy danych. Niestety taka konwersja jest niemożliwa, gdy korzystamy z enumerable, nawet jak wskazuje on na tablicę. IL dla TestForeachWithEnumerable wygląda bardzo skomplikowanie:

IL_0000: ldarg.0
IL_0001: callvirt instance class [mscorlib]System.Collections.Generic.IEnumerator`1<!0> class [mscorlib]System.Collections.Generic.IEnumerable`1<int32>::GetEnumerator()
IL_0006: stloc.0
.try
{
    IL_0007: br.s IL_0010
    // loop start (head: IL_0010)
        IL_0009: ldloc.0
        IL_000a: callvirt instance !0 class [mscorlib]System.Collections.Generic.IEnumerator`1<int32>::get_Current()
        IL_000f: pop

        IL_0010: ldloc.0
        IL_0011: callvirt instance bool [mscorlib]System.Collections.IEnumerator::MoveNext()
        IL_0016: brtrue.s IL_0009
    // end loop

    IL_0018: leave.s IL_0024
} // end .try
finally
{
    IL_001a: ldloc.0
    IL_001b: brfalse.s IL_0023

    IL_001d: ldloc.0
    IL_001e: callvirt instance void [mscorlib]System.IDisposable::Dispose()

    IL_0023: endfinally
} // end handler

Od razu widać, że foreach dodaje bardzo dużo komplikacji. Można zauważyć m.in. try-catch-finally oraz drogą instrukcję callvirt – wywołanie metody wirtualnej, co spowoduje przeszukanie vtable.

W aplikacjach biznesowych ważniejsza jest jednak łatwość w utrzymaniu, stąd zwykle posiadamy wiele warstw w systemie, aby operować na abstrakcji. W takich sytuacjach ważniejsze dla nas jest, aby np. odseparować sposób w jaki przeglądamy dane od nich samych. Nie są to podejścia najbardziej wydajne, ale nie stanowią problemu dla wspomnianych aplikacji. Inne podejście akurat mają programiści np. sterowników kart graficznych czy gier komputerowych. Tam po prostu są inne wyzwania i priorytety.