Sytuacja kobiet w IT w 2024 roku
12.10.202011 min
Sławomir Ciecierski

Sławomir CiecierskiAdministrator systemów w obiektach zagłębionychMinisterstwo Obrony Narodowej

Interfejsy w Javie - Wyrażenia lambda

Poznaj podstawowe koncepcje związane z wyrażeniami lambda i ich zastosowaniem w interfejsach funkcyjnych.

Interfejsy w Javie - Wyrażenia lambda

W moim pierwszym artykule mówiłem o podstawowych koncepcjach związanych z interfejsami Javy. Prezentowałem, jak wykorzystać je w praktyce. Dziś skupię się na operatorze lambda.


Wyrażenie lambda
jest w zasadzie metodą anonimową (tj. metodą, która nie ma nazwy), niemniej jednak metoda ta nie jest wykonywania samodzielnie. Służy ona do podawania implementacji metody określonej przez interfejs funkcyjny. Oznacza to, że wyrażenie lambda pozwala utworzyć klasę anonimową. Wyrażenia lambda są także czasami okreslane jako domknięcia (ang. closure).

Interfejs funkcyjny to interfejs zawierający jedną i tylko jedną metodę abstrakcyjną. Zazwyczaj metoda ta określa przeznaczenie interfejsu. Oznacza to, że w typowych przypadkach interfejs funkcyjny reprezentuje pojedynczą akcję. Na przykład standardowy interfejs Runnable jest interfejsem funkcyjnym, gdyż definiuje tylko jedną metodę run(). Metoda run() definiuje akcję wykonywaną przez interfejs Runnable. Co więcej, interfejs funkcyjny definiuje także typ docelowy (ang. target type) wyrażenia lambda. Wyrażenie lambda może zostać uzyte wyłącznie w kontekście, w którym został określony jego typ docelowy. Interfejsy funkcyjne są czasami określane jako typy SAM (Single Abstract Model) – pojedyncza metoda abstrakcyjna.

Interfejs funkcyjny może zawierać dowolne publiczne metody klasy Object, takie jak equals(), bez utraty statusu "interfejsu funkcyjnego". Te publiczne metody klasy Object są uważane za niejawne składowe interfejsów funkcyjnych, gdyż są automatycznie implementowane przez wszystkie instancje tych interfejsów.

Wyrażenie lambda

Operator lambda zwany inaczej operatorem strzałki mający postać -> dzieli wyrażenie lambda na dwie części. Z jego lewej strony określane są parametry wymagane przez wyrażenie lambda. Jeżeli żadne parametry nie są potrzebne, to stosowana jest lista pusta. Z prawej strony operatora znajduje się ciało wyrażenia lambda określające wykonywane przez nią akcję. Zatem operator -> można rozumieć i odczytywać jako "staje się" lub "przechodzi w". Ciałem lambda może być pojedyncze wyrażenie lub blok kodu:

() -> 123.65

To wyrażenie lambda nie pobiera żadnych parametrów. Zwraca ono wartość stałą: 123.65. Przypomina nieco następującą funkcję:

double myMeth() {return 123.65;}

Oczywiście metoda zdefiniowana przez wyrażenie lambda nie ma nazwy. Kolejny przykład:

() -> Math.random()*100

Wyrażenie pobiera liczbę pseudolosową wygenerowaną przez metodę Math.random(), mnoży przez 100 i zwraca. Tak jak poprzedni przykład – nie wymaga żadnych parametrów. Kolejny przykład – tym razem z parametrem podawanym na liście umieszczonej z lewej strony operatora:

(n) -> (n%2)==0

To wyrażenie zwraca true, jeśli wartość parametru jest parzysta. W wielu przypadkach nie trzeba określać typu parametru, gdyż można go wywnioskować. Można również stosować wiele parametrów.

Interfejsy funkcyjne

Jak wspomniano wcześniej, interfejs funkcyjny to interfejs określający jedną metodę abstrakcyjną. Przed JDK-8 metody interfejsów były domyślnie abstrakcyjne. Od JDK-8, Java zapewnia możliwość określenia domyślnych zachowań metod deklarowanych w interfejsach. Prywatne i statyczne metody interfejsów zawierają implementacje. Innymi słowy – obecnie metoda interfejsu jest abstrakcyjna wyłącznie wtedy, gdy nie określa implementacji i wszystkie metody, które nie są domyślne lub prywatne niejawnie są abstrakcyjne.

interface MyNumber { double getValue(); }

