Modyfikator sealed dla klas–wydajność

O korzyściach z modyfikatora sealed, od strony projektowej pisałem już tutaj.  Dzisiaj postanowiłem jednak napisać prosty program, który pokaże nam czy faktycznie są jakieś różnice wydajnościowe. Oczywiście jest to raczej ciekawostka dla ludzi zajmujących się c# internals. Jeśli zależy nam na optymalizacji, zawsze zaczynajmy od ulepszenia samego algorytmu (zmniejszenia jego złożoności), a w ostateczności sięgajmy po mikro-optymalizacje. Warto stosować wspominane wskazówki, ale ze względu na dobre praktyki, a nie realny czas, który algorytm zyska dzięki nim.

Zacznijmy od sprawdzenia, czy słowo sealed jest rozpoznawane tylko na poziomie kompilatora, czy również IL:

class BaseClass { } sealed class ChildClass : BaseClass { }

IL:

.class private auto ansi sealed beforefieldinit ConsoleApplication3.ChildClass extends ConsoleApplication3.BaseClass { } // end of class ConsoleApplication3.ChildClass

Widzimy, że faktycznie sealed występuje w IL, więc jest szansa, że w jakiś sposób możemy zyskać na wydajności.

W swoim benchmark’u zadeklarowałem następujące metody:

class BaseClass { public virtual void VirtualMethod() { } public void BaseNonVirtualMethod() { } } sealed class ChildClass : BaseClass { public override void VirtualMethod() { base.VirtualMethod(); } public void ChildNotVirtualMethod() { } }

Innymi słowy, mamy wirtualną metodę, która potem jest rozszerzona w klasie potomnej, niewirtualną bazową metodę oraz niewirtualną metodę zadeklarowaną w klasie potomnej. Wiemy (ze wspomnianego wpisu), że jedynie kiedy możemy zyskać wydajność jest to wywołanie wirtualnej metody.  Wywołując VirtualMethod na klasie “zapieczętowanej”, kompilator mógłby pominąć sprawdzanie wirtualnej tabeli. Szkielet testu wygląda następująco:

const int n = 1000000; TestBaseNonVirtualMethod(n); TestChildNotVirtualMethod(n); TestVirtualMethod(n);

Następnie stworzyłem metodę, która zwróci średni czas wykonania danej funkcji:

private static double GetAverageTime(Action action,int n) { const int samplingCount = 100; double sum = 0; for (int i = 0; i < samplingCount; i++) { Stopwatch stopwatch = Stopwatch.StartNew(); for (int j = 0; j < n; j++) { action(); } sum += stopwatch.ElapsedTicks; } return sum / samplingCount; }

Na końcu metody, które wywołują zaimplementowane funkcje:

private static void TestVirtualMethod(int n) { ChildClass childClass = new ChildClass(); Console.WriteLine("TestVirtualMethod: {0}", GetAverageTime(() => childClass.VirtualMethod(), n)); } static void TestBaseNonVirtualMethod(int n) { BaseClass childClass = new ChildClass(); Console.WriteLine("TestVirtualMethod: {0}", GetAverageTime(() => childClass.BaseNonVirtualMethod(), n)); } static void TestChildNotVirtualMethod(int n) { ChildClass childClass = new ChildClass(); Console.WriteLine("TestChildNotVirtualMethod: {0}", GetAverageTime(() => childClass.ChildNotVirtualMethod(), n)); }

Proszę zwrócić uwagę na TestVirtualMethod. Korzystamy tam z instancji ChildClass, a nie BaseClass. W przypadku BaseClass wirtualna tabela musiałaby być wykorzystywana, nawet w przypadku “zapieczętowanej” klasy. Teoretycznie teraz, kompilator ma wystarczającą wiedzę, aby wyemitować call zamiast callvirt.

Wyniki prezentującą się następująco. Klasa sealed:

image

Zwykła (unsealed):

image

Przedstawione wyniki kompletnie nic nie mówią. Zaglądając do IL, tylko dowiemy się to samo, co w poprzednim wpisie czyli, że zawsze callvirt jest emitowany:

IL_0001: ldfld class ConsoleApplication3.ChildClass ConsoleApplication3.Program/'<>c__DisplayClass1'::childClass IL_0006: callvirt instance void ConsoleApplication3.BaseClass::VirtualMethod() IL_000b: nop IL_000c: ret

