C, PHP, VB, .NET

Дневникът на Филип Петров


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

 



Добави коментар

Адресът на електронната поща няма да се публикува


*