Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
Funkcionális programozás JavaScriptben

Funkcionális programozás JavaScriptben

Folytatom a JavaScript bemutatását a függvények és az objektumorientált programozás után most a funkcionális programozásra fókuszálva.

A funkcionális programozás egy programozási paradigma - meghatározza, milyen elemekből épül fel a programunk. Minden számítást matematikai függvényekkel ír le, elkerülve a adatok és a program állapotának megváltozását. Ez azt jelenti, hogy ha egy függvényt akár mennyiszer is hívunk meg, ugyanarra a bemenetre ugyanazt a kimenetet adja vissza. A paradigma alapja a Lambda-kalkulus.

A λ-kalkulust nyugodtan nevezhetjük a legegyszerűbb általános célú programozási nyelvnek. Csak egyfajta értéket ismer: a függvényt (absztrakciót), és csak egyfajta művelet van benne: a függvény alkalmazás (változó-behelyettesítés). Ezen látszólagos egyszerűsége ellenére minden algoritmus, ami Turing-gépen megvalósítható, az megvalósítható tisztán a λ-kalkulusban is.

Lambda-kalkulus - Wikipédia

A legnépszerűbb funkcionális programozási nyelvek a Lisp, Scheme, Clojure, Erlang, OCaml, Haskell, és az F#. A JavaScript ugyan nem tartozik a tisztán funkcionális nyelvek közé, de alkalmas a paradigma használatára.

Koncepciók

Létezik pár koncepció, ami a funkcionális programozáshoz tartozik ezek a következők:

First-class és magasabb rendű függvények

A függvények a JavaScriptben elsődleges (first-class) típusok, azaz hozzárendelhetjük őket változókhoz és objektum tulajdonságokhoz, átadhatjuk őket paraméterként más függvényeknek

[...]

A magasabb rendű függvények (higher order function) olyan függvények, melyek más függvényekkel dolgoznak, azaz vagy paraméterként várják őket, vagy függvényeket adnak vissza.

JavaScript függvények

A magasabb rendű függvények lehetővé teszik a részleges alkalmazást (partial application). Ez azt jelenti, hogy a függvény egyes paramétereit előre megadjuk létrehozva egy új függvényt.

ES5 óta a nyelv részét képezi a Function.prototype.bind ami a kontextus megadása mellett lehetővé teszi a részleges alkalmazást is:

function add(a, b) {
  return a + b;
}
var add10 = add.bind(null, 10);
add10(1); // 11
add.bind(null, 'Hello')(' World!'); // "Hello World!"

A részleges alkalmazáshoz hasonló a currying. Ha létrehozunk egy curry-vel fűszerezett változatot a függvényből, akkor annak minden egyes meghívásával részlegesen alkalmazzuk az átadott paramétereket. Ha az átadott paraméterek száma eléri a függvény argumentumainak számát, akkor pedig visszaadja az eredeti függvény visszatérési értékét a korábban megadott paraméterekkel.

function curry(func, arity) {
    arity = arity || func.length;
    return function _curry(params) {
        var args = Array.prototype.slice.call(arguments);
        return args.length >= arity ? 
            func.apply(null, args) :
            function curried() {
                return _curry.apply(null, args.concat(Array.prototype.slice.call(arguments)))
            };
    };
}
var curriedAdd = curry(add);
curriedAdd(3)(4); // 7
curriedAdd('Hello', ' World!'); // "Hello World!"

Mind a részleges alkalmazás, mind a currying megvalósítható jobbról is, azaz az átadott paraméterek a paraméter lista vége helyett az elejére kerülnek.

function curryRight(func, arity) {
    arity = arity || func.length;
    return function _curry(params) {
        var args = Array.prototype.slice.call(arguments);
        return args.length >= arity ? 
            func.apply(null, args) :
            function curried() {
                return _curry.apply(null, Array.prototype.slice.call(arguments).concat(args))
            };
    };
}
function partialRight(func) {
    var args = Array.prototype.slice.call(arguments, 1);
    return function _partialRight() {
        return func.apply(null, Array.prototype.slice.call(arguments).concat(args));
    }
}
partialRight(add, ' World!')('Hello'); // "Hello World!"
curryRight(add)(' World!')('Hello'); // "Hello World!"

Tiszta függvények

