* Затваряния (Closures)
Публикувано на 12 март 2015 в раздел ПИК3 Java.
Един интересен момент при писането на локални класове е свързан с достъпа до променливи и обекти, които са извън блока дефиниращ локалния клас. Оказва се, че локалните класове (и анонимните в частност) имат достъп до локални променливи и референции към обекти, които са извън техния обхват. Това важи също така и за новия по-модерен вариант с ламбда изрази в Java 8 (те са анонимни класове в крайна сметка). Има обаче едно сериозно ограничение - променливите или референциите трябва да са final. Нека дадем един първи елементарен пример:
public class Example{ public static void main(String[] args){ int var = 5; VarPrinter vp = new VarPrinter(){ public void printVar(){ System.out.println(var); } }; vp.printVar(); } } @FunctionalInterface interface VarPrinter{ void printVar(); }
Виждаме, че променливата var е дефинирана извън блока на метод "printVar", но въпреки това printVar я "вижда". Това не е неестествено - все пак тази променлива е в обграждащ блок. Сега обаче ще се сблъскаме с един интересен проблем - искаме не просто да четем променливата "var", но искаме и да я променим. Ще видим, че това не може да стане:
public class Example{ public static void main(String[] args){ int var = 5; VarPrinter vp = new VarPrinter(){ public void printVar(){ System.out.println(var++); } }; vp.printVar(); } } @FunctionalInterface interface VarPrinter{ void printVar(); }
Грешката от компилатора ще бъде "Error: local variables referenced from an inner class must be final or effectively final" - това се получава заради операцията "++", която променя самата променлива. Казано по друг начин - дори променливата да не е директно дефинирана като "final", тя вътре в локалния клас се достъпва като такава. Защо това е така? Въпреки, че това не е очевидно от горния код - има една привидно добра причина Java да забранява промяната на променливите, които са извън обсега на локалния клас: това няма да е "thread safe" (т.е. може да създаде сериозни конфликти в многонишкова среда). Представете си следното (долният код няма да се компилира):
int var = 5; Runnable changer = () -> { System.out.println(++var); }; Thread t1 = new Thread(changer); Thread t2 = new Thread(changer); t1.start(); t2.start();
Очевидно е, че ако операцията ++ беше позволена, двете нишки спокойно могат да попаднат в race condition. Всъщност дори първият ни пример е леко некоректен, защото той ще се компилира само в Java 8 - във всички предишни версии важи правилото, че "локален клас може да достъпва само final променливи и референции от обграждащия ги блок". В Java 8 ограничението за final е отпаднало, за да се даде път за по-лесно внедряване на ламбда изразите, но ограничението за промяна продължава да важи.
Това ограничение обаче не важи за статични променливи или член променливи. То е само за локални променливи. С долния пример ще демонстрираме race condition именно по този начин:
public class Example{ static int var = 0; public static void main(String[] args){ Runnable changer = () -> { for(int i=0; i<10000; i++){ var++; } }; Thread t1 = new Thread(changer); Thread t2 = new Thread(changer); t1.start(); t2.start(); try{ t1.join(); t2.join(); }catch(Exception e){} System.out.println(var); } }
Ще видите, че при всяко извикване на програмата ще се получава различен краен резултат. Просто операцията "++" не е атомарна. Дори да промените променливата да бъде "volatile", това няма да промени този факт. Решението на подобни казуси е извън обхвата на тази статия, но вие вече би трябвало да го знаете - използване на синхронизация чрез обекти или със синхронизирани методи и т.н.
Как да заобиколим това ограничение? Класическият начин е като спрем да използваме примитивни типове данни, а вместо това използваме обекти:
public class Example{ public static void main(String[] args){ int[] var = {5}; VarPrinter vp = () -> System.out.println(++var[0]); vp.printVar(); } } @FunctionalInterface interface VarPrinter{ void printVar(); }
Вече стойността e записана в heap чрез обект от тип масив, а самата променлива "var" е референтен тип. Ние не бихме могли да променим "var", но няма проблем да променим стойността на променливата, към която var сочи в heap паметта.
Ограничението за промяна на локални променливи на този етап започва да ни се струва меко казано изкуствено. И това наистина е така - явно не е въведено, за да ни предпазва от многонишкови race conditions, а е свързано с нещо друго. Ако искаха да ни защитават от race condition, щяха да го направят и с обектите, и със статичните променливи, и с член променливите, а не само с локалните променливи.
Преди да отговорим, нека разгледаме един интересен и на първо четене доста странен пример. Имаме метод, който получава като входен параметър референция към обект от тип String. Входните параметри на методите се предават по стойност, т.е. те се пазят в референции като локални променливи. Ще предадем този входен параметър на един Runnable обект, който ще започне да го печата в конзолата до безкрайност:
public class Example{ public static void main(String[] args){ repeatMessage("Hello"); System.out.println("We are out of the repeatMessage method"); } public static void repeatMessage(String text) { Runnable r = () -> { while(true){ System.out.println(text); try{ Thread.sleep(500); } catch(InterruptedException e){ return; } } }; new Thread(r).start(); } }
Получава се нещо интересно - локалната променлива "text" се чете от нишката и се печати в конзолата въпреки, че метода вече е завършил своето действие ("We are out of the repeatMessage method" ще се отпечати на екрана, т.е. метода е приключил своето действие). Да, но ние знаем, че локалните променливи се "изтриват" при излизане от блока, в който са дефинирани. Откъде тогава анонимната нишка, която пуснахме, продължава да получава текста, който печати в конзолата?
За да разберем какво всъщност се случва при подобни "затваряния" (closures), трябва да си изясним какво всъщност се случва "зад кулисите". Независимо дали реализирате горното с ламбда израз или с анонимен клас, вие трябва да знаете, че реално в Java се заделя допълнителна памет за т.нар. "свободни променливи" (free variables) към локалния клас. Това, което се случва, е че локалната променлива (референция към обект в нашия пример) се копира по стойност точно в това пространство от допълнителна памет, заемана от анонимния обект! Тоест анонимният обект в случая на затваряне (closure) работи не с оригиналните данни, а с техни копия! Именно това е причината в Java да има ограничение за това локалните променливи достъпвани извън блока на локалния клас да са имплицитно (Java 8) или експлицитно (Java 7 и по-стари) final. Ако те не са final, може да бъдат променени от локалния клас по един начин, а в обграждащия го блок по друг и да получим несъответствие - работим уж с една и съща променлива, а стойностите ѝ са различни по едно и също време. Това е причината за ограничението final.
Най-интересно обаче си остава отпадането на ограничението за експлицитно означаване на достъпваната локална променлива като final. Оказва се, че Java 8 ни позволява да не е final, но независимо къде я променим - в локалния клас или извън него - компилатора ще даде грешката "Error: local variables referenced from an inner class must be final or effectively final". Тоест или не трябва да променяте тези променливи, или трябва да ги направите непроменими. В Java под "effectively final" се има предвид такава променлива, която е дефинирана веднъж и никога не е променена след това - дори без ключова дума final, тя e ефективно final, защото никога не се променя.
Накрая нека покажем друго съществено ограничение. Не можем да дефинираме променлива вътре в локален клас ако вече съществува локална променлива със същото име в обграждащия клас. Следният код няма да се компилира:
int[] var = {5}; VarPrinter vp = () -> { int[] var = {0}; System.out.println(++var[0]); };
Грешката ще бъде "Error: variable var is already defined in method main(java.lang.String[])". Това може да изглежда много объркващо, защото еквивалентът на този ламбда израз, написан с анонимен клас, ще работи:
int[] var = {5}; VarPrinter vp = new VarPrinter(){ public void printVar(){ int[] var = {0}; System.out.println(++var[0]); } }; vp.printVar();
Изходът ще е числото 1, т.е. локалният анонимен клас е дал предимство на референцията в своя собствен метод. Реално в такава ситуация - дублиращи се имена в ламбда израз - вие не можете да достъпите външната за класа променлива. Все пак тук виждаме една реална разлика между анонимен клас и еквивалента му във вид на ламбда израз - очевидно е, че компилатора ги третира по различен начин, въпреки че на теория би трябвало да са равнозначни.
Защо я има тази разлика? Нали ламбда израза беше еквивалент на анонимния клас, а сега се оказва, че не е? Това е малък трик от разработчиците на Java. По този начин те се опитват допълнително да маскират ламбда изразите и да ги направят да изглеждат като методи (функции), а не като обекти. И реално това е така - няма проблем да имате глобална за даден метод променлива (т.е. чрен променлива) и да дефинирате допълнително локална променлива със същото име. Ако такова нещо не беше забранено в ламбда изразите, това щеше да ги "издаде", че са анонимни обекти, а не функции.
Накрая ще дадем и един по-практичен пример за това къде затварянията получават приложение. Нека имаме списък с низове. Искаме да направим нов списък, който съдържа само низовете по-къси от подадено от клавиатурата число N. За да постигнем целта искаме да създадем интерфейс предикат - чрез такъв предикат ще тестваме дали даден низ е по-къс или по-дълъг от N. Знаем, че при стандартния функционален интерфейс Predicate, метод "test" получава един входен параметър - обекта, върху който се извършва теста. Ние не можем да подадем на Predicate.test и числото N, защото то ще е втори входен параметър, а такъв в интерфейса няма. Да правим нов интерфейс за всеки различен филтър естествено в реална ситуация би било непрактично. Затова вместо да правим нов интерфейс, ние ще използваме вградения - Predicate - и ще го накараме да затвори (да направи closure) на числото N:
import java.util.function.Predicate; import java.util.List; import java.util.ArrayList; import java.util.Scanner; public class ClosureExample{ public static void main(String[] args){ // Това е просто масив с някакви обекти - в случая String ArrayList<String> list = new ArrayList<String>(4); list.add("My"); list.add("name"); list.add("is"); list.add("Philip"); System.out.println(">>> The current list contains:"); ListUtil.print(list); // Ще искаме да премахнем тези Strings, които са по-къси от число N System.out.println("\n>>> We will filter words with less letters than N"); System.out.print(">>> Enter N (must be integer): "); Scanner keyboardIn = new Scanner(System.in); int N = keyboardIn.nextInt(); // Създаваме си предикат Predicate<String> wordIsShorterThanNSymbols = new Predicate<String>(){ public boolean test(String str){ // Ето къде правим Closure - взимаме променливата N отвън if(str.length()<=N) return true; else return false; } }; // Тук вече се възползваме от създадения предикат ArrayList<String> filteredList = ListUtil.filter(list, wordIsShorterThanNSymbols); System.out.println(">>> Your filtered list contains:"); ListUtil.print(filteredList); } } class ListUtil{ static <T> ArrayList<T> filter(List<T> sourceList, Predicate<T> predicate){ ArrayList<T> resultList = new ArrayList<T>(); for(T element: sourceList){ if(predicate.test(element)) resultList.add(element); } return resultList; } static <T> void print(List<T> list){ for(T element: list) System.out.println(element); } }
Какво постигнахме в крайна сметка с всичко това? На този етап ни се струва, че няма нищо кой знае колко полезно в цялата тази работа. Бихме могли да си пишем кода и без closures, при това няма да е повече или по-лесно четим. Затварянията обаче ни осигуряват възможност да правим генератори на функции (currying) - техника, която ще покажем в следваща статия.
Добави коментар