Metoda getValue niejawnie jest metodą abstrakcyjną, a jednocześnie jest jedyną metodą definiowaną przez interfejs MyNumber. Oznacza to, że MyNumber jest interfejsem funkcyjnym, a jego funkcję definiuje metoda getValue().

Wyrażania lambda nie są wykonywane samodzielnie. Zamiast tego zostają one użyte do zaimplementowania metody abstrakcyjnej, zdefiniowanej przez interfejs funkcyjny określający typ docelowy wyrażenia lambda. W efekcie wyrażenia lambda mogą być określone wyłącznie w kontekście, w którym jest zdefiniowany ich typ docelowy. Jednym z takich przypadków, w którym powstaje taki kontekst, jest przypisanie wyrażenia lambda do referencji typu interfejsu funkcyjnego. Innymi przykładami takich kontekstów są: inicjalizacja zmiennej, instrukcja return bądź argumenty metod.

Poniższy przykład przedstawia zastosowanie wyrażenia lambda w kontekście przypisania. W pierwszej kolejności zostaje zadeklarowana referencja typu interfejsu funkcyjnego MyNumber:

//tworzona jest referencja typu MyNumber
MyNumber myNum;


Następnie tej zmiennej zostaje przypisane wyrażenie lambda:

//zastosowanie wyrażenia lambda w kontekście przypisania
myNum = () -> 123.65;


Kiedy wyrażenie lambda zostaje użyte w kontekście swojego typu docelowego, automatycznie tworzona jest instancja klasy implementującej interfejs funkcyjny, w której wyrażenie definiuje zachowanie metody abstrakcyjnej tego interfejsu. Późniejsze wywołanie metody za pośrednictwiem elementu docelowego spowoduje wykonania wyrażenia lambda. A zatem wyrażenia lambda udostępniają możliwość przekształcenia fragmentu kodu na obiekt.

W powyższym przykładzie wyrażenie lambda staje się implementacją metody getValue(). W efekcie następujące wywołanie spowoduje wyświetlenie wartości 123.65:

//wywołuje metodę getValue() zaimplementowaną przez określone wcześniej
//wyrażenie lambda
System.out.println(myNum.getValue());


Ponieważ wyrażenie lambda przypisane zmiennej myNum zwraca wartość 123.65, zatem do właśnie ta wartość zostanie pobrana w momencie wywołania metody get.Value().

Aby wyrażenia lambda można było użyć w kontekście jego typu docelowego, typ metody abstrakcyjnej oraz typ wyrażenia lambda muszą być zgodne. Na przykład – jeśli metoda abstrakcyjna określa dwa parametry typu int, to także wyrażenie lambda musi mieć dwa parametry, których typ został jawnie podany jako int bądź też może zostać uznany za int na drodze wnioskowania w oparciu o kontekst.

Ogólnie rzecz biorąc, typy i liczba parametrów wyrażenia lambda muszą być zgodne z parametrami metody abstrakcyjnej, to samo dotyczy typów zwracanych oraz wszelkich wyjątków zgłaszanych przez wyrażenie lambda.

Wyrażenie lambda z parametrem

interface NumericTest {
  boolean test(int n);
}

class LambdaDemo {
  public static void main(String args[]) {
    //wyrażenie lambda sprawdzające, czy liczba jest parzysta
    NumericTest isEven = (n) ->(n % 2) == 0;

    if (isEven.test(10)) System.out.println("10 jest liczbą parzystą");
    if (isEven.test(9)) System.out.println("9 nie jest liczbą parzystą");

    //przykład wyrażenia lambda, które sprawdza
    //czy liczba nie jest ujemna
    NumericTest isNonNeg = (n) ->n >= 0;

    if (isNonNeg(1)) System.out.println("1 nie jest liczbą ujemną");
    if (isNonNeg( - 1)) System.out.println("-1 jest liczbą ujemną");
  }
}


Wyniki:

10 jest liczbą parzystą
9 nie jest liczbą parzystą
1 nie jest liczbą ujemną
-1 jest liczbą ujemną


W przypadku pojedynczego parametru nawias nie jest konieczny. W pierwszej części przykładu (sprawdzenie, czy liczba jest parzysta) typ parametru n nie został jawnie określony ponieważ jest wnioskowany z kontekstu (zdefiniowana metoda interfejsu).

Zmienne referencyjnej typu interfejsu można użyć do wywołania dowolnego wyrażenia lambda, które jest zgodne z danym interfejsem. W przykładzie zdefiniowano dwa wyrażenia lambda przy czym każde z nich jest zgodne z metodą test() interfejsu funkcyjnego NumericTest. Pierwsze wyrażenie lambda isEven określa czy dana liczba jest parzysta, drugie zaś isNonNeg() czy przekazana liczba jest parzysta. W każdym z tych wyrażeń sprawdzana jest wartość parametru n. Ponieważ każde z tych wyrażeń lambda jest zgodne z metodą test(), zatem można je wykonać, używając referencji typu NumericTest.