Azokat a függvényeket nevezzük tisztának, amelyeknek nincsen mellékhatása. Egy függvénynek nincsen mellékhatása, ha a hívó fél szemszögéből nem változnak meg a függvények átadott érték vagy más állapot (I/O vagy memória). Ezek sok hasznos tulajdonsággal rendelkeznek:

  • Ha a eredményét nem használjuk, akkor a hívás mindenféle mellékhatás nélkül eltávolítható.
  • Ha olyan paraméterekkel hívjuk, amiknek nincsen mellékhatása, akkor az eredmény állandó, és csakis a bemeneti adatoktól függ (gyorsítótárazható).
  • Ha nincs adatbeli függőség két tiszta függvény között, akkor hívási sorrendjük felcserélhető, illetve párhuzamosan futtathatók.
  • Ha egy nyelv nem enged meg mellékhatásokat, akkor akár milyen futtatási stratégia alkalmazható.

Rekurzió

Funkcionális nyelvekben legtöbbször az iterációt rekurzióval valósítják meg. A rekurzív függvények saját magukat hívják, hogy megvalósítsanak egy műveletet, amíg egy elemi esethez nem érnek, ahol már nem szükséges rekurzió.

function map(array, callback, context) {
  return (function _map(array, index, callback, context, result) {
    if (index === array.length) {
      return result; // elemi eset
    } else {
      if (index in array) {
        result[index] = callback.call(context, array[index], index, array);
      }
      return _map(array, index + 1, callback, context, result); // rekurzív hívás
    }
  }(array, 0, callback, context, []));
}

Tömbök

