Tajniki RTTISpis treści
Czym jest RTTI i na co pozwala?
RTTI, to skrót od Run-time Type Information, czyli informacji, które zbierane są przez kompilator o klasach w kodzie I dołączane do tychże klas. Dzięki temu możliwe jest odczytywanie i modyfikowane ich podczas wykonywania kodu. Ponadto daje też dostęp do informacji o typach używanych w programie. W szczególności RTTI pozwala na takie operacje, jak:
- Uzyskiwanie nazw klas, a także - w najnowszych wersjach, jak Delphi 12 - także nazw zmiennych czy typów (
NameOf)
- Listowanie metod (wraz z parametrami), pól i właściwości klas
- Odczytywanie i zapisywanie wartości pól i właściwości
- Wywoływanie metod, w tym przekazywanie i odbieranie wartości parametrów
- Dostarczanie informacji o rekordach
- Uzyskiwanie nazw wartości typów wyliczeniowych
- Uzyskiwanie informacji o typach porządkowych
Jak pozyskać informacje?
Wykorzystując RTTI nieodłączne stają się dwa moduły: System.Rtti, który pomaga w przetwarzaniu informacji o klasach i ich zawartości, a także zawiera typy pomocne przy odczycie i nadawaniu wartości oraz System.TypInfo, dzięki któremu można bezpośrednio odwoływać się do wartości obiektów lub np. nazw typów wyliczeniowych.
W najprostszym scenariuszu wystarczy tylko utworzyć kontekst RTTI, aby zyskać dostęp do informacji. Choć sam typ TRttiContext jest rekordem, to zachowuje się nieco jak obiekt przy użytkowaniu:
var
ctx: TRTTIContext;
info: TRttiType;
begin
ctx := TRttiContext.Create;
try
Info := ctx.GetType(TObject);
finally
ctx.Free;
end;
end;
Powyższy przykład pobiera informacje o klasie TObject. Ale oczywiście ten kod jeszcze niczego nie wyświetli. Aby pozyskać więcej informacji warto przyjrzeć się najczęściej używanym metodom i właściwościom dla poszczególnych typów.
Aby móc wyciągać te informacje, warto zapoznać się z następującymi typami:
TRttiContext - umożliwia dostęp do wszystkich informacji
TRttiType - zawiera informacje o typie danych; typ ten ma wiele odrębnych podtypów w zależności od rodzaju danych
TRttiField - informacje o polu klasy
TRttiProperty - informacje o właściwości klasy
TRttiMethod - informacje o metodzie klasy
TRttiParameter - informacje o parametrze metody
TRttiPackage - informacje o załadowanych paczkach
Prawie każdy element w RTTI na swoją nazwę, którą można pobrać z właściwości Name. Zatem chcąc wyświetlić nazwę przekazanej klasy wystarczy dodać po pobraniu informacji:
Writeln(Info.Name);
Z jakich jeszcze elementów zmiennej takiego typu najczęściej będzie się korzystać?
Aby sprawdzić, z czym mamy do czynienia, należy wywołać TypeKind. Pozwala ona określić, czy mamy do czynienia z klasą, rekordem, prostą zmienną liczbową, tekstową, wyliczeniową, czy może wskazywana jest procedura lub metoda. Jeśli określimy, że mamy na pewno do czynienia z klasą lub rekordem, to dla nich wszak można pobierać szczegółowe informacje związane z zawartością poprzez następujące metody:
GetProperties - zwraca listę informacji o właściwościach
GetIndexedProperties - zwraca listę informacji o właściwościach z dostępem indeksowanym (od Delphi XE2)
GetMethods - zwraca listę informacji o metodach
GetFields - zwraca listę informacji o polach
Oczywiście istnieją też możliwości pobrania informacji o wskazanym po nazwie obiekcie - metody nazywają się podobnie (lecz w liczbie pojedynczej) i mają parametr tekstowy. Nadmienić trzeba, że GetMethods również posiada opcjonalny parametr - służy wówczas do pobierania listy przeładowanych metod (czyli wielu o tej samej nazwie).
Istnieją też te same metody z dopisanym po Get słowem Declared - w takim przypadku zwracane są wyłącznie te elementy, które wprost znajdują się w analizowanej klasie.
Wszystkie te metody zwracają jeden z typów wspomnianych już wcześniej. Każdy z nich, poza oczywiście nazwą, ma np. taką właściwość jak Visibility, która mówi, w jakiej części klasy odpowiedzialnej za widoczność jest ten element umieszczony. Dodatkowo dla właściwości można odnaleźć informacje, czy jest ona do odczytu (IsReadable) lub do zapisu (IsWritable), a dla metod będą to np. informacje o parametrach (metoda GetParameters) lub zwracanym typie dla funkcji (ReturnType). Te ponownie są określonego typu, więc można rekurencyjnie przeglądać dalsze informacje.
Kolejnym przydatnym narzędziem może być sprawdzanie, czy coś, z czym pracujemy, nie jest dokładnie pewnym elementem. Owszem, możliwe jest sprawdzenie po prostu nazwy. Ale znacznie prostsze i szybsze okaże się korzystanie z funkcji systemowej (nie wymaga użycia żadnego specjalnego unitu) TypeInfo(…), która zwraca wskaźnik do informacji o danym elemencie. Ten sam znaleźć można w typach zwracanych przez RTTI pod właściwością Handle.
Ostatnim przydatnym elementem jest właściwość BaseType, która - zgodnie z tym, co podpowiada intuicja - zwraca informacje o typie nadrzędnym. Przykładowo dla klasy będą to informacje o klasie, z której dziedziczy, a np. dla typu TDateTime będzie to liczba zmiennoprzecinkowa, gdyż właśnie jest on jej rozwinięciem.
Wyposażeni w tą wiedzę przyjrzyjmy się poniższemu modułowi, który wyświetla szereg informacji o przekazanym dowolnym typie RTTI:
unit uRTTI;
interface
procedure PrintRTTIInformation(const aClass: TClass);
var
ExcludeInherited: Boolean = True;
ListTObject: Boolean = False;
implementation
uses
System.SysUtils,
System.RTTI,
system.TypInfo;
function ParamFlagsToStr(const aFlags: TParamFlags): String;
begin
Result := '';
if pfVar in aFlags then
Result := Result + 'var ';
if pfConst in aFlags then
Result := Result + 'const ';
if pfArray in aFlags then
Result := Result + 'array ';
if pfAddress in aFlags then
Result := Result + 'addr ';
if pfReference in aFlags then
Result := Result + 'ref ';
if pfOut in aFlags then
Result := Result + 'out ';
if pfResult in aFlags then
Result := Result + 'res ';
end;
procedure PrintRttiTypeInfo(const aType: TRttiType; const aLvl: Integer = 1);
const
StrBool: array[False..True] of String = ('-', '+');
StrVis: array[Low(TMemberVisibility)..High(TMemberVisibility)] of String = ('PRIVATE', 'PROTECTED', 'PUBLIC', 'PUBLISHED');
StrMethod: array[Low(TMethodKind)..High(TMethodKind)] of String = ('PROCEDURE', 'FUNCTION', 'CONSTRUCTOR', 'DESTRUCTOR', 'CLASS PROCEDURE', 'CLASS FUNCTION', 'CLASS CONSTRUCTOR', 'CLASS DESTRUCTOR', 'OPERATOR OVERLOAD', 'SAFE PROCEDURE', 'SAFE FUNCTION');
var
prefix: String;
fields: TArray>TRttiField>;
fieldInfo: TRttiField;
props: TArray>TRttiProperty>;
propInfo: TRttiProperty;
meths: TArray>TRttiMethod>;
methInfo: TRttiMethod;
parInfo: TRttiParameter;
begin
if (aType = nil) then
begin
writeln('?');
Exit;
end;
if (aLvl > 6) then
Exit;
prefix := StringOfChar(' ', aLvl * 2);
writeln(aType.Name);
if (aType.Handle = TypeInfo(TObject)) and not ListTObject then
Exit;
if (aType.TypeKind = tkClass) or (aType.TypeKind = tkRecord) then
begin
if ExcludeInherited then
fields := aType.GetDeclaredFields
else
fields := aType.GetFields;
for fieldInfo in fields do
begin
if not ListTObject and (fieldInfo.Parent.Handle = TypeInfo(TObject)) then
Continue;
write(prefix + StrVis[fieldInfo.Visibility] + ' FIELD ' + fieldInfo.Name + ': ');
PrintRttiTypeInfo(fieldInfo.FieldType, aLvl + 2);
end;
if ExcludeInherited then
props := aType.GetDeclaredProperties
else
props := aType.GetProperties;
for propInfo in props do
begin
if ExcludeInherited and (propInfo.Parent <> aType) then
Continue;
if not ListTObject and (propInfo.Parent.Handle = TypeInfo(TObject)) then
Continue;
write(prefix + StrVis[propInfo.Visibility] + ' PROPERTY ' + propInfo.Name + ' [');
if propInfo.IsReadable and propInfo.IsWritable then
write('R/W')
else if propInfo.IsReadable then
write('RO')
else
write('WO');
write(']: ');
PrintRttiTypeInfo(propInfo.PropertyType, aLvl + 1);
end;
if ExcludeInherited then
meths := aType.GetDeclaredMethods
else
meths := aType.GetMethods;
for methInfo in meths do
begin
if ExcludeInherited and (methInfo.Parent <> aType) then
Continue;
if not ListTObject and (methInfo.Parent.Handle = TypeInfo(TObject)) then
Continue;
writeln(prefix + StrVis[methInfo.Visibility] + ' ' + StrMethod[methInfo.MethodKind] + ' ' + methInfo.Name);
for parInfo in methInfo.GetParameters do
begin
write(prefix + ParamFlagsToStr(parInfo.Flags) + ' parameter ' + parInfo.Name + ': ');
PrintRttiTypeInfo(ParInfo.ParamType, aLvl + 1);
end;
if methInfo.ReturnType <> nil then
begin
write(prefix + ' returns ' + methInfo.ReturnType.Name + ': ');
PrintRttiTypeInfo(methInfo.ReturnType, aLvl + 1);
end;
end;
end
else if aType.TypeKind = tkArray then
begin
writeln(prefix + ' dimessions: ' + IntTostr(TRttiArrayType(aType).DimensionCount));
writeln(prefix + ' element type:');
PrintRttiTypeInfo(TRttiArrayType(aType).ElementType, aLvl + 1);
end
else
writeln(prefix + ' type: ' + GetEnumName(TypeInfo(TTypeKind), Ord(aType.TypeKind)));
if aType.IsOrdinal then
begin
writeln(prefix + ' Min value: ' + IntToStr(TRttiOrdinalType(aType).MinValue));
writeln(prefix + ' Max value: ' + IntToStr(TRttiOrdinalType(aType).MaxValue));
end;
if aType.TypeKind = tkEnumeration then
begin
writeln(prefix + ' Enums: ' + String.Join(', ', TRttiEnumerationType(aType).GetNames));
end;
if aType.BaseType <> nil then
begin
writeln('');
write(prefix + 'Base on ');
PrintRttiTypeInfo(aType.BaseType, aLvl + 2);
end;
end;
procedure PrintRTTIInformation(const aClass: TClass);
var
ctx: TRTTIContext;
begin
ctx := TRttiContext.Create;
try
PrintRttiTypeInfo(ctx.GetType(aClass));
finally
ctx.Free;
end;
end;
end.
Funkcja PrintRTTIInformation przyjmuje za punkt startowy wskazaną przez nas klasę. Ale najistotniejsze jest to, co dzieje się w PrintRttiTypeInfo, która wywoływana jest rekurencyjnie.
Warto zwrócić uwagę na kilka fragmentów. Jako, że działa ona rekurencyjnie, to oczywiście dodano zabezpieczenie przed głębokością - inaczej, gdyby poddać jej działaniu klasę, której metoda parametr tej samej klasy, to doszłoby do zapętlenia.
Ponadto w pliku tym znajduje się zmienna ExcludeInherited, która mówi, czy należy przetwarzać dane należące do klas, z której dziedziczy dana klasa. Ponieważ wszystkie publiczne i chronione element z klasy rodzica zawsze przechodzą do klasy potomnej, przez co każda następna jest już na starcie tak samo bogata, jak jej rodzic. Jeśli użyte są metody bez słowa Declared, to wówczas uzyskuje się informacje wraz ze wszystkimi przejętymi po rodzicach informacjami. Użycie wersji z tym słowem sprawia, że zwracane są tylko deklarowane wprost w danej klasie.
Procedura ta może wykluczać też przetwarzanie klasy TObject poprzez ustawienie zmiennej ListTObject. To w tym miejscu wykorzystane jest porównanie wskaźnika z informacjami:
if (aType.Handle = TypeInfo(TObject)) and not ListTObject then
Exit;
Kolejnymi rzeczami, jakie się tu wyszczególniają, to fakt, że w zależności od typu mamy do czynienia z rozbudowanymi wersjami klasy TRttiType, które posiadają dodatkowe właściwości. I tak dla tablic (tkArray) zwracany typ jest w rzeczywistości typem TRttiArrayType, a ten posiada np. takie właściwości jak liczba wymiarów tablicy (DimensionCount) oraz typ jej elementów (ElementType). Ten element można oczywiście rekurencyjnie poddać dalszemu badaniu (por. linie 129-133).
Dla typu wyliczeniowego z kolei można pobrać nazwy jego elementów (por. linia 144-146).
Oczywiście, nie są to wciąż wszystkie możliwe do pozyskania informacje, ale z pewnością jedne z częściej wykorzystywanych.
W tym miejscu warto zwrócić też uwagę na pewne niedomagania RTTI. Jeśli typ złożony znajdzie się wprost w definicji, to nie będzie miał on swojego odwzorowania w RTTI.
TMyClass = class
strict private
fValues: array[0..5] of Integer;
fSub: 1..100;
fProc: procedurę of object;
end;
Aby obejść ten problem, trzeba "otypować", czyli nadać nazwy takim typom:
T6IntArray = array[0..5] of Integer;
TSub1To100 = 1..100;
TProcedure = procedureof object;
TMyClass = class
strict private
fValues: T6IntArray;
fSub: TSub1To100;
fProc: TProcedure;
end;
Podobny problem spotyka typy wyliczeniowe z narzuconymi wartościami, jeśli nie są one zgodne z naturalnymi nadawanymi przez kompilator:
{$SCOPEDENUMS ON} // pozwala zastosować te same nazwy elementów wyliczeniowych, ale wymaga poprzedzenia nazwą typu
TMyEnum1 = (First, Last);
TMyEnum2 = (First = 0, Last = 1);
TMyEnum3 = (First = 1, Last = 2);
TEnumerationClass = class
public
fEnum1: TMyEnum1; // ok
fEnum2: TMyEnum2; // jeszcze ok., bo się pokrywa
fEnum3: TMyEnum3; // brak informacji
end;
Obejścia tego problemu są różne - można utworzyć helper dokonujący konwersji, można zbudować tablicę lub można opisać wartości atrybutami.
Co ciekawe, wartości enumeratora umieszczone wprost w definicji zmiennej, bez tworzenia typu, są obsługiwane!
Dyrektywy kompilatora sterujące informacjami RTTI
Jednak przeglądając informacje nie ma danych o właściwościach i metodach z sekcji innych, niż publiczne lub publikowane. Wynika to z domyślnych ustawień kompilatora. Domyślnie zamieszczane są informacje o publicznych i publikowanych metodach i właściwościach oraz polach ze wszystkich sekcji.
Aby zmienić ten stan rzeczy można posłużyć się dyrektywą kompilatora {$RTTI dziedziczenie, element([widoczność])}.
Dziedziczenie może przyjmować jedną z dwóch wartości:
INHERITED - bez podawania innych parametrów sprawi, że ustawione zostaną wszystkie identycznie, jak dla klasy nadrzędnej (z której dziedziczy).
EXPLICIT - redefiniuje na nowo parametry widoczności elementów klasy
Do sterowania oczywiście oddane są 3 elementy: FIELDS dla pól, PROPERTIES dla właściwości oraz METHODS dla metod.
W zbiorze będącym parametrem elementu można wymieniać zakresy widoczności: vcPrivate - elementy z sekcji prywatnych, vcProtected - chronionych, vcPublic - publicznych oraz vcPublished - publikowanych (szczególnie znaczących dla komponentów).
Przyjrzyjmy się zatem dwóm wariantom przedstawionym niżej:
{$RTTI EXPLICIT METHODS([]) PROPERTIES([]) FIELDS([])}
{$RTTI EXPLICIT METHODS([vcPrivate, vcProtected, vcPublic, vcPublished]) PROPERTIES([vcPrivate, vcProtected, vcPublic, vcPublished]) FIELDS([vcPrivate, vcProtected, vcPublic, vcPublished])}
Pierwszy z nich usuwa wszystkie informacje o polach, właściwościach i metodach. Drugi z kolei umieszcza absolutnie wszystkie możliwe dane.
Należy zaznaczyć, że każdy unit ma niewidoczne ustawienie na początku nakazujące użycie widoczności jak dla klasy bazowej, czyli {$RTTI INHERIT}. Ma to miejsce od wersji XE2. Wcześniej wystarczyło zdefiniowane na poziomie projektu, czyli w pliku dpr. Dlatego chcąc zmienić te informacje, stosowna dyrektywa musi znaleźć się w każdym z unitów. Przy czym może ona być równie dobrze dodana na początku jeszcze przed częścią interfejsową, jak i bezpośrednio przed poszczególnymi klasami.
Jakie elementy zatem będą widoczne dla tak zdefiniowanych klas:
unit Klasy;
{$RTTI EXPLICIT METHODS([]) PROPERTIES([]) FIELDS([])}
interface
type
TMyClass1 = class
strict private
fField1: Integer;
property Prop1: Integer;
procedure Method1;
strict protected
fField2: Integer;
property Prop2: Integer;
procedure Method2;
public
fField3: Integer;
property Prop3: Integer;
procedure Method3;
end;
{$RTTI INHERIT}
TMyClass2 = class
strict private
fField1: Integer;
property Prop1: Integer;
procedure Method1;
strict protected
fField2: Integer;
property Prop2: Integer;
procedure Method2;
public
fField3: Integer;
property Prop3: Integer;
procedure Method3;
end;
{$RTTI EXPLICIT METHODS([vcPublic]) PROPERTIES([vcProtected]) FIELDS([])}
TMyClass3 = class(TMyClass1)
strict private
fFieldA: Integer;
property PropA: Integer;
procedure MethodA;
strict protected
fFieldB: Integer;
property PropB: Integer;
procedure MethodB;
public
fFieldC: Integer;
property PropC: Integer;
procedure MethodC;
end;
Dla klasy TMyClass1 nie zobaczymy nic, gdyż leży ona w obszarze wycięcia wszystkich informacji.
Dla klasy TMyClass2 zobaczymy pola fField1, fField2 oraz fField3, a także właściwość Prop3 i metodę Method3, gdyż takie są domyślne widoczności dla TObject, z której (niejawnie) dziedziczy.
Dla ostatniej klasy TMyClass3 zobaczymy tylko właściwość PropB i metodę MethodC, gdyż ponownie nastąpiła redefinicja.
Kolejne dwie dyrektywy odnoszą się do całego projektu.
Pierwszą jest {$WEAKLINKRTTI ON} (w szczególności używana właśnie z podanym parametrem). Powoduje ona usunięcie informacji z RTTI o typach, które nie są jawnie w kodzie używane.
Druga to {$STRONGLINKTYPES ON, która wymusza umieszczenie wszystkich typów, jakie występują w dołączonych do projektach unitach. Jest to też domyślna dyrektywa dla plików BPL (które po prostu muszą dostarczać wszystkie klasy).
Aby lepiej zobrazować posłużmy się następującymi kodami. Zdefiniujmy w unicie 3 klasy:
unit p2_c1;
interface
type
TMyClass1 = class
end;
TMyClass2 = class(TMyClass1)
end;
TMyClass3 = class
public
procedure Start;
end;
implementation
{ TMyClass3 }
procedure TMyClass3.Start;
begin
TMyClass1.Create.Free;
end;
end.
Mamy tu następujące zależności: klasa TMyClass2 dziedziczy z TMyClass1, zaś klasa TMyClass3 wykorzystuje klasę TMyClass1.
Wykonajmy też listowanie dostępnych klas prostym kodem:
procedure ListClasses;
var
ctx: TRTTIContext;
&type: TRttiType;
begin
ctx := TRttiContext.Create;
try
for &type in ctx.GetTypes do
begin
if (&type.TypeKind = tkClass) then
writeln(&type.Name);
end;
finally
ctx.Free;
end;
end;
Dodajmy też w kodzie odwołanie do klasy TMyClass3 poprzez jej utworzenie i zwolnienie:
TMyClass3.Create.Free
Jeśli nie użyjemy żadnych dyrektyw kompilatora, to poza klasami systemowymi wynikającymi z użycia samego RTTI, otrzymamy w wyniku klasy TMyClass3 (z racji użycia) oraz TMyClass1 (gdyż do niej odwołuje się ona w swoim kodzie ta pierwsza).
Dodanie dyrektywy {$WEAKLINKRTTI ON} sprawi, że z wyniku zniknie TMyClass1 ponieważ kod, który się do niej odwołuje, nie jest używany.
Jeśli z kolei użyjemy dyrektywy {$STRONGLINKTYPES ON}, to pojawią się wszystkie 3 klasy, gdyż ta dyrektywa zmusza do dołączenia informacji o wszystkich klasach z kompilowanego kodu nawet, jeśli są one nieużywane. To ustawienie jest silniejsze od poprzedniego, więc poprzednie traci w takiej sytuacji swoją moc.
Bardziej obrazowo przedstawi to poniższa tabelka:
Atrybuty
Atrybuty pozwalają na umieszczanie dodatkowych obiektów (i informacji, jakie one przechowują) w informacjach RTTI kolekcjonowanych przez kompilator. Umieszcza się je w nawiasach kwadratowych przed danym elementem (klasą, polem, właściwością, metodą czy nawet parametrem - w tym ostatnim przypadku dopiero od Delphi XE7) zgodnie z konwencją, jaką dopuszcza konstruktor atrybutu. Może być to atrybut bezparametrowy (wówczas samo jego istnienie może nieść informację), ale może też posiadać wiele parametrów podobnie jak i wiele przeładowanych konstruktorów.
W odróżnieniu od większości typów, atrybuty posługują się inną konwencją nazewnictwa. Po pierwsze nie poprzedza się ich zwyczajowo wyróżnikiem T. Po drugie, jeśli jego nazwa zakończy się słowem Attribute, to to słowo może być pomijane podczas opisywania kodu tym atrybutem.
Atrybut jest tworzony w momencie, gdy poprzez RTTI zażądamy listy atrybutów. To oznacza, że jeśli obiekt podczas tworzenia zwróci wyjątek, to dowiemy się o nim dopiero w tym momencie, a nie, jak można by się spodziewać, na etapie kompilacji.
Przyjrzyjmy się definicji atrybutów - bezparametrowego i z pojedynczym parametrem:
interface
type
ZnacznikAttribute = class(TCustomAttribute)
end;
OpisAttribute = class(TCustomAttribute)
strict private
fOpis: String;
public
constructor Create(const aOpis: String); reintroduce;
property Opis: String read fOpis;
end;
implementation
uses
System.SysUtils;
{ OpisAttribute }
constructor OpisAttribute.Create(const aOpis: String);
begin
inherited Create;
fOpis := aOpis;
end;
Aby wykorzystać, wystarczy teraz umieścić w sekcji uses odwołanie do tej definicji (w przeciwnym wypadku kompilator wypluje ostrzeżenie W1025 Unsupported language feature: 'custom attribute') oraz opisać atrybutami kod. Warto zwrócić uwagę, że dany atrybut może pojawiać się wiele razy:
type
[Opis('To jest moja klasa')]
TMyClass = class
strict private
[Opis('To jest pole')]
[Opis('To pole przechowuje wartość')]
[Znacznik]
fVal: Integer;
public
[Opis('To jest konstruktor')]
constructor Create([Opis('To jest wartość na początek')] const aValue: Integer);
[Opis('Ta funkcja zwiększa wartość o 1 i zwraca wynik')]
function Inc: Integer;
end;
Aby pobrać listę atrybutów, każdy z typów RTTI opisujących element możliwy do opisania atrybutami posiada metodę GetAttributes:
var
a: TCustomAttribute;
begin
…
for a in (…).GetAttributes do
if a is OpisAttribute then
writeln('Opis: ' + OpisAttribute(a).Opis)
else
writeln('Atrybut klasy ' + a.ClassName);
end;
W tym miejscu sprawdza się warunkowanie, czy atrybut jest danego typu. Ale możliwe jest też... Ponowne wykorzystanie RTTI w odniesieniu do atrybutów w celu np. sprawdzenia nazwy czy poznania właściwości. Choć częściej jednak wykorzysta się pierwszą z możliwości.
Ograniczenia atrybutów
Niestety, ze względu na sposób działania atrybutów, posiadają one też pewne swoje ograniczenia w odniesieniu do parametrów kontruktorów. Oczywistym jest, że można stosować w nich wyłącznie stałe wartości. Zatem odpadają wszelkie obiekty (pomimo tego, że atrybut jest tworzony dopiero w momencie jego pobrania) czy zmienne.
Ale na tym ograniczenia się nie kończą. RTTI, jak już wcześniej wspomniałem, nie radzi sobie z typami wyliczeniowymi z narzuconymi wartościami. Stąd parametr z taką wartością wyliczeniową będzie powodował błąd o treści EInsufficientRtti: Insufficient RTTI available to support this operation (por. kod poniżej, linia 10). Jednak można temu niec zaradzić - wystarczy zdefiniować parametr typu liczbowego i dokonać rzutowania podczas oznaczania kodu atrybutem czyli zdefiniować jako [Fail(Integer(NotFound))].
Innym, być może mniej intuicyjnym, ale za to dającym błędy już na etapie kompilacji w momencie użycia takiego konstruktora, jest chęć użycia tablicy stałych, badź to dynamicznej (array of ...), bądź statycznej (array[0..1] of ...). Choć z punktu kodu jest z góry określony, to nie wypełnia on definicji stałej dla kompilatora. Jeśli zależy nam na zmiennej ilości parametrów, a wiemy, że nie będzie ich więcej niż np. 3, można poradzić sobie kolejnymi przeładowanymi wersjami konstruktora lub używając wartości domyślnych, jeśli to możliwe (por. kod poniżej, linie 15-18).
Dokładnie ten sam problem, co wcześniej dotyka rekordy. Także w tym przypadku próba użycia atrybutu z parametrem rekordu skończy się błędem kompilatora E2026 Constant expression expected.
Kolejnym typem, który jest podobnym do wcześniejszych jest PChar, który de facto jest wskaźnikiem, a nie stałą wartością.
Nie ma możliwości także używania typów wariantowych (Variant), choć w tym przypadku kompilacja przebiegnie poprawnie. Jednak próba dobrania się do parametru skończy się wyjątkiem EVariantInvalidOpError: Invalid variant operation.
W starszych wersjach Delphi, sprzed 10.3 problemem były też rozległe zbiory, np. set of char. Próba użycia tego typu w parametrze kontruktora atrybutu kończyła się podobnie, jak ma to miejsce w przypadku wszelkich danych dynamicznych. W nowszych wersjach jednak nie jest już to problemem.
Poniżej przedstawiono kod z opisywanymi wcześniej parametrami w konstruktorach atrybutów wraz z opisem. Warto zwrócić uwagę, że sam w sobie jest w pełni kompilowalny.
type
TMyEnum = (NotFound = 404, ServerError = 500);
TArr = array[0..3] of Integer;
TMyRec = record
a, b: Integer;
end;
FailAttribute = class(TCustomAttribute)
public
constructor Create(const aEnum: TMyEnum); overload; // typ wyliczeniowy z narzuconymi wartościami spowoduje bład przy próbie odczytu atrybutu
constructor Create(const aArr: array of Integer); overload; // to spowoduje błąd kompilacji przy próbie zdefiniowana atrybutu z tym kontruktorem
constructor Create(const aArr: TArr); overload; // ten tak samo
// Obejście:
constructor Create(const aElem1: Integer); overload; // na 1 element
constructor Create(const aElem1, aElem2: Integer); overload; // na 2 elementy
constructor Create(const aElem1, aElem2, aElem3: Integer); overload; // na 3 elementy
constructor Create(const aElem1, aElem2, aElem3, aElem4: Integer; const aElem5: Integer = 0; const aElem6: Integer = 0); overload; // na 4-6 elementów
//----------
constructor Create(const aRec: TMyRec); overload; // to także nie zadziała przy próbie użycia
constructor Create(const aStr: PChar); overload; // to wskaźnik, a nie stała wartość
constructor Create(const aVar1, aVar2: Variant); overload; // taki atrybut zgłosi błąd przy dostępie
end;
Odczytywanie i nadawanie wartości oraz wywoływanie metod
Aby móc przejść dalej, nieodzownym staje się zaznajomienie z typem TValue, który pojawił się wraz z nową wersją RTTI w Delphi 2010. Jest on dość podobny do dobrze znanego typu Variant, ale na pewno nie jest z nim równoważny i nie należy go wykorzystywać do swobodnego zamieniania typów danych, mimo że pozwala na przechowywanie różnych typów i dla typów kompatybilnych umożliwia konwersję. Ale po określeniu typu nie można już tego zmieniać. Przeznaczony jest do przenoszenia danych do i z systemu RTTI. Przyjrzyjmy się poniższemu przykładowi, który obazuje, co można, czego nie można i jak tworzyć nawet złożone typy:
type
TMyRec = record
Val: Integer;
Arr: array[0..1] of Char;
end;
var
v: TValue;
i: Integer;
r, r2: TMyRec;
begin
v := 123;
i := v; // tak nie można!
i := v.AsInteger; // tak można
v := v.Cast<Byte>; // tak można zmienić typ na kompatybilny
if v.IsType then
writeln('V jest typu Byte');
r.Val := 1;
r.Arr[0] := 'a';
r.Arr[b] := 'b';
TValue.Make(@r, TypeInfo(TMyRec), v);
r2 := v.AsType>TMyRec>;
end;
W pierwszych wierszach widać różnicę między TValue a Variant. Do rzutowania typu TValue na określony typ służy metoda generyczna Cast<T>. Do określania kompatybilności można posłużyć się IsType<T>.
Bez wątpienia ciekawym jest ostatni przykład pokazujący, jak w zmiennej TValue można przechować bardziej złożone struktury danych przy użyciu klasowej metody Make oraz jak takie dane odczytać do zmiennej poprzez AsType.
Mając już wiedzę na temat działania typu TValue łatwo można przejść zarówno do odczytywania wartości pól i właściwości, nadawania a także wywoływania metod wraz z parametrami.
Zacznijmy od najprostszego przypadku, a mianowicie odczytu wartości z pól. Wystarczy do tego prosty kod:
procedure TExecutor.ListProperties(const aObj: TObject);
var
ctx: TRttiContext;
rt: TRttiType;
pt: TRttiProperty;
attr: TCustomAttribute;
s: String;
i: Integer;
begin
ctx := TRttiContext.Create;
try
rt := ctx.GetType(aObj.ClassType);
for pt in rt.GetProperties do
begin
if not pt.IsReadable then
Continue;
s := '';
for attr in pt.GetAttributes do
if attr is OpisAttribute then
s := OpisAttribute(attr).opis;
s := s + ' [' + pt.Name + '] = ' + pt.GetValue(obj).ToString;
writeln(s);
end;
finally
ctx.free;
end;
end;
Co ma tu miejsce? Z przekazanego obiektu odczytujemy przez RTTI informacje o klasie. Następnie przechodzimy do listowania właściwości. Sprawdzamy, czy właściwość pozwala na odczyt - jeśli nie jest do odczytu - ignorujemy. Przy okazji, poza nazwą właściwości, szukamy też zdefiniowanego przez nas atrybutu z opisem. Na koniec wystarczy proste pobranie wartości i - w większości przypadków - konwersja dzięki metodzie ToString. Jeśli jednak chcielibyśmy bardziej zaawansowaną obsługę wyświetlania, w tym typów złożonych bądź innych obiektów, jakie by były we właściwościach, trzeba by oczywiście tą część rozbudować. Tak, jak może mieć to miejsce w przypadku ustawiania wartości. Jednak nim do tego przejdziemy, przeanalizujmy kod tworzenia obiektu danej klasy z konstruktorem, który może posiadać parametry. Warto zwrócić uwagę, że często konstruktor może być dziedziczony, dlatego szukając go, lepiej użyć metody GetMethods, a nie GetDeclaredMethods.
function CreateNew(const aClass: TClass): TObject;
var
ctx: TRttiContext;
rt: TRttiType;
mt: TRttiMethod;
params: TArray>TValue>;
begin
writeln;
writeln('Wybierz obiekt:');
ctx := TRttiContext.Create;
try
rt := ctx.GetType(aClass);
if rt = nil then
Exit(nil);
// szukamy konstruktora:
for mt in rt.GetMethods do
if mt.MethodKind = mkConstructor then
begin
params := GetParameters(mt);
Result := mt.Invoke(aClass, params).AsObject;
Exit;
end;
finally
ctx.Free;
end;
end;
To prosta funkcja, która utworzy obiekt przekazanej klasy wywołując pierwszy napotkany konstruktor (można się pokusić o obsługę przeładowanych konstruktorów).
Używa ona funkcji pobierania parametrów. Zatem jak wygląda wylistowanie i pobranie wartości?
function GetParameters(const aMethod: TRttiMethod): TArray>TValue>;
var
p: TRttiParameter;
attr: TCustomAttribute;
s: String;
paramsList: TList>TValue>;
begin
paramsList := TList>TValue>.Create;
try
// sprawdzamy parametry:
for p in aMethod.GetParameters do
begin
s := p.Name;
limitAttr := nil;
//szukamy atrybutu opisu
for attr in p.GetAttributes do
if attr is OpisAttribute then
s := OpisAttribute(attr).Opis;
s := s + ': ' + p.ParamType.Name;
write(s + ' = ');
repeat
try
readln(s);
paramsList.Add(StrToVal(s, p.ParamType));
Break;
except
on E: Exception do
writeln(E.Message);
end;
until False;
end;
Result := paramsList.ToArray;
finally
paramsList.Free;
end;
end;
Ta funkcja z kolei używa następnej, pozwalającej na konwersję tekstu, jakim podamy parametr do wartości TValue. To właśnie kluczowa funkcja związana z koniecznością odpowiedniej konwersji między typami i poprawnym nadaniem wartości:
function StrToVal(const aParamStr: String; const aParamType: TRttiType): TValue;
var
LElemType,
LElemType2: TRttiType;
lValArray: TArray>TValue>;
lStrArray: TArray>string>;
i: Integer;
ctx: TRttiContext;
EnumOrd: Integer;
SetIntVal: Int64;
begin
case aParamType.TypeKind of
tkInteger:
Result := StrToInt(aParamStr);
tkChar,
tkWChar:
Result := aParamStr[1];
tkEnumeration:
if SameText(aParamType.Name, 'Boolean') then
Result := SameText(aParamStr, 'True') or SameText(aParamStr, '1')
else
begin
ctx := TRttiContext.Create;
try
lElemType := ctx.GetType(aParamType.Handle);
if (lElemType <> nil) and (lElemType is TRttiEnumerationType) then
begin
EnumOrd := GetEnumValue(aParamType.Handle, aParamStr);
if EnumOrd < 0 then
raise EIncorrectValue.CreateFmt('Wrong enum name (%s)', [aParamStr]);
Result := TValue.FromOrdinal(aParamType.Handle, EnumOrd);
end;
finally
ctx.Free;
end;
end;
tkFloat:
if SameText(aParamType.Name, 'TDateTime') or SameText(aParamType.Name, 'TDate') then
Result := System.DateUtils.ISO8601ToDate(aParamStr, False)
else if SameText(aParamType.Name, 'TTime') then
Result := StrToTime(aParamStr)
else
Result := StrToFloat(aParamStr);
tkString,
tkLString,
tkUString,
tkWString:
Result := aParamStr;
tkInt64:
Result := StrToInt64(aParamStr);
tkSet:
begin
LElemType := aParamType.AsSet.ElementType;
lStrArray := aParamStr.Split([',']);
if (lElemType <> nil) and (lElemType is TRttiEnumerationType) then
begin
SetIntVal := 0;
for i := 0 to High(lStrArray) do
begin
EnumOrd := GetEnumValue(lElemType.Handle, lStrArray[i].Trim);
if EnumOrd < 0 then
raise EIncorrectValue.CreateFmt('Wrong enum name (%s)', [lStrArray[i]]);
SetIntVal := SetIntVal or (1 shl EnumOrd);
end;
Tvalue.Make(@SetIntVal, aParamType.Handle, Result);
end;
end;
tkArray,
tkDynArray:
begin
if aParamType is TRttiDynamicArrayType then
LElemType := TRttiDynamicArrayType(aParamType).ElementType
else if aParamType is TRttiArrayType then
LElemType := TRttiArrayType(aParamType).ElementType
else
raise EConvertError.Create('Nieobsługiwany typ tablicy');
lStrArray := aParamStr.Split([',']);
SetLength(lValArray, Length(lStrArray));
for i := 0 to High(lStrArray) do
case LElemType.TypeKind of
tkInteger:
lValArray[I] := StrToInt(lStrArray[i].Trim);
tkChar:
lValArray[I] := lStrArray[i].Trim[1];
tkEnumeration:
if SameText(LElemType.Name, 'Boolean') then
lValArray[I] := SameText(lStrArray[i].Trim, 'True') or (StrToIntDef(lStrArray[i].Trim, 0) > 0)
else
begin
ctx := TRttiContext.Create;
try
lElemType2 := ctx.GetType(lElemType.Handle);
if (lElemType2 <> nil) and (lElemType2 is TRttiEnumerationType) then
begin
EnumOrd := GetEnumValue(lElemType.Handle, lStrArray[i].Trim);
if EnumOrd < 0 then
raise EIncorrectValue.CreateFmt('Wrong enum name (%s)', [lStrArray[i].Trim]);
lValArray[I] := TValue.FromOrdinal(lElemType.Handle, EnumOrd);
end;
finally
ctx.Free;
end;
end;
tkFloat:
if SameText(LElemType.Name, 'TDateTime') or SameText(LElemType.Name, 'TDate') then
lValArray[I] := System.DateUtils.ISO8601ToDate(lStrArray[i].Trim, False)
else if SameText(LElemType.Name, 'TTime') then
lValArray[I] := StrToTime(lStrArray[i].Trim)
else
lValArray[I] := StrToFloat(lStrArray[i].Trim);
tkInt64:
lValArray[I] := StrToInt64(lStrArray[i].Trim);
else
lValArray[I] := lStrArray[i].Trim;
end;
Result := TValue.FromArray(aParamType.Handle, lValArray);
end;
else
raise EConvertError.Create('Nieobsługiwany typ zmiennej');
end;
end;
Choć ta funkcja nie obsługuje wciąż wszystkich typów (wykluczono np. inne obiekty czy rekordy, których sposób wprowadzania musiałby być bardziej złożony, np. w postaci zapisu JSON lub XML), to przedstawia pracę z większością podstawowych typów, w tym z tablicami typów prostych. Warto też zwrócić uwagę na fakt traktowanie typu Boolean jako wyliczeniowego oraz konwersję miedzy nazwami a wartościami typów wyliczeniowych.
Gdzie RTTI ma zastosowanie?
Użycie RTTI z pewnością pozwala na lepszą segregację zadań. Programista zmieniając tylko jeden, swój plik, może wprowadzać daleko idące zmiany, a jednocześnie nie musi przy tym ingerować w inne pliki. Na tej samej zasadzie można dynamicznie tworzyć UI dostarczając wyłącznie obiekty obliczeniowe odpowiednio opisane.
RTTI pozwala także na bardzo swobodną serializację i deserializację danych wg dowolnych standardów czyniąc też taki kod uniwersalnym, mogącym pracować z typami, o których nie ma się pojęcia na etapie projektowania kodu. Dzięki temu kod pozostaje niezmienny, a zmieniają się tylko dane.
Niewątpliwą swobodę daje też przy tworzeniu testów jednostkowych (co wykorzystuje np. framework DUnitX). Ponownie, dobrze opisana i stworzona klasa automatycznie podda się testom bez potrzeby generowania odrębnego kodu testującego.
RTTI pomoże także w prowadzeniu przeglądu kodu (z ang. code rewiev) pomagając zweryfikować trzymania się pewnych założeń, np. dotyczących nazewnictwa, używania określonych typów, konwencji i innych wytycznych szczególnie istotnych przy pracach zbiorowych.
Bez wątpienia daje też ogromny potencjał przy budowie API wraz z automatycznym tworzeniem dokumentacji kodu, co eliminuje zarówno potrzebę dwukrotnej pracy jak i zapewnia znacznie lepszą zgodność i odporność na błędy w dokumentacji (choćby takie dotyczące literówek czy opisu nieistniejących funkcji, albo braku tych, które zostały utworzone), gdyż programista tworząc i opisując kod skupia się wciąż w jednym obszarze.
Innym pomocnym elementem może być stworzenie rozszerzonego narzędzia do logowania błędów, które nie tylko będzie już wyposażone w komunikat czy stos wywołań, ale może dołączać całą konfigurację obiektu, w którym powstaje wyjątek i to niezależnie od miejsca wywołania takiej funkcji logującej.
Przykłady do pobrania
Dostępne są do pobrania przykłady kodu źródłowego obrazujące poruszone powyżej zaganienia. W plikach dpr na początku umieszczono w komentarzach krótki opis, na czym skupia się dany projekt. Dostępnych jest 6 różnych projektów.
Większość projektów bazuje na tych samych plikach np. do pozyskiwania informacji RTTI. Dodatkowo w pliku ConsoleUtils.pas są funkcje, które automatycznie maksymalizują okno konsoli, zmieniają czcionkę czy pozwalają na kolorowanie znaków w systemie Windows. |