Wiemy, że zawsze dla instance-methods emitowany jest callvirt. Przypuszczenie było, że callvirt dla klas zapieczętowanych może ominąć etap sprawdzania vtable. Powyższy test nie potwierdza tego, ale może być to spowodowane wersją .NET\C#, dużo bardziej skomplikowanymi regułami, które nie są udokumentowane, implementacją specyficznego języka (C#, VB,CPP)  itp. Kiedyś pisałem o wydajności metod statycznych i metod wirtualnych. Bardzo szybko udało się uzyskać znaczące różnice w wydajności. O ile w przypadku metod wirtualnych, różnica między wywołaniem metody inline a zwykłą,  mogła mieć realny wpływ na wydajność, to w przypadku modyfikatora sealed, możemy to bez problemu zignorować.

Jednym z ważnych powodów, dlaczego c# używa callvirt nawet dla niewirtualnych metod, jest sprawdzenie czy instancja nie jest NULL’em. Na przykład, poniższy kod nie wyrzuciłby wyjątku, gdyby byłby wywołany za pomocą instrukcji call, a nie callvirt:

AnyClass instance = null; instance.AnyMethod(); //OK jeśli AnyMethod nie korzysta z this i jest wywolana za pomoca call.

Tutaj kolejna ciekawostka odnośnie sealed. Załóżmy, że mamy:

new ChildClass().VirtualMethod();

W takim przypadku, mamy pewność, że nigdy nie będziemy mieli wartości NULL. Dla wirtualnej metody, wciąż niestety zostanie wyemitowany callvirt:

IL_0000: newobj instance void ConsoleApplication3.ChildClass::.ctor() IL_0005: callvirt instance void ConsoleApplication3.BaseClass::VirtualMethod()

Zmieńmy naszą deklaracje na method hiding:

class BaseClass { public virtual void VirtualMethod() { } } sealed class ChildClass : BaseClass { public new void Method() { } }

Wtedy ten sam kod, tzn:

new ChildClass().Method();

Wyemituje call, zamiast callvirt!:

IL_0000: newobj instance void ConsoleApplication3.ChildClass::.ctor() IL_0005: call instance void ConsoleApplication3.ChildClass::Method()

Jeśli tylko rozdzielimy inicjalizacje z wywołaniem:

ChildClass childClass=new ChildClass(); childClass.Method();

Kompilator wyemituje znów callvirt, ponieważ nie ma pewności czy instancja nie jest NULL:

IL_0000: newobj instance void ConsoleApplication3.ChildClass::.ctor() IL_0005: stloc.0 IL_0006: ldloc.0 IL_0007: callvirt instance void ConsoleApplication3.ChildClass::Method() IL_000c: ret

Spróbujmy jeszcze sprawdzić, co się stanie, gdy mamy instrukcję warunkową, teoretycznie gwarantującą, że wartość nigdy nie będzie NULL:

ChildClass childClass=new ChildClass(); if(childClass!=null) childClass.Method();

Kompilator tego nie rozpozna i wciąż będziemy mieli callvirt:

IL_0000: newobj instance void ConsoleApplication3.ChildClass::.ctor() IL_0005: stloc.0 IL_0006: ldloc.0 IL_0007: brfalse.s IL_000f IL_0009: ldloc.0 IL_000a: callvirt instance void ConsoleApplication3.ChildClass::Method()

Nic dziwnego, byłoby to zbyt ryzykowne przypuszczenie, biorąc pod uwagę np. wielowątkowość.

Takich przykładów można mnożyć, ale nie ma to według mnie sensu. Reguły dobierania callvirt, a call dla zapieczętowanych klas są skomplikowane i mogą w każdej chwili ulec zmianie – są to oczywiście sprawy czysto implementacje i w każdej aktualizacji framework’u, może to zmienić się. Wniosek taki, że aspekt wydajności kompletnie pomijamy i wiemy, że generalnie kompilator preferuje callvirt dla instance-method, call jest wykorzystywany wyłącznie w metodach statycznych oraz bardzo rzadko (wyłącznie w bardzo specyficznych sytuacjach), gdy korzystamy z sealed.

SpecFlow – zdarzenia

