Jak mówią inni, najpierw powinieneś zmierzyć wydajność swojego programu i prawdopodobnie nie znajdziesz żadnej różnicy w praktyce.
Mimo to, z poziomu koncepcyjnego, myślałem, że wyjaśnię kilka rzeczy, które są powiązane z twoim pytaniem. Po pierwsze pytasz:
Czy we współczesnych kompilatorach koszty wywołania funkcji nadal mają znaczenie?
Zwróć uwagę na słowa kluczowe „funkcja” i „kompilatory”. Twój cytat jest subtelnie inny:
Pamiętaj, że koszt wywołania metody może być znaczny, w zależności od języka.
Mówi się o metodach w sensie obiektowym.
Podczas gdy „funkcja” i „metoda” są często używane zamiennie, istnieją różnice, jeśli chodzi o ich koszt (o który pytasz) i jeśli chodzi o kompilację (który jest kontekstem, który podałeś).
W szczególności musimy wiedzieć o wysyłce statycznej a dynamicznej . Na razie zignoruję optymalizacje.
W języku takim jak C zwykle wywołujemy funkcje z wysyłaniem statycznym . Na przykład:
int foo(int x) {
return x + 1;
}
int bar(int y) {
return foo(y);
}
int main() {
return bar(42);
}
Gdy kompilator widzi wywołanie foo(y)
, wie, do jakiej funkcji foo
odnosi się nazwa, więc program wyjściowy może przejść bezpośrednio do foo
funkcji, co jest dość tanie. To właśnie oznacza wysyłkę statyczną .
Alternatywą jest dynamiczne wysyłanie , w którym kompilator nie wie, która funkcja jest wywoływana. Jako przykład podajemy kod Haskell (ponieważ odpowiednik C byłby niechlujny!):
foo x = x + 1
bar f x = f x
main = print (bar foo 42)
Tutaj bar
funkcja wywołuje swój argument f
, którym może być cokolwiek. Stąd kompilator nie może po prostu skompilować bar
do instrukcji szybkiego skoku, ponieważ nie wie, do którego skoku. Zamiast tego generowany przez nas kod nie bar
będzie ustalał, f
do której funkcji wskazuje, a następnie przeskoczy do niej. To właśnie oznacza dynamiczna wysyłka .
Oba te przykłady dotyczą funkcji . Wspomniałeś o metodach , które można traktować jako szczególny styl dynamicznie wywoływanej funkcji. Na przykład, oto niektóre Python:
class A:
def __init__(self, x):
self.x = x
def foo(self):
return self.x + 1
def bar(y):
return y.foo()
z = A(42)
bar(z)
y.foo()
Wezwanie wykorzystuje dynamiczne wysyłkę, ponieważ patrzy się wartość foo
właściwości w y
obiekcie, a nazywając cokolwiek znajdzie; nie wie, że y
będzie miała klasę A
lub że A
klasa zawiera foo
metodę, więc nie możemy po prostu przejść do niej od razu.
OK, to podstawowy pomysł. Pamiętaj, że wysyłka statyczna jest szybsza niż wysyłka dynamiczna, niezależnie od tego, czy kompilujemy, czy interpretujemy; wszystko inne jest równe. Dereferencing wiąże się z dodatkowymi kosztami.
Jak to wpływa na nowoczesne, optymalizujące kompilatory?
Pierwszą rzeczą, na którą należy zwrócić uwagę, jest to, że statyczne wysyłanie można zoptymalizować bardziej: gdy wiemy, do której funkcji przeskakujemy, możemy wykonywać takie czynności jak wstawianie. Dzięki dynamicznej wysyłce nie wiemy, że skaczemy do czasu wykonania, więc nie możemy wiele zoptymalizować.
Po drugie, w niektórych językach można wywnioskować, gdzie niektóre dynamiczne wysyłki zakończą przeskakiwanie, a tym samym zoptymalizować je do wysyłki statycznej. Dzięki temu możemy przeprowadzać inne optymalizacje, takie jak wstawianie itp.
W powyższym przykładzie Python takie wnioskowanie jest dość beznadziejne, ponieważ Python pozwala innym kodom na przesłonięcie klas i właściwości, więc trudno jest wywnioskować wiele, które będą obowiązywać we wszystkich przypadkach.
Jeśli nasz język pozwala nam nałożyć więcej ograniczeń, na przykład ograniczając się y
do klasy A
za pomocą adnotacji, moglibyśmy wykorzystać te informacje do wnioskowania o funkcji docelowej. W językach z podklasą (czyli prawie wszystkie języki z klasami!) To w rzeczywistości za mało, ponieważ y
może mieć inną (pod) klasę, więc potrzebowalibyśmy dodatkowych informacji, takich jak final
adnotacje Javy, aby dokładnie wiedzieć, która funkcja zostanie wywołana.
Haskell nie jest językiem OO, ale możemy wywnioskować wartość f
przez inline bar
(co jest statycznie wysłane) do main
podstawiając foo
za y
. Ponieważ cel parametru foo
in main
jest statycznie znany, wywołanie jest statycznie wysyłane i prawdopodobnie zostanie wbudowane i całkowicie zoptymalizowane (ponieważ te funkcje są małe, kompilator jest bardziej skłonny je wbudować; chociaż nie możemy na to liczyć ).
Stąd koszt sprowadza się do:
- Czy język wysyła połączenie statycznie czy dynamicznie?
- Jeśli to drugie, czy język pozwala implementacji na wnioskowanie o celu przy użyciu innych informacji (np. Typów, klas, adnotacji, inlinizacji itp.)?
- Jak agresywnie można zoptymalizować wysyłkę statyczną (wywnioskowaną lub inną)?
Jeśli używasz „bardzo dynamicznego” języka, z dużą ilością dynamicznej wysyłki i kilkoma gwarancjami dostępnymi dla kompilatora, każde połączenie będzie wiązało się z kosztami. Jeśli używasz „bardzo statycznego” języka, dojrzały kompilator wygeneruje bardzo szybki kod. Jeśli jesteś pomiędzy, może to zależeć od twojego stylu kodowania i tego, jak inteligentna jest implementacja.