Funkcionális programozás Javában

Kategória: Java standard könyvtárak.

A programozás oldal megfelelő alfejezete áttekintést nyújt a funkcionális programozásról. A gyakorlatban ez azt jelenti, hogy a függvények "első osztályú állampolgárokká" válnak: át lehet őket adni paraméterként, lehetnek visszatérési értékei, célszerű tiszta függvényekkel dolgozni stb.

A Java nem funkcionális programozási nyelv, a 8-as verziójában viszont megjelentek olyan elemek, amelyek funkcionális elemeket tartalmaznak. Ezeket tekintjük át most. E szakasz megírásában sokat segített a következő oldal: http://tutorials.jenkov.com/java-functional-programming/.

Funkcionális interfészek

Funkcionális interfészeknek nevezzük azokat az interfészeket, melyek összesen egy függvényt tartalmaznak. Egészen pontosan: egy olyan függvényt, ami nincs megvalósítva. Emlékeztetőül: pont a Java 8-ban jelent meg az alapértelmezett megvalósítást tartalmazó függvény lehetősége. Ez utóbbinak később jelentősége lesz; most elég csak annyit megjegyeznünk, hogy ilyen is lehetséges.

Vegyünk egy egyszerű példát:

interface MathOperation {
    int perform(int a, int b);
}

A MathOperation egy olyan függvényt deklarál, amely két egészet vár paraméterül, és egy egészet ad vissza. Mivel tehát ez egy olyan interfész, melyben pontosan egy nem megvalósított függvény fejléc szerepel, funkcionális interfésznek nevezzük. Ebben tehát idáig nincs semmi nyelvi újítás, csak megnevezés.

Lambda kifejezések

A lambda kifejezés a legnagyobb nyelvi újítás a Java 8-ban (de megkockáztatom, hogy a Java történetében az első verzió óta). Először viszont folytassuk a fenti példát!

Tekintsük a következő függvényt!

void performOperation(int a, String sign, int b, MathOperation mo) {
    System.out.println(a + " " + sign + " " + b + " = " + mo.perform(a, b));
}

Tegyük fel, hogy a fenti interfészt kétféleképpen szeretnénk megvalósítani: az egyik összeadás, a másik szorzás. A Java 8 előtti világban ehhez létre kellett hoznunk egy-egy osztályt, a perform() függvényt megvalósítani, az osztályt példányosítani, majd átadni a fenti függvénynek, valahogy így:

interface MathOperation {
    int perform(int a, int b);
}
 
class AddOperation implements MathOperation {
    @Override
    public int perform(int a, int b) {
        return a + b;
    }
}
 
class MultiplyOperation implements MathOperation {
    @Override
    public int perform(int a, int b) {
        return a * b;
    }
}
 
public class NonFunctionalExample {
    static void performOperation(int a, String sign, int b, MathOperation mo) {
        System.out.println(a + " " + sign + " " + b + " = " + mo.perform(a, b));
    }
 
    public static void main(String[] args) {
        performOperation(2, "+", 3, new AddOperation());
        performOperation(2, "*", 3, new MultiplyOperation());
    }
}

Ez utóbbin a Java 8 előtti világban úgy tudunk egyszerűsíteni, hogy - feltéve, hogy nincs szükségünk az AddOperation és MultiplyOperation osztályokra egyébként, hogy anonymous osztályt hozunk létre:

package basics;
 
interface MathOperation {
    int perform(int a, int b);
}
 
public class NonFunctionalExample {
    static void performOperation(int a, String sign, int b, MathOperation mo) {
        System.out.println(a + " " + sign + " " + b + " = " + mo.perform(a, b));
    }
 
    public static void main(String[] args) {
        performOperation(2, "+", 3, new MathOperation() {
            @Override
            public int perform(int a, int b) {
                return a + b;
            }
 
        });
        performOperation(2, "*", 3, new MathOperation() {
            @Override
            public int perform(int a, int b) {
                return a * b;
            }
        });
    }
}