Kiedyś w jednym z komentarzu ktoś zasugerował, aby opisać również zdarzenia (hooks) w specflow. Wszystko można znaleźć w dokumentacji, ale również preferuję kilka zwięzłych przykładów niż suchy opis.

Tak jak w nUnit czy jakimkolwiek innym framework’u, możemy definiować co powinno wykonać się przed lub po testach. Atrybutami BeforeTestRun oraz AfterTestRun dekorujemy metody, które mają się wykonać przed i po wszystkich testach:

[Binding] public class Hooks { [BeforeTestRun] public static void BeforeTestRun() { } [AfterTestRun] public static void AfterTestRun() { } }

Atrybut Binding jest niezbędny, gdy definiujemy zdarzenia w osobnej klasie. W przypadku BeforeTestRun ma to sens, ponieważ nie są one specyficzne dla żadnego zestawu testów. Jeśli mamy 10 scenariuszy, BeforeTestRun oraz AfterTestRun wykonają się tylko raz – przed i po wykonaniu wszystkich testów (a nie po każdym scenariuszu osobno). Metoda powinna być również statyczna.

Jeśli mamy logikę, która powinna być wykonana dla każdego scenariusza osobno, wtedy korzystamy z BeforeScenario oraz AfterScenario:

[Binding] public class Hooks { [BeforeFeature] public static void BeforeFeature() { } [AfterFeature()] public static void AfterFeature() { } }

Dla 10 scenariuszy,  metody zatem zostaną wykonane również po 10 razy. Z poprzedniego posta, pamiętamy, że mamy do dyspozycji jeszcze tagi:

@myFirstTag Feature: EditPost Jakis opis tutaj... Scenario: Edit a post Given I have logged into CMS When I press the edit a post button Then article should be updated.

W takim scenariuszu, możliwe jest powiązanie hook’a ze specyficznym tagiem:

[BeforeFeature("myFirstTag")] public static void BeforeFeature() { }

Parametr to lista tagów więc można stworzyć jeden hook dla kilku scenariuszy jednocześnie.

Before i After to aliasy dla BeforeScenario oraz AfterScenario. Moim zdaniem jednak, jeśli ktoś nie jest obeznany z SpecFlow, wtedy nazwa Before może być mało dokładna. Tak czy inaczej, odpowiednik powyższego kodu to:

[Binding] public class Hooks { [Before("myFirstTag")] public static void BeforeFeature() { } [After()] public static void AfterFeature() { } }

Jeśli chcemy wykonać kod pomiędzy Given, When, Then wtedy:

[Binding] public class Hooks { [BeforeScenarioBlock] public static void BeforeScenarioBlock() { } [AfterScenarioBlock] public static void AfterScenarioBlock() { } }

Atrybuty również wspierają tagi. Nazwa Block może być trochę myląca, ale chodzi tutaj, jak wspomniałem o GWT.

Jeśli potrzebujemy bardziej szczegółowe zdarzenia, możemy:

[Binding] public class Hooks { [BeforeStep] public static void BeforeStep() { } [AfterStep] public static void AfterStep() { } }

Pozostaje wyjaśnić czym różni się krok od bloku.  Załóżmy, że mamy zaprezentowany wcześniej test:

@myFirstTag Feature: EditPost Jakis opis tutaj... Scenario: Edit a post Given I have logged into CMS When I press the edit a post button Then article should be updated.

W tym przypadku, blok i krok stanowią to samo i można to byłoby rozdzielić w następujący sposób:

[Blok 1, Krok 1] Given I have logged into CMS

[Blok 2, Krok 2] When I press the edit a post button

[Blok 3, Krok 3] Then article should be updated.

Różnica jest, gdy mamy bloki złożone z And, tzn.:

Feature: EditPost ... Scenario: Edit a post Given I have logged into CMS And I have enough permissions When I press the edit a post button Then article should be updated and afafa

Given składa się z dwóch kroków, zatem można byłoby to rozpisać:

[Blok 1, Krok 1] Given I have logged into CMS

[Krok 2] And I have enough permissions

[Blok 2, Krok 3] When I press the edit a post button

[Blok 3, Krok 4] Then article should be updated and afafa   

Specflow – kroki o tej samej nazwie ale z inną implementacją