Blokowe wyrażenia lambda

Przedstawione wyżej wyrażenia lambda składają się z jednego wyrażenia. Ich ciało nazywamy ciałem wyrażeniowym, a takie wyrażenia lambda nazywa się lambdami wyrażeniowymi. W takim przypadku kod umieszczony z prawej strony operatora lambda musi składać się z jednego wyrażenia, które zwróci wartość wyrażenia lambda. W przypadku gdy z prawej strony operatora lambda umieszczany jest blok kodu mogący zawierać więcej niż jedną instrukcję – taki typ ciała nazywa się blokowym, a wyrażenie lambda – blokowym wyrażeniem lambda. Należy jednak w tym przypadku jawnie zwrócić wartość wyrażenia używając instrukcji return.

//blokowe wyrażenie lambda obliczające silnię podanej liczby
interface NumericFunc {
  int func(int n);
}

//blokowe wyrażnie lambda oblicza silnię liczby przekazanej typu int
NumericFunc factorial = (n) ->{
  int result = 1;
  for (int i = 1; i <= n; i++) result = i * result;
  return result;
};
System.out.println("Silnia liczby 3 wynosi " + factorial.func(3));
System.out.println("Silnia liczby 5 wynosi " + factorial.func(5));


Program wygeneruje wyniki:

Silnia liczby 3 wynosi 6
Silnia liczby 5 wynosi 120


Blokowe wyrażenie lambda deklaruje zmienną result, korzysta z pętli for oraz zawiera instrukcję return. Ciało blokowego wyrażenia lambda jest podobne do ciała metody. Wykonanie instrukcji return w wyrażeniu lambda powoduje zakończenie wyłącznie wyrażenia, a nie metody, w której jest ono używane.

Sparametryzowane interfejsy funkcyjne

W samych wyrażeniach lambda nie można stosować parametrów typów, aczkolwiek ze względu na stosowanie wnioskowania typów wyrażenia lambda i tak wykazują pewne cechy "typów sparametryzowanych". Nie mniej jednak interfejs funkcyjny skojarzony z wyrażeniem lambda może być sparametryzowany. W takim przypadku typ docelowy wyrażenia lambda jest także określany przez argument lub argumenty typu określone podczas referencji typu interfejsu. Zamiast tworzyć dwa odrębne interfejsy funkcyjne, których metody różnią się wyłącznie typami, można zadeklarować jeden interfejs sparametryzowany obsługujący dwa przypadki.

//sparametryzowany interfejs funkcyjny
interface SomeFunc < T > {
  T func(T t);
}

public class GenericFunctionaInterfaceDemo {
  public static void main(String[] args) {
    //zastosowanie interfejsu GenericFunctionaInterfaceDemo z typem String
    SomeFunc < String > reverse = (str) ->{
      String result = "";
      int i;
      for (i = str.length() - 1; i >= 0; i--) result += str.charAt(i);

      return result;
    };

    System.out.println("Lambda po odwróceniu: " + reverse.func("Lambda"));
    System.out.println("Wyrażenia po odwróceniu: " + reverse.func("Wyrażenia"));

    //zastosowanie interfejsu GenericFunctionaInterfaceDemo z typem Integer
    SomeFunc < Integer > factorial = (n) ->{
      int result = 1;
      for (int i = 1; i <= n; i++) result = i * result;

      return result;
    };

    System.out.println("Silna liczby 3 wynosi " + factorial.func(3));
    System.out.println("Silna liczby 5 wynosi " + factorial.func(5));
  }
}


Program generuje wyniki:

Lambda po odwróceniu: adbmaL
Wyrażenia po odwróceniu: aineżaryW
Silna liczby 3 wynosi 6
Silna liczby 5 wynosi 120


Parametr typu T określa zarówno typ zwracanego wyniku, jak i typ parametru metody func(). To znaczy również, że jest zgodny z dowolnym wyrażeniem lambda, które pobiera jeden argument i zwraca wynik tego samego typu. Interfejs SomeFunc jest używany do przechowywania referencji do dwóch różnych typów wyrażeń lambda tj. typu String oraz Integer. Ten sam interfejs funkcyjny może być używany do odwoływania się do wyrażenia lambda reverse, jak i factorial. Różni się jedynie argument typu przekazywany do interfejsu SomeFunc.