Itt jön a nagy ugrás: mivel a MathOperation egy funkcionális interfész, valójában csak egyetlen függvényt kell megvalósítanunk, minek írnánk ki a nevét? Erre alkották meg a -> (lambda) operátort a Java 8-ban. Az összeadás a következőképpen néz ki ennek segítségével: (int a, int b) -> {return a + b;}. A fenti kód ennek következtében az alábbira egyszerűsödik:

interface MathOperation {
    int perform(int a, int b);
}
 
public class FunctionalExample {
    static void performOperation(int a, String sign, int b, MathOperation mo) {
        System.out.println(a + " " + sign + " " + b + " = " + mo.perform(a, b));
    }
 
    public static void main(String[] args) {
        performOperation(2, "+", 3, (int a, int b) -> {return a + b;});
        performOperation(2, "*", 3, (int a, int b) -> {return a * b;});
    }
}

Néhány szó a szintaxisról:

  • A paramétereket kerek zárójelekben adjuk meg, mint ahogy a többi függvénynél megszokhattuk.
  • A paraméter típusok megadása opcionális, ugyanis ezt a fordító ki tudja következtetni (type inference). Tehát például az összeadás tovább egyszerűsödik: (a, b) -> {return a + b;}
  • Ha összesen egyetlen return utasítás van, akkor a kapcsos zárójel is elhagyható: (a, b) -> a + b;
  • Elképzelhető, hogy nincs paraméter; ez esetben is ki kell tenni a zárójeleket, pl. () -> new MyObject() (ha pl. az interfészben deklarált függvény így néz ki: MyObject create();).
  • Ha egyetlen paraméter van, akkor a zárójel opcionális. Pl. a következő kettő ekvivalens: (x) -> 2 * x és x -> 2 * x. Ez utóbbi szintaxis egyébként a lambda kifejezések talán legnagyobb ereje, amikor igazán tömöré válik a kód.

Lássuk a fenti példát úgy, hogy minden egyszerűsítési lehetőséget kihasználunk:

interface MathOperation {
    int perform(int a, int b);
}
 
public class FunctionalExample {
    static void performOperation(int a, String sign, int b, MathOperation mo) {
        System.out.println(a + " " + sign + " " + b + " = " + mo.perform(a, b));
    }
 
    public static void main(String[] args) {
        performOperation(2, "+", 3, (a, b) -> a + b);
        performOperation(2, "*", 3, (a, b) -> a * b);
    }
}

Látható tehát, hogy rendkívül tömörré vált a kód adott része, ami az igazi funkcionális programozási nyelvekre emlékeztet. Ez a valóságban kisebb részt nyelvi elem (mindenekelőtt a -> lambda operátor), nagyobb részt a fordító "felokosítása" (pl. típus következtetés a funkcionális interfész segítségével), a háttérben viszont ugyanúgy létre jön egy osztálypéldány, ami az adott függvény megvalósítását tartalmazza. Függvényt magát tehát továbbra sem adhatunk át paraméterként, csak olyan osztályt, ami egy függvényt tartalmaz.

Metódus referencia

Tegyük fel, hogy van egy Printer interfész, ami tartalmaz egy void print(String text) metódust, valamint egy olyan függvény, ami két paramétert vár, egy String-et és egy Printer-t, és meghívja a Printer-en a print(text)-et. Paraméterként átadhatjuk neki ezt: print(text, (s) -> System.out.println(s));. Lássuk, hogy miről van szó! (Ez nem futtatható példa, az a szakasz végén lesz.)

interface Printer {
    void print(String s);
}
 
static void print(String text, Printer printer) {
    printer.print(text);
}
 
print(text, (s) -> System.out.println(s));

Az utolsó sort a következőképpen is írhatjuk:

print(text, System.out::println);

