Kiedyś na blogu już wspomniałem o różnych sposobach wywoływania metod przez CLR. Myślę jednak, że warto przypomnieć sobie te informacje jeśli omawiamy już język IL. W IL istnieją trzy sposoby na wykonanie metod:
- call
- callvirt
- calli
Najważniejsze to te dwie pierwsze. Instrukcja callvirt służy, jak sama nazwa wskazuje, do wykonywania metod wirtualnych. Rozważmy przykład:
public class Employee { public virtual void Print() { } } public class Manager : Employee { public override void Print() { base.Print(); } } class Program { private static void Main() { Employee employee=new Manager(); employee.Print(); } }
Wygenerowany IL będzie wyglądać następująco:
IL_0001: newobj instance void ConsoleApplication11.Manager::.ctor() IL_0006: stloc.0 IL_0007: ldloc.0 IL_0008: callvirt instance void ConsoleApplication11.Employee::Print()
Widzimy, że callvirt jest używany tutaj, aby wykonać metodę Print:
IL_0008: callvirt instance void ConsoleApplication11.Employee::Print()
Jeśli przyjrzymy się powyższej instrukcji, zobaczymy, że w żaden sposób nie przekazuje ona referencji na której chcemy wykonać Print. Wspomniana metoda (Print) nie jest statyczna więc musimy jakoś mieć obiekt this.
Aby to zrozumieć, przejdźmy kilka instrukcji wyżej. Najpierw tworzymy instancje obiektu Manager:
IL_0001: newobj instance void ConsoleApplication11.Manager::.ctor()
Instrukcja newobj umieści nową instancję na stosie (co nie powinno być już zaskoczeniem). Następnie standardowo umieszczamy właśnie stworzoną wartość ze stosu w zmiennej lokalnej, a potem znów ją ładujemy na stos:
IL_0006: stloc.0 IL_0007: ldloc.0
Zmienne lokalne z kolei, zdefiniowane są następująco:
.locals init ( [0] class ConsoleApplication11.Employee employee )
Innymi słowy, callvirt weźmie wartość ze stosu i użyje jej jako this. callvirt potrafi wyznaczyć, która metoda powinna być wykonana za pomocą virtual method table. Z tego względu, dla wszystkich metod wirtualnych konieczne jest użycie callvirt a nie call. Instrukcja call nie korzysta z virtual method table, stąd nie wiadomo byłoby, która metoda powinna być wykonana.
Ponadto callvirt wyrzuca kilka intersujących wyjątków:
1. NullReferenceException – sprawdzane jest, czy obiekt na którym wykonujemy metodę ma jakąś wartość. W praktyce oznacza to, że następujący kod wyrzuci NullReferenceException:
Employee employee = null; employee.Print();
Skutek:
Dla większości programistów jest to oczywiste, ale jak zobaczymy później, nie powinno to być takie naturalne, że wywołanie obiektu na NULL wyrzuca wyjątek.
2. SecurityException – brak uprawnień
3. MissingMethodException
Kolejna instrukcja call służy do wykonania niewirtualnych metod. Klasycznym przykładem są metody statyczne:
public class Employee { public virtual void Print() { } public static void StaticPrint() { } } class Program { private static void Main() { Employee.StaticPrint(); } }
IL:
IL_0001: call void ConsoleApplication11.Employee::StaticPrint()
Tutaj bardzo ważna uwaga. Call nie sprawdza czy referencja jest równa NULL. W przypadku metod statycznych, nie ma to znaczenia, ale co z niewirtualnymi instance methods?
C#:
public class Employee { public void InstancePrint() { } } class Program { private static void Main() { Employee employee = null; employee.InstancePrint(); } }
IL:
IL_0001: ldnull IL_0002: stloc.0 IL_0003: ldloc.0 IL_0004: callvirt instance void ConsoleApplication11.Employee::InstancePrint()
I tutaj niespodzianka… Wywołujemy niestatyczną metodę a korzystamy z callvirt. Dlaczego? W końcu nie potrzebujemy korzystać z virtual table method. Twórcy CLR zdecydowali jednak się na taki krok, aby uniknąć sytuacji, gdzie wykonujemy metodę na NULL i nie powoduje to wyjątku. Byłoby to bardzo nieintuicyjne zachowanie. Niestety jednak, istnieją sytuacje, w których nie unikniemy tego problemu.
Metody rozszerzające są klasami statycznymi, a więc będą na pewno wykonane przez call. Niestety operują one czasami na klasycznych obiektach. Może zdarzyć się, że wykonamy metodę na referencji wskazującym na NULL. Przykład:
public class Employee { public string Text = "Hello World"; } static class EmployeeExtensions { public static void Print(this Employee employee) { Console.WriteLine("Ten kod wykona sie bez problemu"); Console.WriteLine(employee.Text); } } class Program { private static void Main() { Employee employee = null; employee.Print(); } }
Powyższy kod wywoła Print bez wyjątku. Nie jest sprawdzane czy obiekt równa się NULL. Dopiero próba dostępu do Text spowoduje wyjątek:
Trzeba mieć to na uwadze bo może spowodować to nieoczekiwane efekty w aplikacji.
IL do powyższego kodu:
IL_0000: nop IL_0001: ldnull IL_0002: stloc.0 IL_0003: ldloc.0 IL_0004: call void ConsoleApplication11.EmployeeExtensions::Print(class ConsoleApplication11.Employee) IL_0009: nop IL_000a: ret
Widać wyraźnie, że call został użyty. Podsumowując, call jest dla statycznych metod a callvirt dla instance-methods (nawet tych niewirtualnych!).
Póki co, zajęliśmy się wyłącznie metodami bez parametrów i wartości zwracanej. Prześledźmy następujący kod:
public class Employee { public string Print(string text) { Console.WriteLine(text); return text; } } class Program { private static void Main() { Employee employee = new Employee(); employee.Print("Hello World"); } }
IL:
IL_0001: newobj instance void ConsoleApplication11.Employee::.ctor() IL_0006: stloc.0 IL_0007: ldloc.0 IL_0008: ldstr "Hello World" IL_000d: callvirt instance string ConsoleApplication11.Employee::Print(string)
Po kolei:
1. 0001 – tworzymy instancję obiektu
2. 0006 – przechowujemy obiekt w zmiennej lokalnej
3. 0007 – umieszczamy zmienną lokalną na stosie
4. 0008 – przechowuje na stosie napis “Hello World”
5. 000d – wywołujemy (callvirt) metodę Print. Na stosie zatem będziemy mieli obiekt this i parametr wejściowy “Hello World”.
Nie powinno budzić wątpliwości fakt, że parametry wejściowe, jak wszystko inne umieszczane są na stosie. Tak samo wynik (wartość zwracana) jest umieszczona na stosie. Powyższy fragment kodu nie pokazuje tego, ale return tak naprawdę umieści argument na stosie, który potem można zdjąć i wyświetlić jak każdą inną wartość.
Przejdźmy teraz do ciała metody Print, aby przekonać się jak argumenty są używane:
IL_0001: ldarg.1 IL_0002: call void [mscorlib]System.Console::WriteLine(string) IL_0007: nop IL_0008: ldarg.1 IL_0009: stloc.0 IL_000a: br.s IL_000c IL_000c: ldloc.0 IL_000d: ret
ldarg załaduje parametr wejściowy o określonym indeksie na stos. Z kolei ret zwraca wartość i wykonanie. Reszta instrukcji powinna być znana (w debug trochę śmieci jest wygenerowanych). Ret zwraca wykonanie, ale sami powinniśmy umieścić zwracaną wartość na stosie (tutaj robimy to za pomocą ldloc.0, w której znajduje się po prostu parametr wejściowy wcześniej załadowany). Gdy funkcja zwraca void, wtedy po prostu nic nie umieszczamy na stosie.
Instrukcja calli jest najrzadziej spotykana i służy do wykonywania metod w sposób pośredni – przez wskaźnik (interop). Innymi słowy, calli wywołuje metodę, która jest określona jako wskaźnik znajdujący się na stosie. Poprzednie instrukcje wywoływały jawnie konkretne metody.