Przekazywanie wyrażeń lambda jako argumentów

Jak wspomniano, wyrażenia lambda mogą być stosowane w dowolnym kontekście określającym ich typ docelowy, np. argumenty wywołań metod. Przekazywanie wyrażeń lambda jako argumentów jest powszechnie stosowane. Takie rozwiązanie pozwala na przekazywanie do metod wykonywalnego kodu. Aby to zrobić, typem parametru musi być interfejs funkcyjny zgodny z wyrażeniem lambda.

// Zastosowanie 
interface StringFunc {
  String func(String n);
}

class LambdasAsArgumentsDemo {

  static String stringOp(StringFunc sf, String s) {
    return sf.func(s);
  }

  public static void main(String args[]) {
    String inStr = "Wyrażenia lambda rozszerzają możliwości Javy";
    String outStr;

    System.out.println("Łańcuch wejściowy: " + inStr);

    outStr = stringOp((str) ->str.toUpperCase(), inStr);
    System.out.println("Łańcuch zapisany wielkimi literami: " + outStr);

    outStr = stringOp((str) ->{
      String result = "";
      int i;

      for (i = 0; i < str.length(); i++)
      if (str.charAt(i) != ' ') result += str.charAt(i);

      return result;
    },
    inStr);

    System.out.println("Łańcuch bez znaków odstępu: " + outStr);

    // Można także przekazać instancję interfejsu StringFunc 
    // utworzoną przez jedno z wyrażeń lambda przedstawionych 
    // wcześniej. Na przykład po wykonaniu tej deklaracji
    // zmienna reverse będzie zawierać sztuczną instancję 
    // interfejsu StringFunc.
    StringFunc reverse = (str) ->{
      String result = "";
      int i;

      for (i = str.length() - 1; i >= 0; i--)
      result += str.charAt(i);

      return result;
    };

    // Teraz zmienną reverse można przekazać jako pierwszy 
    // argument wywołania metody stringOp(), gdyż zawiera 
    // referencję do obiektu StringFunc.
    System.out.println("Odwrócony łańcuch wejściowy: " + stringOp(reverse, inStr));
  }
}


W metodzie stringOp typem pierwszego parametru jest interfejs funkcyjny. Oznacza to, że można do niej przekazać dowolną referencję do instancji tego typu, w tym także instancji tworzonej przy użyciu wyrażenia lambda. Drugi parametr określa łańcuch znakowy, na którym należy operować. W przykładzie zastosowano po pierwsze proste, po drugie blokowe wyrażenie lambda, po trzecie utworzono instancję interfejsu StringFunc i przekazano ją jako argument wywołania metody stringOp().

Wyniki

Łańcuch wejściowy: Wyrażenia lambda rozszerzają możliwości Javy
Łańcuch zapisany wielkimi literami: WYRAŻENIA LAMBDA ROZSZERZAJĄ MOŻLIWOŚCI JAVY
Łańcuch bez znaków odstępu: WyrażenialambdarozszerzająmożliwościJavy
Odwrócony łańcuch wejściowy: yvaJ icśowilżom ąjazrezszor adbmal aineżaryW

Wyrażenia lambda i wyjątki

Wyrażenia lambda mogą zgłaszać wyjątki. Niemnej jednak, jeśli wyrażenie zgłasza wyjątek weryfikowalny, to musi być zgodny z wyjątkiem (wyjątkami) podanymi na liście klauzuli throws metody abstrakcyjnej interfejsu funkcyjnego. Poniżej przedstawiony został stosowny przykład, który oblicza średnią liczb zapisanych w tablicy. Jeśli przekazana tablica jest pusta, to program zgłasza niestandardowy wyjątek typu EmptyArrayException. Wyjątek został podany w klauzuli throws metody func(), zadeklarowanej w interfejsie funkcyjnym DoubleNumericArrayFunc.

interface DoubleNumericArrayFunc {
  double func(double[] n) throws EmptyArrayException;
}
class EmptyArrayException extends Exception {
  EmptyArrayException() {
    super("Tablica jest pusta");
  }
}
class LambdaExceptionDemo {
  public static void main(String args[]) throws EmptyArrayException {
    double[] values = {
      1.0,
      2.0,
      3.0,
      4.0
    };
    //blokowe wyrażenie lambda oblicza średnią liczb
    //zapisanych w przekazanej tablicy
    DoubleNumericArrayFunc average = (n) ->{
      double sum = 0;
      if (n.length == 0) throw new EmptyArrayException();
      for (int i = 0; i < n.length; i++)
      sum += n[i];
      return sum / n.length;
    };
    System.out.println("Średnia wynosi " + average.func(values));
    //to wywołanie spowoduje zgłoszenie wyjatku
    System.out.println("Średnia wynosi " + average.func(new double[0]));
  }
}