Vegyük észre, hogy a println() a System.out egy statikus függvénye. A következő referenciákat tudjuk használni:

  • Statikus metódus, mint pl. fent.
  • Az egyik paraméter metódusa; pl. a következő kettő ekvivalens (s1 és s2 mindkettő String típusú): Finder finder = (s1, s2) -> s1.indexOf(s2); és Finder finder = String::indexOf;.
  • Egy példány metódusa, hasonlóan a statikus metódushoz.
  • Konstruktor referencia, pl. String::new.

Lássunk mindegyikre egy működő példát (ami az eredeti felvetést is tartalmazza)!

interface Printer {
    void print(String s);
}
 
interface Factory {
    String create(char[] val);
}
 
interface Finder {
    int find(String s1, String s2);
}
 
interface Converter {
    public String convert(String text);
}
 
class Capitalizer {
    public String capitalize(String text) {
        return text.toUpperCase();
    }
}
 
public class MethodReferenceExample {
    static void print(String text, Printer printer) {
        printer.print(text);
    }
 
    static void find(String text1, String text2, Finder finder) {
        System.out.println(finder.find(text1, text2));
    }
 
    static void convert(String text, Converter converter) {
        System.out.println(converter.convert(text));
    }
 
    public static void main(String[] args) {
        char[] chars = {'H', 'e', 'l', 'l', 'o'};
        Factory factory = String::new;
        String text = factory.create(chars);
        print(text, (s) -> System.out.println(s));
        print(text, System.out::println);
        find(text, "ll", String::indexOf);
        convert(text, (new Capitalizer())::capitalize);
    }
}

Függvények átadása paraméterként és visszatérési értékként

A funkcionális programozásban magasabb rendű függvénynek (higher order function) hívjuk azokat a függvényeket, melyeket paraméterül adunk át vagy egy függvény visszatérési értéke. A fenti példa már bemutatta, hogy függvényt mi módon tudunk átadni paraméterként egy másik függvénynek (ami a valóságban olyan név nélküli osztály egy példányát jelenti, ami egyetlen függvényt tartalmaz).

Lássunk egy példát a visszatérési értékre is! Létrehozunk egy olyan eljárást, ami visszatérési értékként a paraméterül kapott műveleti jeltől függő függvénnyel tér vissza: "+" esetén összeadással, "*" esetén szorzással.

interface MathOperation {
    int perform(int a, int b);
}
 
public class FunctionalExample {
    static void performOperation(int a, String sign, int b, MathOperation mo) {
        System.out.println(a + " " + sign + " " + b + " = " + mo.perform(a, b));
    }
 
    static MathOperation createMathOperation(String sign) {
        switch (sign) {
        case "+": return (a, b) -> a + b;
        case "*": return (a, b) -> a * b;
        }
        return (a, b) -> 0;
    }
 
    public static void main(String[] args) {
        performOperation(2, "+", 3, createMathOperation("+"));
        performOperation(2, "*", 3, createMathOperation("*"));
    }
}

A kód ránézésre olyan, mintha tényleg függvényt kapna paraméterül ill. azt adna vissza; ezt nem tudnánk megtenni, ha nem vezették vona be a funkcionális interfész fogalmát.

Beépített funkcionális interfészek

Ahogy láthattuk, mi magunk is hozhatunk létre funkcionális interfészeket. Számos funkcionális interfészt definiál ugyanakkor maga a Java keretrendszer a java.util.function csomagban, feltehetően leginkább abból a megfontolásból, hogy a hasonló megvalósítások egységesek legyenek. Ezek közül nézünk meg most párat:

  • Function: az apply() függvény paramétere és visszatérési értéke is generikus. Ennek speciális esete az UnaryOperator, melynek paramétere és visszatérési értéke ugyanolyan típusú.
  • Predicate: a test() függvény paramétere generikus, visszatérési értéke boolean.
  • BinaryOperator: az apply() két ugyanolyan típusú paramétert vár, mely megegyezik a visszatérési típussal.
  • Supplier: a get() függvénynek paramétere nincs, visszatérési értéke generikus.
  • Consumer: az accept() függvénynek egy generikus paramétere van, a visszatérési típusa pedig void.