W poprzednim poście, pisałem jak są współdzielone kroki między różnymi scenariuszami. Jeśli tylko zostaną one rozdzielone odpowiednio między klasy, zestaw testów stanie się po prostu łatwiejszy w utrzymaniu. Dzięki temu nie musimy duplikować kodu. Oczywiście pokazałem również jak nie należy pisać testów ponieważ stosunkowo łatwo w SpecFlow można “rozsynchronizować” plik z opisem scenariuszu z klasami implementującymi kolejne kroki.

Przede wszystkim jeśli jakiś krok ma identyczny opis w dwóch scenariuszach, naturalne jest, że domyślna implementacja jest współdzielona. Jeśli oczekujemy kompletnie innej implementacji, wtedy być może powinniśmy zmienić opis tego kroku. W praktyce jednak zdarzają się sytuacje, kiedy np. Given jest taki sam, ale chcemy napisać kompletnie inny kod.  Załóżmy, że mamy scenariusze z poprzedniego postu:

Feature: NewPost Jakis opis tutaj... Scenario: Create a new post Given I have logged into CMS When I press the create a post button Then article should be created.

Feature: EditPost Jakis opis tutaj... Scenario: Edit a post Given I have logged into CMS When I press the edit a post button Then article should be updated.

Jak wspomniałem wcześniej, chcemy dostarczyć dwie różne implementacje dla given. Domyślnie, zostaną one współdzielone. W SpecFlow możemy skorzystać z atrybutu Scope. Spróbujmy najpierw wstawić ręcznie given dla powyższych dwóch scenariuszy:

[Binding] public class EditPostSteps { [Given(@"I have logged into CMS")] public void GivenIHaveLoggedIntoCMS() { } [When(@"I press the edit a post button")] public void WhenIPressTheEditAPostButton() { } [Then(@"article should be updated\.")] public void ThenArticleShouldBeUpdated_() { ScenarioContext.Current.Pending(); } }

[Binding] public class NewPostSteps { [Given(@"I have logged into CMS")] public void GivenIHaveLoggedIntoCMS() { } [When(@"I press the create a post button")] public void WhenIPressTheCreateAPostButton() { ScenarioContext.Current.Pending(); } [Then(@"article should be created\.")] public void ThenArticleShouldBeCreated_() { ScenarioContext.Current.Pending(); } }

Po uruchomieniu kodu dostaniemy wyjątek:

Additional information: Ambiguous step definitions found for step 'Given I have logged into CMS': EditPostSteps.GivenIHaveLoggedIntoCMS(), NewPostSteps.GivenIHaveLoggedIntoCMS()

Nic dziwnego, kroki (wiązania) jak wiemy są globalne.  Za pomocą atrybutu scope możemy zawęzić  nasze poszukiwania:

[Given(@"I have logged into CMS")] [Scope(Feature = "EditPost")] public void GivenIHaveLoggedIntoCMS() { }

W tej chwili wszystko skompiluje się bez problemów ponieważ powyższy Given jest przeznaczony wyłącznie dla scenariuszy EditPost. Atrybut nie ogranicza się wyłącznie do nazwy feature:

[Scope(Tag = "mytag", Feature = "feature title", Scenario = "scenario title")]

Title to tytuł scenariusza:

[Given(@"I have logged into CMS")] [Scope(Feature = "Edit a post")] public void GivenIHaveLoggedIntoCMS() { }

Możliwa jest również identyfikacja za pomocą samego tagu:

[Given(@"I have logged into CMS")] [Scope(Tag = "myFirstTag")] public void GivenIHaveLoggedIntoCMS() { }

Musimy również zaktualizować feature file, dodając tag:

@myFirstTag Feature: EditPost Jakis opis tutaj... Scenario: Edit a post Given I have logged into CMS When I press the edit a post button Then article should be updated.

Code Review: Kilka pułapek ze SpecFlow

Specflow jest według mnie znakomitym framework’iem do pisania testów BDD. Bardzo często dodawanie nowych scenariuszy sprowadza się do przekopiowania przypadków użycia z user story. Oczywiście wymaga to trochę praktyki, ponieważ definiowanie za pomocą GWT na początku może wydawać się nienaturalne.

