Wstęp: Jeśli jeszcze nie bywacie na devspl.slack.com to zdecydowanie polecam. Z obecnie istniejących tam 175 kanałów na pewno wybierzecie coś dla siebie. Ja zaglądam regularnie na kilka, w tym na kanał #dotnet. A tam czasem pojawiają się naprawdę ciekawe pytania! Co najmniej dwa zainspirowały mnie na tyle, że postanowiłem zainicjować nową serię pt. Tajemnice CLR. Odpowiedzi na ciekawe pytania i rozterki, na jakie się natknę. Jeśli macie pomysły - pytajcie! Czy to tu, czy na wspomnianym Slacku. Wchodzić z odpowiedziami będziemy głęboko, nie będzie tu żadnego ciu ciu babciu.
Dziś pierwsze pytanie, które zadała Angelika Piątkowska:
Jak tak naprawdę działa GetType()?
Rozszerzając to pytanie: Skąd obiekt wie jakiego jest typu? Czy to wie kompilator czy runtime? Jak się do tego ma CLR? Czy z faktu, że C# jest statycznie i silnie typowany wynika, że wywołania metody GetType() tak naprawdę nie istnieją i kompilator może je zastąpić odpowiednim wynikiem już w trakcie kompilacji?
Szybko możemy sobie odpowiedzieć na ostatnie pytanie, empirycznie:
object o = new Random().Next(2) == 0 ? new BaseClass() : new DerivedClass();
Jaki jest wynik o.GetType()? Nie wiadomo tego ani w trakcie kompilacji, ani w trakcie JITowania kodu. Dopiero w trakcie wykonania. Szukamy więc dalej.
Jeśli spojrzymy w .NET Framework Reference Source na źródło metody Object.GetType() to szybko okaże się, że niczego się tam nie dowiemy:
// Returns a Type object which represent this object instance.
[MethodImplAttribute(MethodImplOptions.InternalCall)]
public extern Type GetType();
Zwróćmy uwagę, że metoda ta nie jest oznaczona jako wirtualna, ale zachowuje się jak wirtualna - dla każdego obiektu zwraca faktyczny typ. Jest to zasługa wewnętrznej implementacji. Atrybut z wartością InternalCall oznacza, że metoda została zaimplementowana wewnętrznie w CLR. Dzięki CoreCLR możemy zajrzeć głębiej. Jeśli chcemy odnaleźć wewnętrzną implementację funkcji InternalCall, sięgamy w źródłach CoreCLR do pliku .\src\vm\ecalllist.h gdzie znajduje się odpowiednie mapowanie. W naszym przypadku jest to:
FCFuncStart(gObjectFuncs)
FCIntrinsic("GetType", ObjectNative::GetClass, CORINFO_INTRINSIC_Object_GetType)
FCFuncElement("MemberwiseClone", ObjectNative::Clone)
FCFuncEnd()
I w ten sposób trafiamy na implementację (tu i dalej wycinam różne niekoniecznie istotne fragmenty):
// This routine is called by the Object.GetType() routine. It is a major way to get the Sytem.Type
FCIMPL1(Object*, ObjectNative::GetClass, Object* pThis)
{
// ...
OBJECTREF objRef = ObjectToOBJECTREF(pThis);
if (objRef != NULL)
{
MethodTable* pMT = objRef->GetMethodTable();
OBJECTREF typePtr = pMT->GetManagedClassObjectIfExists();
if (typePtr != NULL)
{
return OBJECTREFToObject(typePtr);
}
}
else
FCThrow(kNullReferenceException);
FC_INNER_RETURN(Object*, GetClassHelper(objRef));
}
FCIMPLEND
W skrócie, to co tu widzimy to pobranie tzw. MethodTable danego obiektu (Object::GetMethodTable) i zwrócenie odpowiadającego mu obiektu Type (MethodTable::GetManagedClassObjectIfExists) lub utworzenie go jeśli jeszcze nie istnieje (GetClassHelper) 1). Tutaj dla przejrzystości musimy chwilę odsapnąć i rozdzielić nasze rozważania na kilka kroków/opcji.
MethodTable
Nierozłącznym elementem obsługi typów w CLR jest tzw. MethodTable czyli struktura danych opisująca dany typ. Struktury te są trzymane w wydzielonym obszarze pamięci. Opisują m.in. jakie metody zawiera dany typ, jakie interfejsy implementuje itd. Nie miejsce tu szczegółowo opisywać tą strukturę. Przyjmijmy po prostu, że MethodTable to taki wewnętrzny opis typu 2). Patrząc na klasę reprezentującą obiekty w pamięci (te znajdujące się na stercie) zauważymy, że jej pierwszym elementem jest wskaźnik właśnie na MethodTable:
// code:Object is the respesentation of an managed object on the GC heap.
//
// See code:#ObjectModel for some important subclasses of code:Object
//
// The only fields mandated by all objects are
//
// * a pointer to the code:MethodTable at offset 0
// * a poiner to a code:ObjHeader at a negative offset. This is often zero. It holds information that
// any addition information that we might need to attach to arbitrary objects.
//
class Object
{
protected:
PTR_MethodTable m_pMethTab;
// ...
Widoczna wcześniej metoda GetMethodTable zwraca zaś po prostu ten wskaźnik:
PTR_MethodTable Object::GetMethodTable() const
{
// ...
return m_pMethTab;
// ...
}
Każdy obiekt znajdujący się na stercie ma w pamięci konkretną reprezentację - rozmiary różnią się w zależności czy mówimy o wersji 32 czy 64 bit:
Obok samych danych mamy zatem jeszcze miejsce na wspomniany wskaźnik MethodTable i właśnie na to miejsce wskazują wszelkie referencje do obiektu z innych obiektów. Przed tym adresem znajduje się nagłówek obiektu. Często może składać się z samych zer. Ale może też zostać użyty m.in. do mechanizmów synchronizacji na danym obiekcie (ID wątku który "zalockował" dany obiekt - tzw. ThinLock albo indeks w tablicy zawierającej informacje o pełnoprawnych lockach). Tak czy inaczej, wiemy już że pierwszym krokiem jest po prostu pobranie adresu znajdującego się na początku danego obiektu - wskazującego na MethodTable.
Type
Aby uzyskać obiekt Type reprezentujący dany MethodTable, wykonywana jest metoda OBJECTREF MethodTable::GetManagedClassObjectIfExists która sięga do wewnętrznych struktur sprawdzając czy obiekt taki nie został już utworzony. Jeżeli zaś obiekt nie istnieje to pośrednio wołany jest MethodTable::GetManagedClassObject, który go utworzy. Tak czy inaczej w efekcie dostajemy wskaźnik na obiekt, który w kodzie zarządzanym stanie się referencją do odpowiedniego obiektu Type.
I to właściwie tyle. Widzimy tu, że za GetType() nie stoi specjalnie skomplikowana implementacja. Jest to sięgnięcie do pewnych struktur, które są przypisane do każdego obiektu na stercie. Co zaś z obiektami na stosie? O tym dalej.
Obiekty na stosie
Na stosie występują obiekty typu wartościowego (Value Type). Trzeba tu podkreślić, że tak naprawdę jest to ich szczegół implementacyjny, a nie cecha charakterystyczna. Mają one w pamięci znacznie prostszą reprezentację - składają się tylko ze swoich wartości:
Innymi słowy w pamięci taki obiekt nie trzyma przy sobie nigdzie informacji o swoim typie. Nie znaczy to, że MethodTable typów wartościowych nie istnieje. Nie jest po prostu potrzebne jego "przyklejanie" do obiektu, bowiem kompilator jest tu w stanie stwierdzić znacznie więcej - z powodu braku dziedziczenia, dokładny typ jest tak naprawdę znany już w trakcie kompilacji. No i teraz pytanie, jak to wszystko działa, że możemy wykonać GetType() na typie wartościowym jak w poniższym przykładzie:
struct MyStruct { int x; int y; }
void Main()
{
MyStruct s = new MyStruct();
var t = s.GetType();
}
Skąd "coś" wie czym jest s? Może tutaj kompilator/JIT zastępuje wywołanie metody odpowiednim wynikiem? Teoretycznie mogłoby ale popatrzmy na CIL jaki zostanie wygenerowany z tego przykładu:
IL_0001: ldloca.s 00 // s
IL_0003: initobj UserQuery.MyStruct
IL_0009: ldloc.0 // s
IL_000A: box UserQuery.MyStruct
IL_000F: call System.Object.GetType
IL_0014: stloc.1 // t
Odpowiedź jest szybko widoczna. Przed wywołaniem metody GetType() następuje boxing typu wartościowego, a typ ten jest znany kompilatorowi. Kompilator wykonując operację boxowania alokuje na stercie nowy obiekt, który ma znany nam już układ w pamięci, w centrum którego znajduje się odpowiedni MethodTable:
HCIMPL2(Object*, JIT_Box, CORINFO_CLASS_HANDLE type, void* unboxedData)
{
TypeHandle clsHnd(type);
MethodTable *pMT = clsHnd.AsMethodTable();
// ..
newobj = pMT->FastBox(&unboxedData);
return(OBJECTREFToObject(newobj));
}
HCIMPLEND
OBJECTREF MethodTable::FastBox(void** data)
{
// ..
OBJECTREF ref = Allocate();
CopyValueClass(ref->UnBox(), *data, this, ref->GetAppDomain());
return ref;
}
Dalej już wywołanie GetType() postępuje normalnie. Skoro zboksowany obiekt ma układ, o którym pisaliśmy wcześniej, to możemy użyć standardowego mechanizmu Object.GetType() czyli pobrać jego MethodTable i zwrócić odpowiadający mu obiekt Type.
Tyle podstaw związanych z działaniem GetType(). Jeśli macie jakieś pytania, pytajcie!
PS. W trakcie odpowiadania na to pytanie rodzi się kilka kolejnych, na które postaram się odpowiedzieć w przyszłości:
- Czemu CLR nie wspiera dziedziczenia struktur?
- Jak obsługiwany jest typ dynamic i czy łamie jakoś opisane tu zasady?
- A co jeśli w swoim typie zrobię public new Type GetType() { return typeof(string); }
- Jak wygląda MethodTable i cała powiązania z nią struktura danych? Kiedy powstaje i w jakim obszarze pamięci?
- Skoro kompilator dobrze wie jaki jest dokładnie typ wartościowy, czemu nie zastąpi wywołania GetType() od razu wynikiem?
- Jak wygląda w pamięci obiekt class C {} oraz struct S {} (czyli pusty)? Ile pamięci zajmuje?
Przypisy:
1) Widoczny warunek na wynik ObjectToOBJECTREF możemy tak naprawdę pominąć bo w trybie Release OBJECTREF jest aliasem na Object* i makro to ma po prostu postać:
#define ObjectToOBJECTREF(obj) ((PTR_Object) (obj))
W trybie Debug OBJECTREF (dla zapaleńców .\src\vm\vars.hpp) to klasa obudowująca Object* z pewną dodatkową diagnostyką ("we use operator overloading to detect common programming mistakes that create GC holes").
2) Tak naprawdę sprawy są oczywiście trochę bardziej skomplikowane i MethodTable to tylko punkt wejścia do większej struktury zawierającej informacje o typie.