Call by value, call by name

W języku Java argumenty do metod przekazywane są techniką call by value. Język Scala pozwala dodatkowo na zastosowanie metody call by name. W jaki sposób działa call by name? Co nam daje? W jaki sposób użyc tej techniki w Scali? Czy w Javie możemy uzyskać podobny efekt a jeśli tak to w jakis sposób? Na te pytania postaram się odpowiedzieć w poniższym artykule.

Na początek szybkie przypomnienie:

  • parametr funkcji, jest to to co znajduje się w sygnaturze – nazwa po której identyfikujemy zmienną wewnątrz funkcji.
  • argument funkcji jest to to co podajemy do niej wywołując ją.

Call by value, call by name – od strony teoretycznej

Wywołując funkcję, podajemy jej argumenty. Czasami są to stałe. Czasami są to zmienne. Nierzadko są to większe wyrażenia. Koniec końców w czasie wykonywania programu musi nastąpić moment w którym to co podaliśmy jako argument do funkcji kończy jako faktyczna wartość parametru wewnątrz funkcji. Styl w jaki odbywa się ta operacja nazywany jest strategią ewaluacji (evaluation strategy). Strategia ta musi definiować kiedy i w jaki sposób wyrażenie podane jako argument funkcji zostanie obliczone.

Istnieje całkiem sporo takich strategii – ale najpopularniejsze są dwie ich grupy: strict evaluations oraz non-strict evaluations. W przypadku ewaluacji typu strict wartość argumentu będzie znana zanim funkcja zostanie wywołana. W przypadku ewaluacji non-strict jest na odwrót – wartości argumentów nie będą znane przed wywołaniem funkcji.

Zarówno Java jak i Scala korzystają ze strategii call by value. Jest to jedna z ewaluacji typu strict i najprościej rzecz ujmując polega ona na tym, że przed wywołaniem funkcji wyrażenie podane jako argument jest obliczane a jego wynik kopiowany do parametru wewnątrz funkcji. W Javie w ten sposób przekazuje się zarówno prymitywy jak i referencje do obiektów.

To czego nie ma w Javie a co możemy zrobić w Scali to skorzystać z ewaluacji typu call by name. Jest to jedna ze strategii non-strict. Wewnątrz funkcji w każdym miejscu w którym chcielibyśmy użyć danego parametru – zostanie podstawione całe wyrażenie przekazane jako argument do funkcji. 

Call by name w Scali – składnia

Z punktu widzenia składni języka Scala musimy wprowadzić minimalną zmianę nagłówka funkcji. Kiedy zwykła funkcja wygląda tak:

def simplePrint(x:Int) = {
	println(x)
}

To aby użyć strategii call by name wystarczy wstawić “strzałkę” między nazwę argumentu a jego typ.

def simplePrint(x: => Int) = {
	println(x)
}
I to wszystko.

Zastosowanie

Z tego w jaki sposób traktowane są argumenty przy call by name wynikają dwie podstawowe rzeczy:

Po pierwsze – ponieważ ewaluacja argumentu jest przeniesiona do momentu jego użycia, to jest to ewaluacja typu lazy. Oznacza to, że w szczególnym wypadku ewaluacja może nie nastąpić. Jest to przydatne w momencie gdy z naszym argumentem związane są zasobożerne obliczenia. Call by name odroczy je do momentu gdy naprawdę będziemy tego potrzebować – a jeśli nie będziemy potrzebować ich w ogóle, to obliczenia nie zostaną przeprowadzone.

Poniżej infantylny przykład

def forZero(): String = {
	// some time consuming computation...
	"Zero"
}

def forNonZero(): String = {
	// some time consuming computation...
	"Non zero"
}

def isZero(number:Int, zeroMessage: String, nonZeroMessage: String) = {
	if (number == 0) println(zeroMessage) else print(nonZeroMessage)
}

def main(args: Array[String]) {
	isZero(0, forZero, forNonZero)
}

Po drugie,  ponieważ parametr zostanie zastąpiony przekazanym do funkcji wyrażeniem, to za każdym razem kiedy będziemy chcieli skorzystać z parametru – całe wyrażenie będzie obliczane na nowo. W pewnych przypadkach może to wprowadzać problem wydajnościowy, jednak czasami takie zachowanie jest dla nas pożądane.

Poniżej przykład prostego wyświetlania wszystkich elementów listy. Wewnątrz funkcji nie musimy wiedzieć skąd biorą się elementy i jak sprawdzić kiedy się skończą. Po prostu w pętli wyświetlamy wartości tak długo aż warunek jest prawdziwy. W tym wypadku z zewnątrz przekazane są metody iteratora, ale równie dobrze mogłoby to być cokolwiek o podobnej funkcjonalności (nawet jakiś zdalny serwis).

