Dynamiczne dodawanie właściwości do obiektu w C#
Wzorujemy się na JavaScript
JavaScript ma kilka ciekawych właściwości. Jedną z nich jest możliwość szybkiego dodawania nowych pól i funkcji do już istniejących obiektów. Z jednej strony jest to niezwykła wygoda, z drugiej zaś ogromne źródło błędów. Nie zamierzam wszczynać dyskusji na temat wad i zalet języków silnie typowanych i języków słabo typowanychNie istnieje granica oddzielająca języki silnie typowane od języków słabo typowanych. Istnieją języki, w tym C#, który ma elementy silnie typowane (większość), ale umożliwia również dość luźne traktowanie danych. Wystarczy wspomnieć opisywany w artykule obiekt lub słowo kluczowe unsafe., bo każdy z nich ma swoje wady i zalety. Zamierzam pokazać, że język C#, od wersji .NET 4.0, również pozwala na zastosowanie w swoich projektach konstrukcji słabo typowanych. Nie chcę przekonywać do stosowania tego typu konstrukcji w swoim kodzie, bo sam mam wobec nich mieszane uczucia. Nie ważne co o tych rozwiązaniach myślimy - inni programiści, zafascynowani ich nowością i świeżością, będą ich używać. Będą ich używać, a my będziemy musieli to zrozumieć i zapewne niejednokrotnie poprawiać taki dynamiczny kod.
Większość zapewne zna typ danych dynamic, do którego można wpisać dowolną wartość. Jest jednak w .NET jeszcze ciekawszy dynamiczny typ - typ, do którego można w locie dodawać dowolne właściwości i metody. Ten typ to ExpandoObject. Jak on działa?
ExpandoObject w podstawowym wydaniu
Przyjrzyjmy się przykładowemu listingowi pokazującemu omawiany typ w najprostszej postaci:
extensible.Name = "Rozszerzalny";
extensible.Age = 5;
Console.WriteLine("Jestem {0} i mam {1} lat", extensible.Name, extensible.Age);
Właściwości można przypisywać w każdym miejscu i są one tworzone w momencie przypisania im wartości. Gdy odwołamy się do właściwości, do której nic nie zostało przypisane, otrzymamy wyjątek. Na pierwszy rzut oka takie magiczne obiekty wydają się bardzo elastyczne i miłe w obsłudze. Ładnie to wygląda w małym przykładzie. Jeżeli jednak kod zaczyna się rozrastać, różne właściwości dodawane są w różnych metodach, przez różnych użytkowników - wtedy można się nieco pogubić. Co zrobić, gdy chcemy pobrać właściwości, które zostały ustawione przez kogoś w innej metodzie, a ta inna osoba nie chce nam zdradzić szczegółów?
Pobieranie listy właściwości
Jednym z możliwych zastosowań obiektu ExpandoObject jest przekazanie go wywołującemu i pozwolenie mu na uzupełnienie dowolnych właściwości. Obiekt taki uzupełnia się stosunkowo łatwo - w każdym razie łatwiej niż słownik. Nadaje się on zatem do bardzo elastycznego API, w którym nie wiemy ile i jakiego typu będą konkretne właściwości.
Aby przebadać obiekt ExpandoObject można przyjrzeć się jego definicji. Dowiemy się, że implementuje on interfejs IEnumerable<KeyValuePair<string, object>>. Możemy wobec tego skorzystać z odpowiedniego rzutowania i wydobyć potrzebne nam informacje pisząc taką oto metodę:
{
var enumerable = extensible as IEnumerable<KeyValuePair<string, object>>;
if (enumerable != null)
{
foreach (var item in enumerable)
{
yield return item.Key;
}
}
}
Otrzymamy w ten sposób listę wszystkich właściwości obiektu. Wykonując pokazany poniżej kod:
extensible.Name = "Rozszerzalny";
extensible.Age = 5;
Console.WriteLine("Właściwości: {0}", string.Join(", ", GetProperties(extensible)));
Otrzymamy następujący rezultat:
Samo rozpoznanie nazw właściwości niewiele nam jednak daje. Całe szczęście ExpandoObject ma coś w zanadrzu.
Rozpoznawanie typów poszczególnych właściwości
Mamy listę właściwości i wiemy jak się te właściwości nazywają. Odpowiednie wyświetlenie wymaga jednak czegoś więcej niż nazwy. Z pomocą przychodzi pewna właściwość .NET - każda właściwość, o ile nie jest równa null, obsłuży wywołanie metody ToString. Gdybyśmy jednak zechcieli pójść krok dalej i wygenerować na podstawie tego obiektu jakiś weselszy interfejs użytkownika - będziemy potrzebowali typu. Jak to zrobić? Wiemy, że mamy do dyspozycji interfejs IEnumerable<KeyValuePair<string, object>>, ale gdybyśmy jeszcze dokładniej przyjrzeli się deklaracji typu ExpandoObject zobaczylibyśmy, że implementuje on również interfejs IDictionary<string, object>. To z niego skorzystamDla niewielkiej liczby właściwości przeszukanie interfejsu IEnumerable może się okazać wydajniejsze niż skorzystanie ze słownika. Diabeł tkwi w szczegółach implementacji tablic z haszowaniem.. Popatrzmy na poniższy listing:
{
var dictionary = extensible as IDictionary<string, object>;
if (dictionary != null && dictionary.ContainsKey(propertyName))
{
return dictionary[propertyName].GetType();
}
else
{
return null;
}
}
Przyjrzyjmy się jeszcze przykładowemu wywołaniu pokazanej metody:
extensible.Name = "Rozszerzalny";
extensible.Age = 5;
foreach (var typeName in GetProperties(extensible))
{
Console.WriteLine("Właściwość {0} jest typu {1}", typeName, GetPropertyType(extensible, typeName));
}
Na konsoli powinien pojawić się następujący rezultat:
Właściwość Age jest typu System.Int32
Jakie jeszcze interfejsy mamy do dyspozycji?
Powiadamianie o zmianach właściwości
ExpandoObject ma jeszcze jeden przydatny interfejs i jest nim INotifyPropertyChanged. Możemy zatem podpiąć metodę, która będzie wywoływana w momencie dodawania nowej, usuwania niepotrzebnej i modyfikowania istniejącej właściwości. Popatrzmy na przykład:
{
Console.WriteLine("Właściwość {0} zmieniła się", e.PropertyName);
}
// Gdzieś w kodzie...
dynamic extensible = new ExpandoObject();
((INotifyPropertyChanged)extensible).PropertyChanged += Program_PropertyChanged;
extensible.NewProperty = 'G';
extensible.NewProperty = 'C';
Po uruchomieniu przykładu otrzymamy dwa komunikaty:
Właściwość NewProperty zmieniła się
Pierwszy komunikat związany jest z utworzeniem właściwości i przypisaniem początkowej wartości, drugi komunikat związany jest już tylko ze zmianą wartości.
Usuwanie właściwości
Wiemy już, że dodawanie właściwości jest wyjątkowo proste i polega na przypisaniu do niej jakiejś wartości. Jak taką właściwość usunąć? Przypisać null? A jeżeli chcielibyśmy mieć null jako wartość właściwości? Rozwiązaniem problemu jest implementacja interfejsu IDictionary<string, object>:
{
var dictionary = extensible as IDictionary<string, object>;
if (dictionary != null && dictionary.ContainsKey(propertyName))
{
dictionary.Remove(propertyName);
}
}
Teraz wystarczy gdzieś w kodzie tę metodę wywołać i cieszyć się z rezultatu. Popatrzmy na poniższy przykład:
extensible.Name = "Rozszerzalny";
extensible.Age = 5;
extensible.NewProperty = 'G';
((INotifyPropertyChanged)extensible).PropertyChanged += Program_PropertyChanged;
RemoveProperty(extensible, "NewProperty");
Console.WriteLine("Właściwości: {0}", string.Join(", ", GetProperties(extensible)));
Wykonanie tych instrukcji sprawi, że w oknie konsoli pojawią się dwa komunikaty:
Właściwości: Name, Age
Możemy się przy okazji przekonać, że usuwanie właściwości również wywołuje zdarzenie PropertyChanged.
Dynamiczne dodawanie dynamicznych właściwości
Wiemy już, że dodanie właściwości w kodzie jest bardzo proste i sprowadza się do napisania nazwy obiektu, kropki, nazwy właściwości i przypisania temu jakiejś wartości. Przetwarzanie obiektów dynamicznych czasem wymaga programowego dodania nowej właściwości. W takim przypadku znów trzeba sięgnąć do interfejsu słownika:
{
var dictionary = extensible as IDictionary<string, object>;
if (dictionary != null)
{
dictionary.Add(propertyName, value);
}
else
{
throw new NotImplementedException();
}
}
Znów wszystko sprowadza się do rzutowania na interfejs słownika.
Nie będę już pokazywał przykładu wywołania tej metody w kodzie, bo nie wniesie ona nic nowego. Wykorzystam ją w nieco bardziej rozbudowanym przykładzie.
Dodawanie metod w locie
Obiekt ExpandoObject może tworzyć dynamicznie nie tylko właściwości - może też mieć metody. Metody w sensie właściwości reprezentujących metody. Mówiąc jeszcze dokładniej - delegaty. Popatrzmy na poniższy przykład i przekonajmy się:
extensible.AddProperty = (Action<string, object>)((name, value) => AddProperty(extensible, name, value));
extensible.AddProperty("OneMore", "Se, se, se...");
Pokazana w poprzednim przykładzie metoda do dodawania właściwości została nadpisana. Metoda była ogólna, bo potrafiła obsłużyć każdy obiekt ExpandoObject przekazany w postaci pierwszego parametru. Aby uprościć to wywołanie można ten pierwszy parametr zdefiniować jako... obiekt wywołujący metodę. Coś na kształt wskazania this. Metoda być może nie ma większego sensu, ale pokazuje pewną technikę. To, jak tę technikę wykorzystamy, i czy wogóle wykorzystamy, zależy od nas samych.
Warto zwrócić uwagę na rzutowanie na typ Action. Powód tego jest bardzo prosty: wyrażenie lambda nie jest typem delegate. Typ delegate reprezentuje konkretną metodę, a w zasadzie wskazanie na nią. Wyrażenie lambda reprezentuje sposób wykonywania pewnych operacji i nie może być wykonywane bezpośrednio. To, że wyrażenie lambda może być użyte w miejscu delegata, to tylko zasługa kompilatora. To dlatego tworząc wyrażenia należy je wcześniej skompilować (zobacz metodę LambdaExpression.Compile). Napiszę jeszcze inaczej - wyrażenie lambda nie jest nawet typem! Dopiero jawne wskazanie na konkretny typ delegata pozwoli nam zadowolić kompilator.
Dynamiczne nie zawsze znaczy dobre
Napisałem na początku, że nie jestem do końca przekonany do zbyt luźnego traktowania zmiennych i typów w kodzie. Można pisać wiele, ale jedna myśl będzie zawsze krążyła: dlaczego większość złożonych aplikacji pisana jest w językach silnie typowanych? Wystarczy wymienić CC i C++ posiadają wskaźniki, które reprezentują pewien obszar w pamięci procesu. Definiują one jedynie typ obiektów wskazywanych przez wskaźnik lub sam adres w przypadku wskaźnika void*., C++, Javę, C#. Czy te języki mają większe możliwości? Zaryzykowałbym stwierdzenie, że nie i że jest wręcz przeciwnie. Pozwalają one na znacznie więcej. Można wstawić wszystko, w każym momencie i wszędzie! Właśnie przez takie podejście analiza kodu jest znacznie trudniejsza, trudniejsze jest wyszukiwanie błędów, trudniejsze jest zlokalizowanie miejsca, w którym zmienne są ustawiane, zmieniane. Zdarzenia pojawiaja się z zaskoczenia. Drobne literówki w nazwaniu zmiennych mogą pozostać w ukryciu przez długi czas - dopóki ktoś nie wykona napisanego kodu. Ktoś, kto analizował nieczytelnie napisany kod JavaScript mający więcej niż kilka stron z pewnoscią rozumie problem. Ktoś może powiedzieć - wystarczy ładnie pisać kod! Praktyka jest jednak inna. Komunizm w założeniach też jest dobry - każdemu według potrzeb! W językach silnie typowanych mamy wielkiego, praktycznie nieomylnego pomocnika - jest nim kompilator. Trzeba docenić to, że już na etapie kompilacji potrafi wyłapać dużą liczbę błędów, potrafi ostrzec przed kontrowersyjnymi konstrukcjami języka programowania. Potrafi ostrzec przed niejawnymi konwersjami pomiędzy typami i zapobiec mnożeniu tekstu przez liczbę całkowitą. Większość opisanych w artykule technik można zrealizować przy pomocy zwykłego słownika - ośmielę się powiedzieć, że niektóre operacje będą nawet czytelniejsze. Powiem więcej - błąd braku klucza w słowniku jest powszechnie znany, a sama obecność słownika u większości zapala żółte światło oznaczające podwyższone ryzyko.
Musiałem o tym wszystkim napisać, żeby pobudzić do myślenia. Pamiętajmy, że być może kiedyś przyjdzie innym (lub, nie daj Boże nam!) poprawiać napisany przez nas kod. Superdynamiczne struktury mają to do siebie, że potrafią być niezrozumiałe.
Żeby to wszystko nie wzbudziło atmosfery grozy napiszę tak: ogniem można się poparzyć, nożem można się skaleczyć. Wszystko, co zawsze podkreślam, zależy od konkretnego przypadku. Mam jednak głęboką nadzieję, że choć trochę pobudziłem do myślenia. Takie bowiem, według mnie, zadanie programisty i projektanta - myśleć i wybierać odpowiednie narzędzia. Z tego nikt nas podczas programistycznych zmagań nie zwolni.
Kategoria:C#
Brak komentarzy - bądź pierwszy