Példaként tekintsünk a fentinek a BinaryOperator változatát!

import java.util.function.*;
 
public class FunctionalInterfaceExample {
    static void performOperation(int a, String sign, int b, BinaryOperator<Integer> bo) {
        System.out.println(a + " " + sign + " " + b + " = " + bo.apply(a, b));
    }
 
    public static void main(String[] args) {
        performOperation(2, "+", 3, (a, b) -> a + b);
        performOperation(2, "*", 3, (a, b) -> a * b);
    }
}

A BinaryOperator generikus típusa csak valamilyen osztály lehet, primitív típus nem. Ebben a példában az autoboxing is megjelenik.

Funkcionális kompozíció

A funkcionális interfész definíciójánál szó volt arról, hogy azok tartalmazhatnak tetszőleges számú megvalósított függvényt. Ilyeneket a keretrendszer által nyújtott interfészek is tartalmaznak, melyekből pár fontosabb példát említek:

  • Predicate: két predikátumot az and() és az or() függvényekkel tudunk összefűzni, melynek eredménye szintén predikátum. Pl. ha a multipleOfTwo és a multipleOfThree két predikátum, melyek számot várnak, és akkor térnek vissza igaz értékkel, ha a paraméterül kapott szám a kettőnek ill. a 3-nak többszörösei, akkor a multipleOfTwo.and(multipleOfThree) a 6 többszöröseire fog igazzal visszatérni.
  • Function: két függvény egymás után fűzhető a compose() és az andThen() függvényekkel. Az f.compose(g) estén előbb a g hajtódik végre, utána az f(), az f.andThen(g) pedig fordítva.

Lássunk erre is egy példát, melyben létrehozunk egy olyan, egészet váró és egészet visszaadó függvényt, amely megnöveli a paraméterül kapott értéket eggyel, ill. egy olyat, ami megduplázza, és ezt fűzzük egymás után, ebben a sorrendben.

import java.util.function.*;
 
public class FunctionCompositionExample {
    static void performOperation(int a, Function<Integer, Integer> f) {
        System.out.println(a + " -> " + f.apply(a));
    }
 
    public static void main(String[] args) {
        Function<Integer, Integer> increment = a -> a + 1;
        Function<Integer, Integer> duplicate = a -> a * 2;
        Function<Integer, Integer> incrementThenDuplicate = increment.andThen(duplicate);
        performOperation(2, incrementThenDuplicate);
    }
}

Ebben a példában azt is láthatjuk, hogy a kompozíció eredménye szintén egy olyan függvény, ami egészet vár és egészet ad vissza.

Összegzés

A Java eredendően nem egy funkcionális nyelv, és a Java 8-ban sem lett az. Viszont bevezettek olyan újításokat, amelyek komoly lépéseket jelentenek a funkcionális programozás irányába. Ennek a használt a következő alfejezetben fogjuk látni. Legvégső soron legalul viszont továbbra is egy függvényt tartalmazó osztálypéldányok vannak, tehát függvényt ténylegesen nem tudunk átadni paraméterként, sem visszatérni vele visszatérési értékként. Viszont olyan funkcionális finomságokat, mint pl. a currying a Scalaban, Javaban továbbra sem tudunk megvalósítani. Ugyanakkor tudunk olyan kódot írni, ami első ránézésre funkcionálisként hat.

Egyetlen lépést még hiányolok a nyelvben: amikor használjuk a paraméterül átadott függvényt, akkor tudnunk kell a függvény nevét. Az első példában volt egy MathOperation interfész, azon belül egy perform() függvény, és a tényleges kiszámoláskor így kellett használnunk: mo.perform(a, b). De mivel összesen egy eljárást tartalmaz a funkcionális interfész, valójában a függvény nevét is ki kellene tudnia következtetni, így jó lenne, ha ez is működne: mo(a, b). Ez még egy lépés lenne a funkcionalitás felé.

Unless otherwise stated, the content of this page is licensed under Creative Commons Attribution-ShareAlike 3.0 License