def forEachPrint(hasNext: => Boolean, element: => Int) = while(hasNext) println(element)

def main(args: Array[String]) {
	val list = 1 to 100 toList
	val it = list iterator;
	forEachPrint(it hasNext, it next)
}

Inny przykład – a w zasadzie lekko zmodyfikowany przykład poprzedni.

def forEachCurr(time: => Long)(hasNext: => Boolean, element: => Int) = {
	while(hasNext) println(time + " " + element)
}

def main(args: Array[String]) {

	val forEachPrint: (=> Boolean, =>Int) => Unit = forEachCurr(System.currentTimeMillis);

	// later on...

	val list = 1 to 100 toList
	val it = list iterator;
	forEachPrint( it hasNext, it next)

}

W pierwszym wywołaniu przekazujemy System.currentTimeMillis, który za każdym wywołaniem zwraca nam aktualny timestamp (bo przekazaliśmy go call by name). Samo printowanie elementów jest odroczone na później dzięki mechanizmowi curryingu. Możemy więc podjąć decyzje o wykorzystaniu System.currentTimeMillis jako “providera” timestampu już na samym początku programu a np. dużo, dużo później gdy zajdzie taka potrzeba – wyświetlać elementy jakiejś listy.

Zyskujemy na czytelności, mimo że funkcja ostatecznie przyjmuje 3 parametry to jednak moment wywołania iteracji nie jest “zaśmiecony” informacją o pobieraniu czasu (Currying jest w ogóle dość ciekawym zagadnieniem, ale o tym może innym razem).

Czy można zasymulować call by name w Javie?

W Javie, technicznie rzecz biorąc, mamy tylko przekazywanie parametrów techniką call by value. Jeżeli jendak się na tym zastanowić to można zasymulować oba efekty (wywoływanie lazy i każdorazowa ewaluacja) wykorzystując interfejsy funkcyjne. Zapis nie będzie tak czysty jak w Scali, ale wciąż jest to możliwe. Spróbujmy powtórzyć wcześniejszy infantylny przykład z testem na zero.

public static String forZero() {
	// some time consuming computation...
	return "Zero" ;
}

public static String forNonZero() {
	// some time consuming computation...
	return "Non zero" ;
}

public static void isZero(int number, Supplier<String> zeroMessage, Supplier<String> nonZeroMessage ) {
	if(number == 0) {
		System. out.println(zeroMessage .get());
	}
	else {
		System. out.println(nonZeroMessage .get());
	}
}

public static void main(String[] args) {
	isZero(0, CallByNameSimulation::forZero, CallByNameSimulation::forNonZero);
}

Dzięki temu, że cała “logika” jest zamknięta wewnątrz funkcji to nie zostanie ona wykonana tak długo aż nie wywołamy get z interfejsu Supplier. Warto zwrócić uwagę, że z punktu widzenia składni nie jesteśmy w dużo gorszej sytuacji niż Scala – konieczność ręcznego wywołania metody get nie powoduje powstania śmietnika w kodzie (myślę, że wręcz ktoś mógłby stwierdzić, że dużo trudniej przeoczyć taki styl wywołania w Javie niż strzałkę w sygnaturze metody w Scali).

Spróbujmy teraz powtórzyć przykład z iteratorem.

public static void main(String[] args) {
	List<Integer> list = IntStream. rangeClosed(0, 100).boxed().collect(Collectors.toList());
	Iterator<Integer> it = list.iterator();
	forEachPrint(it::hasNext, it::next);
}

public static void forEachPrint(Supplier<Boolean> hasNext, Supplier<Integer> next ) {
	while(hasNext.get()) {
		System.out.println(next.get());
	}
}

Nie jest źle. Właściwie jedyne co bym mógł chcieć zmienić to zastąpienie interfejsu Supplier dla hasNext czymś bardziej sugestywnym.

Podsumowanie

Call by name jest jedną ze strategii ewaluacji. Dzięki niej wyrażenie przekazane do funkcji, zamiast obliczania, zostanie skopiowane tam gdzie ma być użyte. Dzięki temu odraczamy ewaluacje w czasie  – w szczególności może ono nigdy nie nastąpić. Każde użycie parametru wewnątrz funkcji sprawi, że wyrażenie zostanie obliczone na nowo.

W Scali możemy użyć tej techniki po przez prostą zmianę sygnatury funkcji. W Javie 8 możemy jedynie symulować ten efekt – w tym celu musimy opakować nasze wyrażenie interfejsem funkcyjnym.