A ECMAScript 5-ös változata rengeteg segítséget ad a funkcionális programozáshoz. Korábban már bemutattam, hogy létezik beéptített eszköz a részleges alkalmazásra, de a nyelv tömbök esetén is sok segítséget ad a paradigma alkalmazására (Array#extras). Ezek végigiterálnak a tömbön, minden egyes elemre meghívva az átadott függvényt.

Array#forEach (each)

Az Array#forEach használatához nem igazán tudunk tiszta függvényt átadni, de attól még a függvényünk maradhat tiszta. 3 paramétert ad át a függvénynek: az aktuális elem, az elem sorszáma és maga a tömb. Például, hogy ha ki akarjuk iratni az tömbünk elemeit:

function each(array, callback) {
  array.forEach(function iterator(item) {
    callback(item);
  });
  return array;
}
each([1, 2, 3, 4], console.log.bind(console, 'Elem:'));
Elem: 1
Elem: 2
Elem: 3
Elem: 4

Az each függvényünk tiszta (mivel nem módosít semmit), csak az átadott függvény végez I/O-t.

Array#map

A Array#map függvény sokkal hasznosabb, ugyanis segítségével egy új tömböt hozhatunk létre a már meglévő elemeit használva. 3 paramétert ad át a függvénynek hasonlóan az Array#forEach-hez és a visszaadott érték lesz az új tömb aktuális eleme.

[1, 2, 3, 4].map(function elemTimesIndex(elem, index) {
  return elem * index;
}); // [0, 2, 6, 12]
[1, 2, 3, 4].map(add); // [1, 3, 5, 7]

Array#filter

Az Array#filter segítségével egy olyan tömböt hoznatunk létre, melyben csak azokat az elemeket tartjuk meg, amelyek eleget tesznek a feltételnek. 3 paramétert ad át a függvénynek hasonlóan az Array#forEach-hez. Például, ha csak a páratlan elemeket szeretnénk megtartani:

function isOdd(n) {
  return !!(n % 2);
}
[1, 2, 3, 4].filter(isOdd); // [1, 3]

Hogyan tudnánk létrehozni egy olyan függvényt, ami a párosakat adja vissza?

function negate(predicate) {
  return function() {
    !predicate.apply(this, arguments);
  };
}
var isEven = negate(isOdd)
[1, 2, 3, 4].filter(isEven); // [2, 4]

Array#reduce és Array#reduceRight

Az Array#reduce segítségével a tömbünket egyetlen értékre redukálhatjuk. Paraméterként egy függvényt és opcionális kezdőértéket vár. 4 paramétert ad át a függvénynek: az előző érték, az aktuális elem, az elem sorszáma és a tömb. A függvénynek az új értéket kell visszaadnia. Ha nem adunk meg kezdőértéket, akkor a kezdőérték az első elem lesz és az iteráció a második elemmel indul.

Segítségével könnyen összeadhatjuk a tömbünk elemeit:

[1, 2, 3, 4].reduce(add); // 10
[' ', 'World', '!'].reduce(add, 'Hello'); // "Hello World!"

Az Array#reduceRight megegyezik a Array#reduce-szal, csak a tömb elemeit jobbról balra dolgozza fel. Ha nem adunk meg kezdőértéket, akkor a kezdőérték az utolsó elem lesz és az iteráció a utolsó előtti elemmel indul.

['!', 'World', ' ', 'Hello'].reduceRight(add); // "Hello World!"

Array#some és Array#every

Az Array#some és a Array#every az Array#reduce egy speciális formája. Az Array#some true értéket ad vissza, ha a tömb legalább egy eleme teljesíti a feltételt. Az Array#every pedig akkor ad vissza true értéket, ha a tömb minden eleme teljesíti a feltételt. Az Array#some nem iterál tovább amennyiben talált egy elemet, ami megfelel a feltételnek. Az Array#every pedig akkor, amikor egy elem nem felel meg a feltételnek.

[1, 2, 3, 4].some(isOdd); // true
[1, 3].some(isEven); // false
[1, 2, 3, 4].every(isOdd); // false
[1, 3].every(isOdd); // true

Array#reduce segítségével a követképp írhatjuk fel őket:

function some(array, predicate, context) {
  return array.reduce(function (previous) {
    return previous || !!predicate.apply(context, Array.prototype.slice.call(arguments, 1));
  }, false);
}
some([1, 2, 3, 4], isOdd); // true
some([1, 3], isEven); // false

function every(array, predicate, context) {
  return array.reduce(function (previous) {
    return !previous ? previous : !!predicate.apply(context,  Array.prototype.slice.call(arguments, 1));
  }, true);
}
every([1, 2, 3, 4], isOdd); // false
every([1, 3], isOdd); // true

Felhasználás

Funkcionális programozáshoz alapvető, hogy rendelkezzünk egy általánosan használható függvénykönyvtárral, amikből újabb függvényeket készíthetünk. Bemutatok néhány hasznos ilyen függvényt, ezzel bővítve a rendelkezésre álló eszközöket.

Azonosság

Kezdjük pár hasznos, de egyszerű függvénnyel. A legalapabb ezek közül az identity, amely visszaadja az első kapott paramétert:

function identity(value) {
  return value;
}
[0, 1, 2, null, undefined].filter(identity); // [1, 2]

Konstans

Másik hasznos függvény lehet a constant, amely mindig az előre megadott értéket adja vissza:

function constant(value) {
  return function () {
    return value;
  };
}
[1, 2, 3, 4].map(constant(42)); // [42, 42, 42, 42]

Tulajdonság

Hozzunk létre egy függvényt, ami az megadott objektumnak visszaadja egy, tömbbel megadott elérési úton található elemét:

function prop(object, path) {
  return path.reduce(
    function (previous, current) {
      return previous !== undefined && previous !== null ? previous[current] : undefined;
    }, object);  
}
prop([1, 2, 3, 4], [1]); // 2
var myObj = {a: {b: {c: 'd'}}};
prop(myObj, ['a', 'b', 'c']); // "d"

Ezt később felhasználhatjuk arra, hogy generáljunk egy függvényt, ami a megadott elérési utat megkeresi.

function property() {
  var path = Array.prototype.slice.call(arguments);
  return function(object) {
    return prop(object, path);
  };
}

var head = property(0);
head([1, 2, 3, 4]); // 1
var length = property('length');
length([1, 2, 3, 4]); // 4

Ezután, ha meg akarjuk kapni a kapott tömbök első elemét, vagy elemeinek hosszát, könnyű a dolgunk:

[[1, 2, 3, 4], [5, 6, 7], [8, 9]].map(head); // [1, 5, 8]
['foo', 'bar', 'foobar', 'baz'].map(length); // [3, 3, 6, 3]
['foo', 'bar', 'foobar', 'baz'].map(length).reduce(add); // 15

Ha a megadott objektum különböző tulajdonságát akarjuk kikeresni, generálhatunk egy másik függvényt:

function propertyOf(object) {
  return function() {
    return prop(object, Array.prototype.slice.call(arguments));
  };
}
var myObj = {a: 1, b: 2, c: 3, d: {e : 4}}
myObjGetter = propertyOf(myObj);
myObjGetter('a') // 1
myObjGetter('d', 'e') // 4

Tulajdonság felhasználása

Ezek után már csak egy lépés egy általános elemkiereső pluck függvény felírása:

function pluck(array) {
  var path = Array.prototype.slice.call(arguments, 1);
  return array.map(function(object) {
    return prop(object, path);
  });
}
var nameList = [{name: 'Foo'}, {name: 'Bar'}, {name: 'Bazbar'}]
pluck(nameList, 'name'); // ["Foo", "Bar", "Bazbar"]

Ha meg akarjuk tudni, hogy mindegyik név hosszabb, mint 2 karakter, vagy van-e olyan, ami 3 karakter hosszú, egyszerű a dolgunk:

function greaterThan(value1) {
  return function(value2) {
    return value1 < value2;
  }
}
function equals(value1) {
  return function(value2) {
    return value1 === value2;
  }
}
var nameLengths = pluck(nameList, 'name').map(length); // [3, 3, 6]
var equals3 = equals(3);
nameLengths.every(greaterThan(2)); // true
nameLengths.every(equals3); // false
nameLengths.some(equals3); // true

Függvényekből felépülő függvények

Előfordulhat, hogy egy értékkel egymás után több műveletet szeretnénk végezni, mindig felhasználva az előző művelet eredményét. Erre való a flow:

function flow() {
  var functions = Array.prototype.slice.call(arguments);
  return function(target) {
    return functions.reduce(function(previous, current) {
      return current(previous);
    }, target);
  };
}
function multiply(a, b) {
  return a * b;
}
// f(x) = 2 * (3 + x)
[0, 1, 2, 3].map(
  flow(
    add.bind(null, 3),
    multiply.bind(null, 2)
  )
); // [6, 8, 10, 12]

Ha fordított sorrendben akarjuk megadni a függvényeket, akkor szokás a compose elnevezéssel élni. Ez megfelel a függvények felírási sorrendjének egy egyenletben.

function compose() {
  return flow.apply(null, Array.prototype.reverse.call(arguments));
}

function divider(b, a) {
    return a / b;
}
function round(precision) {
  var multiplier = precision ? Math.pow(10, precision) : 1;

  return precision ? 
    compose(
        divider.bind(null, multiplier), 
        Math.round,
        multiply.bind(null, multiplier)
    ) : 
    Math.round
}
var roundToSinglePrecision = round(1);
[.12, .345, 67.89].map(roundToSinglePrecision); // [0.1, 0.3, 67.9]

// f(x) = roundToSinglePrecision(100 * Math.abs(Math.cos(PI / 3 * x)))
[0, 1, 2, 3].map(
  compose(
    roundToSinglePrecision, 
    Math.abs,
    Math.cos, 
    multiply.bind(null, Math.PI / 3)
  )
); // [1, 0.5, 0.5, 1]

Letölthető függvénytárak

A két legnépszerűbb funkcionális függvénytár az Underscore.js és a lodash - utóbbi eredetileg az előbbi újraírt változata volt. Mindkettő rengeteg hasznos segítséget nyújt funkcionális programozáshoz, utóbbi legtöbb esetben gyorsabb, és több lehetőséggel rendelkezik. Jellemzőjük, hogy általánosságban első paraméterként várják az objektumot, amin a műveletet végzik, hasonlóan az fent említett each és map függvényekhez.

Jelentősen különbözik tőlük a Ramda, melynek alapvető tulajdonsága, hogy mindent a currying-re épít. Így például a map nem első, hanem utolsó paramétere a tömb. Ezzel lehetővé válik, hogy előre deklaráljuk az átalakítás menetét, majd többször alkalmazzuk különböző tömbökre.

Az Underscore.js-hez hasonló a Highland.js azzal a hatalmas különbséggel, hogy a műveletek egy adatfolyamon végzi. Azaz ahogy érkezik be folyamatosan az adat, úgy születik az adatfolyam másik végén az átalakított eredmény.

A Reactive Extensions for JavaScript egy Microsoft által indított projekt, mely sok tekintetben hasonlít a Highland.js-hez, ugyanakkor sokkal több nála. Adatfolyam helyett események folyamára alapoz és az API az Array#extras-ra épít paraméterezésében is.

Összefoglalás

Mint látható, igen minimális függvényekből komoly rendszert hozhatunk össze. Segítségükkel megkönnyíthetjük az adatfeldolgozást, validálást. A függvényeink egyszerűen tesztelhetők, működésük könnyen ellenőrízhető. Kombinálásukkal igen hasznos újabb függvényeket hozhatunk létre tovább egyszerűsítve az elvégzendő feladaton.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.