* Затваряния (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) - техника, която ще покажем в следваща статия.
Добави коментар