Na blogu o podstawach SpecFlow pisałem już wiele razy. Dzisiaj chciałbym pokazać pewną pułapkę, na którą w przypadku wielu scenariuszy trzeba uważać. Wiele osób narzeka, że praca ze SpecFlow jest trudna dla skomplikowanych projektów. Moim zdaniem również łatwo popełnić błędy i po pewnym czasie zestaw testów jest trudny w utrzymaniu.

Załóżmy, że tworzymy następujący plik ze scenariuszem (proszę nie zwracać uwagi na sens i opis testu Uśmiech):

Feature: NewPost Jakis opis tutaj... Scenario: Create a new post Given I have logged into CMS When I press the create a post button Then article should be created.

Wygenerowane kroki testu, będą wyglądać tak:

[Binding] public class NewPostSteps { [Given(@"I have logged into CMS")] public void GivenIHaveLoggedIntoCMS() { ScenarioContext.Current.Pending(); } [When(@"I press the create a post button")] public void WhenIPressTheCreateAPostButton() { ScenarioContext.Current.Pending(); } [Then(@"article should be created\.")] public void ThenArticleShouldBeCreated_() { ScenarioContext.Current.Pending(); } }

Wszystko fajnie, mamy trzy wygenerowane metody dla GWT. Następnie, być może po kilku miesiącach, dodajemy analogiczny scenariusz, ale dla edycji postów:

Feature: EditPost Jakis opis tutaj... Scenario: Edit a post Given I have logged into CMS When I press the edit a post button Then article should be updated.

Po wygenerowaniu kroków dostaniemy:

[Binding] public class EditPostSteps { [When(@"I press the edit a post button")] public void WhenIPressTheEditAPostButton() { ScenarioContext.Current.Pending(); } [Then(@"article should be updated\.")] public void ThenArticleShouldBeUpdated_() { ScenarioContext.Current.Pending(); } }

Na początku może wydawać się to dziwne. Nie został wygenerowany krok Given. W Specflow kroki są globalne i w naszym przypadku given będzie współdzielony. Zwróćmy uwagę, że “Given I have logged into CMS” jest takie same dla obydwu testów. Specflow automatycznie wykryje to i nie będzie generował duplikatu w EditPostSteps. Ma to sens, jeśli faktycznie krok jest taki sam. Często kroki o takiej samej nazwie, mają kompletnie inny kod, w zależności od kontekstu. Z tego względu należy być uważnym i mieć świadomość tego.

W naszym przypadku, ma sens współdzielenie given, bo inicjalizacja będzie taka sama. Niestety,  aktualny kod jest brzydki, ponieważ Given dla EditPostSteps, znajduje się w NewPostSteps. Z tego względu lepiej przenieść to do osobnej klasy, np. CmsSteps:

[Binding] public class CmsSteps { [Given(@"I have logged into CMS")] public void GivenIHaveLoggedIntoCMS() { } }

Rozwiązanie jest od razu czytelniejsze. CmsSteps może zawierać kroki współdzielone przez inne scenariusze. Wcześniejsza sytuacja, gdzie jeden plik scenariusza zawierał kroki dla innych był zdecydowanie nieelegancki.

Pozostaje jednak kolejna zagadka, jak współdzielić stan między różnymi klasami. W końcu Given coś inicjalizuje, co potem powinno być dostępne w EditPostSteps oraz NewPostSteps.

Najprostszym rozwiązaniem jest użycie ScenarioContext:

[Binding] public class CmsSteps { [Given(@"I have logged into CMS")] public void GivenIHaveLoggedIntoCMS() { ScenarioContext.Current["text"] = "logged in"; } } [Binding] public class EditPostSteps { [When(@"I press the edit a post button")] public void WhenIPressTheEditAPostButton() { string text = ScenarioContext.Current["text"].ToString(); } [Then(@"article should be updated\.")] public void ThenArticleShouldBeUpdated_() { ScenarioContext.Current.Pending(); } }

Niestety powstałe w taki sposób testy będą wcześniej czy później trudne w utrzymaniu. ScenarioContext to globalny kontener i na dodatek, dane przekazuje się za pomocą słownika (“text”). Zmiana w jednym miejscu, spowoduje problem w innym. Z tego względu zawsze lepiej tworzyć wrappery. Może mieć to formę następującej klasy:

public class CmsLoginPage { public string Login { get { return ScenarioContext.Current["Login"].ToString(); } set { ScenarioContext.Current["Login"] = value; } } public string Password { get { return ScenarioContext.Current["Password"].ToString(); } set { ScenarioContext.Current["Password"] = value; } } }

Odradzam bezpośrednie modyfikowanie kontekstu w krokach testu. Zawsze lepiej tworzyć właściwość, która opakowuje operacje na słowniku.

Mój preferowany sposób to dependency injection. Najpierw tworzę klasę z informacjami, które chce przekazać z jednego kroku (klasy) do drugiej:

public class CmsLoginPage { public string Login { get; set; } public string Password { get; set; } }

Następnie za pomocą konstruktora przekazuje referencję:

[Binding] public class CmsSteps { private readonly CmsLoginPage _loginPage; public CmsSteps(CmsLoginPage loginPage) { _loginPage = loginPage; } [Given(@"I have logged into CMS")] public void GivenIHaveLoggedIntoCMS() { _loginPage.Login = "piotr"; _loginPage.Password = "password"; } }

SpecFlow już o to zadba, aby została ona przekazana do jakichkolwiek innych klas:

[Binding] public class EditPostSteps { private readonly CmsLoginPage _loginPage; public EditPostSteps(CmsLoginPage loginPage) { _loginPage = loginPage; } [When(@"I press the edit a post button")] public void WhenIPressTheEditAPostButton() { string login = _loginPage.Login; string passwsord = _loginPage.Password; } [Then(@"article should be updated\.")] public void ThenArticleShouldBeUpdated_() { ScenarioContext.Current.Pending(); } }

W kolejnych postach mam zamiar również poruszać temat SpecFlow. Oczywiście jeśli wymagania nie są przygotowywane przez osoby z biznesu (np. przez PO), to nie docenimy prawdziwej przydatności framework’u. W takich przypadkach warto również rozważyć narzędzia typu SpecFor, które często są preferowane, gdy to programiści (bez udziału osób nietechnicznych) piszą scenariusze.

Visual Studio 2015 – debugowanie wyrażeń Lambda

Kolejne, drobne ulepszenie w VS 2015, to możliwość debugowania wyrażeń lambda.  Załóżmy, że mamy następujący kod:

int[] numbers = new int[] { 3, 5, 1, 10, 15, 2, 5, 10 }; var results = numbers.Where(x => x % 2 == 0);

Jeśli odpalimy powyższy kod w VS 2013 (lub w starszej wersji) niemożliwe byłoby debugowanie lambda. W okienku watch następująca wiadomość zostałby wyświetlona:

image

Analogicznie sprawa wygląda z Immediate Window:

image

Z kolei VS 2015, bez problemu ujrzyjmy końcowy wynik:

image

image

Bardzo drobna zmiana, ale wiele razy ta niedogodność utrudniała debugowanie. Mam na myśli sytuacje, kiedy warunek jest bardziej skomplikowany niż proste modulo 2. Wciąż są pewnie ograniczenia m.in. brak Mark Object ID czy debugowanie wywołań do kodu natywnego.

Microsoft Immutable Collections: Modyfikacja kolekcji

Tak samo, jak w .NET istnieje klasa StringBuilder za pomocą, której można modyfikować “string” (nie dosłownie), tak w MS immutable collections zaimplementowano analogiczny builder.

Przykład:

var immutableList = ImmutableList<int>.Empty; immutableList = immutableList.Add(1); Debug.Assert(immutableList.Count==1); ImmutableList<int>.Builder builder = immutableList.ToBuilder(); Debug.Assert(builder.Count==1);

Za pomocą ToBuilder() na liście tworzymy builder, który jest kopią danej kolekcji. Następnie jakiekolwiek operacje wykonywane na obiekcie builder, nie będą modyfikowały bazowej listy (tutaj zmienna o nazwie “immutableList”):

var immutableList = ImmutableList<int>.Empty; immutableList = immutableList.Add(1); Debug.Assert(immutableList.Count==1); ImmutableList<int>.Builder builder = immutableList.ToBuilder(); Debug.Assert(builder.Count==1); builder.Add(2); builder.Add(3); Debug.Assert(builder.Count == 3); var newImmutable = builder.ToImmutable(); Debug.Assert(newImmutable.Count == 3); Debug.Assert(immutableList.Count == 1);