Pierwsze wywołanie average.func() powoduje zwrócenie wartości 2.5. Z kolei drugie wywołanie, w którym przekazywana jest pusta tablica, spowoduje zgłoszenie wyjątku EmptyArrayException

Dodanie klauzuli throws w metodzie func() jest niezbędne – bez niej nie uda się skompilować programu, gdyż wyrażenie lambda nie będzie zgodne z metodą func(). Warto zwrócić uwagę, że parametr metody func(), zadeklarowanej przez interfejs funkcyjny DoubleNumericArrayFunc, jest tablicą. Jednak parametrem wyrażenia lambda jest n, a nie n[]. Trzeba pamiętać, że typ parametru wyrażenia lambda zostanie wywnioskowany na podstawie kontekstu docelowego tj. double[], a zatem typ parametru n zostanie określony jako double[]. Deklarowanie typu wyrażenia lambda jako n[] jest niedozwolone.

Wyrażenia lambda i przechwytywanie zmiennych

Zmienne zdefiniowane w zasięgu, w którym zostało zdefiniowane wyrażenia lambda, są dostępne w tym wyrażeniu. Na przykład wyrażenie lambda może używać zmiennej instancyjnej lub statycznej, zdefiniowanych w klasie, w której zostało podane dane wyrażenie. Oprócz tego wyrażenia lambda mają także dostęp do referencji this (zarówno jawny, jak i niejawny), odwołującej się do instancji klasy, wewnątrz której dane wyrażenie lambda zostało wywołane. Oznacza to, że wyrażenie lambda może odczytywać i zapisywać wartości zmiennych instancyjnych oraz składowych statycznych, jak również wywoływać metody klasy, w której zostało zdefiniowane.

Jeśli jednak wyrażenie lambda używa zmiennej lokalnej pochodzącej z zasięgu, w którym zostało zdefiniowane, zachodzi specjalna sytuacja, określana jako przechwycenie zmiennej. W takim przypadku wyrażenie lambda może używać zmiennych lokalnych, które są praktyczne finalne, tzn. zmienne, których wartość nie zmienia się po pierwszym przypisaniu. Takich zmiennych nie trzeba jawnie deklarować jako sfinalizowanych, choć użycie słowa final nie zostanie potraktowane jako błąd. 

Parametr this zasięgu zewnętrznego automatycznie jest traktowany jako sfinalizowany, a same wyrażenia lambda nie mają własnej referencji this

Wyrażenia lambda nie mogą modyfikować wartości zmiennych lokalnych pochodzących z zasięgu, w którym zostały podane. Taka modyfikacja spowodowałaby bowiem utratę statusu zmiennej praktyczne finalnej, co z kolei sprawiłoby, że jej wartość nie mogłaby zostać przechwycona.

Interface MyFunc {
  int func(int n);
}

class VarCapture {
  public static void main(String args[]) {
    //zmienna lokalna, którą można przechwycić
    int num = 10;
    MyFunc myLambda = (n) ->{
      //to prawidłowy sposób użycia zmiennej num,
      //jej wartość nie jest bowiem modyfikowana
      int v = num + n;
      //ta instrukcja jest nieprawidłowa
      //gdyz próbuje zmienić wartość zmiennej num
      num++;
      return v;
    };
    //również ta instrukcja bedzie skutkowała zgłoszeniem błędu
    //powoduje ona, że zmienna num traci status zmiennej praktycznie finalnej
    //num = 9;
  }
}


Zmienna num jest praktycznie finalna i dlatego może być używana w wyrażeniu myLambda. Gdyby jednak została zmodyfikowana wewnątrz wyrażenia lambda, bądź poza nim, to straciłaby swój status zmiennej praktycznie finalnej. To z kolei spowodowałoby błąd i uniemożliwiłoby skompilowanie programu. Wyrażenie lambda może używać i modyfikować zmienne instancyjne dostępne w klasie, która je wywołuje. Nie może jednak używać zmiennych lokalnych z zasięgu, w którym zostało zdefiniowane, o ile zmienne te nie są praktycznie finalne.

W następnej części przedstawię ważną możliwość języka Java związaną z wyrażeniami lambda, która pozwala odwoływać się do metod bez ich wykonywania tzn. referencje do metod.

<p>Loading...</p>