Dodałem asercje dla pokazania co jest modyfikowane. Widać wyraźnie, że ToBuilder() tworzy nową wewnętrzna listę, tak jak i ToImmutable. Oczywiście wewnętrzna implementacja, z tego co mi wiadomo, jest dosyć sprytna i nie zawsze realokowane są obiekty. Utrzymywana jest lista albo drzewo i tyko w koniecznych przypadkach obiekty są kopiowane. Autorzy starali się ograniczyć te operacje, aby wydajność nie ucierpiała znacząco w stosunku do klasycznych kolekcji

Microsoft Immutable Collections: Wydajność

W poprzednim wpisie obiecywałem, porównać wydajność kolekcji immutable z klasycznymi typami z System.Collections.Generic. W dzisiejszym benchmarku, porównamy zarówno zużycie pamięci, jak i czas potrzebny na dodanie węzłów.

Zacznijmy od klasycznych (zmiennych) typów:

var list = new List<int>(); const int n = 100000; long beforeAvailableMemory = GC.GetTotalMemory(true); var stopwatch = Stopwatch.StartNew(); for (int i = 0; i < n; i++) { list.Add(i); } Console.WriteLine("Mutable, czas: {0}", stopwatch.ElapsedTicks); long afterAvailableMemory = GC.GetTotalMemory(false); Console.WriteLine("Mutable, pamiec: {0}", afterAvailableMemory - beforeAvailableMemory);

Wynik:

image

Następnie Microsoft immutable:

var immutableList = ImmutableList<int>.Empty; const int n = 100000; long beforeAvailableMemory = GC.GetTotalMemory(true); var stopwatch = Stopwatch.StartNew(); for (int i = 0; i < n; i++) { immutableList = immutableList.Add(i); } Console.WriteLine("Immutable, czas: {0}", stopwatch.ElapsedTicks); long afterAvailableMemory = GC.GetTotalMemory(false); Console.WriteLine("Immutable, pamiec: {0}", afterAvailableMemory - beforeAvailableMemory);

Wynik:

image

Różnica w pamięci jest tak naprawdę mniejsza niż można byłoby spodziewać się. Niestety czas wykonania jest wielokrotnie dłuższy. Oczywiście podany przykład zastosowania niezmiennych kolekcji, jest skrajnym anty-wzorcem, który pokazuje jak bardzo wydajność może spaść. W przyszłym wpisie, pokażę buildery, które umożliwiają tymczasową modyfikacje niezmiennych kolekcji – analogicznie do StringBuilder dla String.

Microsoft Immutable Collections

Kolekcje z System.Collections.Generic są wszystkim dobrze znane. Czasami jednak zachodzi potrzeba skorzystania z typów immutable.  Ogólnie o tych obiektach pisałem tutaj.  Szczególnie w środowisku wielowątkowym są one przydatne. Jak można przeczytać we wspomnianym poście, obiekty takie nigdy nie mogą zostać zmienione a modyfikowanie stanu polega na tworzeniu nowej instancji.

Ktoś mógłby zasugerować, że mamy w końcu ReadOnlyCollection. Niestety, interfejs uniemożliwia modyfikacje tej kolekcji wyłącznie jego użytkownikowi. Załóżmy, że mamy metodę:

private void DoSomething (IReadOnlyCollection<int> list) { }

Co prawda w DoSomething nie możemy zmodyfikować danych i interfejs jasno o tym mówi, ale nic nie stoi na przeszkodzie, aby ktoś z zewnątrz dokonywał zmian na kolekcji. Jeśli byłoby to kolekcja immutable, mielibyśmy pewność, że nikt jej w międzyczasie (np. z osobnego wątku) nie zmodyfikuje.

Aby zacząć zabawę, musimy najpierw zainstalować zewnętrzny pakiet:

image

 

Do dyspozycji będziemy mieli wiele typów:

– ImmutableStack<T>

– ImmutableQueue<T>

– ImmutableList<T>

– ImmutableHashSet<T>

– ImmutableSortedSet<T>

– ImmutableDictionary<K, V>

– ImmutableSortedDictionary<K, V>

Wszystkie one znajdują się w System.Collections.Immutable;. Sprawdźmy, jak działa najpopularniejsza z nich, ImmutableList.

Jeśli spróbujemy obiekt zainicjalizować w standardowy sposób, zakończy to się błędem:

ImmutableList<int> immutableList = new ImmutableList<int ();

Po prostu, konstruktor jest prywatny. Dlaczego? Obiekty niezmienne inicjalizuje się w następujący sposób:

ImmutableList<int> immutableList = ImmutableList<int>.Empty;

Może wydawać to się dziwne, ale jaki jest sens inicjalizowania pustego obiektu, jeśli żadna operacja na nim nie zostanie wykonana? Wszystkie operacje typu Add, Remove tworzą zawsze nową kopię. Zatem ten pierwszy, bazowy może być wspólny bo nie ma to znaczenia, a zaoszczędzi pamięć.

Napiszmy kawałek kodu, aby przekonać się, że zawsze nowa instancja jest zwracana:

ImmutableList<int> immutableList = ImmutableList<int>.Empty; ImmutableList<int> list1= immutableList.Add(1); ImmutableList<int> list2 = list1.Add(12); Debug.Assert(immutableList.Count==0); Debug.Assert(list1.Count == 1); Debug.Assert(list2.Count == 2);

Z tego wynika, że jeśli chcemy dodać serie wartości, wtedy osobne wywołania Add nie są najlepszym wyborem, ponieważ spowodują relokacje zasobów. Innymi słowy, poniższy kod jest bardzo brzydki:

ImmutableList<int> immutableList = ImmutableList<int>.Empty; immutableList=immutableList.Add(1); immutableList=immutableList.Add(2); immutableList=immutableList.Add(3);

Z tego względu, dużo lepiej jest:

ImmutableList<int> immutableList = ImmutableList<int>.Empty; immutableList.AddRange(new []{1,2,3,4});

Alternatywą również jest użycie Fluent API:

ImmutableList<int> immutableList = ImmutableList<int>.Empty; immutableList.Add(3).Add(1).Add(5);

O tym jednak, więcej w następnym wpisie. Planuje również zrobić test wydajności między różnymi kolekcjami, aby pokazać kiedy nie należy stosować przedstawionych kolekcji.

Visual Studio 2015: Analiza jakości kodu

VS 2015 usprawnił, moim zdaniem znaczącą sposób analizy kodu. Sam sposób podpowiedzi również został poprawiony i coraz bliżej im do Resharper, a w pewnych sprawach, jak zobaczymy, daje potencjalnie większe możliwości.

Zacznijmy od przykładu pokazanego w prezentacji VS 2015. Załóżmy, że chcemy zaimplementować interfejs ISerializable:

class Sample :ISerializable
{
}

Jeśli tylko namespace zawierający ISerializable nie został dołączony, naciskamy ctrl+. i wyświetli się następujące menu:

image

Nowością jest tutaj podgląd tego, co zostanie zmienione (w tym przypadku dołączenie przestrzeni za pomocą using).

Kolejnym etapem będzie implementacja metod wymaganych przez interfejs. Podgląd będzie wtedy wyglądać następująco:

image

W tej chwili mamy następujący kod:

class Sample : ISerializable
{
   public void GetObjectData(SerializationInfo info, StreamingContext context)
   {
       throw new NotImplementedException();
   }
}

Wspomniałem na początku o nowym mechanizmie analizy kodu. W VS 2015 będzie to rozszerzalne i każdy taki analizator można zainstalować w formie pakietu NuGet. Daje to ogromne możliwości. Zainstalujmy więc jeden z nich:

image

Zaglądając do References->Analysers zobaczymy listę reguł:

image

Wracając do naszego przykładu. Po instalacji powyższego pakietu, zostaniemy poinformowani, że powinniśmy dodać atrybut [Serializable] do stworzonej klasy.

image

Widzimy również numer reguły (CA2237).

Dostępny jest już teraz pakiet reguł dla aplikacji Azure. Opisywany mechanizm, potencjalnie może przydać się do pisania reguł, specyficznych dla danego projektu.

Z drugiej strony, długa jeszcze droga zanim zastąpi to nDepend i Resharper, ale moim zdaniem idzie to w tym kierunku…