Heim > Artikel > Web-Frontend > Einführung in die funktionale Programmierung in JavaScript
Viele Teile dieses Artikels erläutern die Vorteile der funktionalen Programmierung. Der Autor erkennt persönlich an, dass die funktionale Programmierung bestimmte Vorteile hat, empfiehlt jedoch keine vollständige funktionale Programmierung, insbesondere für die Entwicklung komplexer Anwendungslogik.
JavaScript Functional Programming
In den letzten Jahren Funktionen Funktionale Programmierung ist zu einem der heißesten Themen in der JavaScript-Community geworden. Ob Sie dieses Programmierkonzept schätzen oder nicht, ich glaube, Sie wissen bereits etwas darüber. Selbst als funktionale Programmierung vor einigen Jahren noch nicht populär war, habe ich in vielen großen Anwendungscodebasen viele tiefgreifende Praktiken funktionaler Programmierkonzepte entdeckt. Funktionale Programmierung bedeutet, die Verwendung von gemeinsam genutzten Zuständen (Shared State), veränderlichen Zuständen (Mutable Data) und Nebenwirkungen (Side Effects) in Softwareentwicklungsprojekten zu vermeiden. Bei der funktionalen Programmierung wird die gesamte Anwendung durch Daten gesteuert, und der Zustand der Anwendung fließt zwischen verschiedenen reinen Funktionen. Im Vergleich zur objektorientierten Programmierung, die die imperative Programmierung bevorzugt, ist die funktionale Programmierung die deklarative Programmierung. Der Code ist einfacher, klarer, vorhersehbarer und testbarer. . Funktionale Programmierung ist im Wesentlichen ein Programmierparadigma (Programmierparadigma), das eine Reihe grundlegender Definitionsprinzipien für den Aufbau von Softwaresystemen darstellt. Weitere Programmierparadigmen sind die objektorientierte Programmierung und die prozedurale Programmierung.
Reine Funktion
Wie der Name schlägt vor, reine Funktion. Funktionen beziehen sich oft auf Funktionen, die die Ausgabe nur auf der Grundlage von Eingabeparametern bestimmen und keine Nebenwirkungen erzeugen. Eine der besten Eigenschaften reiner Funktionen ist die Vorhersagbarkeit ihrer Ergebnisse:
var z = 10; function add(x, y) { return x + y; } console.log(add(1, 2)); // prints 3 console.log(add(1, 2)); // still prints 3 console.log(add(1, 2)); // WILL ALWAYS print 3
Die z-Variable wird in der Additionsfunktion nicht manipuliert, d. h. der Wert von z wird nicht gelesen oder geändert. Es nimmt einfach die als Parameter eingegebenen x- und y-Variablen und gibt die Summe der beiden zurück. Diese Additionsfunktion ist eine typische reine Funktion. Wenn die Additionsfunktion das Lesen oder Ändern der z-Variablen beinhaltet, verliert sie ihre Reinheit. Schauen wir uns eine andere Funktion an:
function justTen() { return 10; }
Wenn eine Funktion ohne Eingabeparameter eine reine Funktion bleiben soll, muss der Rückgabewert der Funktion eine Konstante sein. Eine Funktion wie diese, die eine feste Konstante zurückgibt, könnte jedoch genauso gut als eine bestimmte Konstante definiert werden. Daher können wir davon ausgehen, dass die meisten nützlichen reinen Funktionen mindestens einen Eingabeparameter zulassen. Schauen Sie sich die folgende Funktion noch einmal an:
function addNoReturn(x, y) { var z = x + y }
Beachten Sie, dass diese Funktion keinen Wert zurückgibt. Sie hat zwei Eingabeparameter x und y und fügt dann die beiden Variablen hinzu und weist sie z zu, also dies Die Funktion kann auch als bedeutungslos angesehen werden. Hier können wir sagen, dass die nützlichsten reinen Funktionen einen Rückgabewert haben müssen. Zusammenfassend sollten reine Funktionen die folgenden Spezialeffekte haben:
●Die meisten reinen Funktionen sollten einen oder mehrere Parameterwerte haben.
●Reine Funktionen müssen einen Rückgabewert haben.
●Die Rückgabewerte reiner Funktionen mit derselben Eingabe müssen konsistent sein.
●Reine Funktionen können keine Nebenwirkungen hervorrufen.
Shared State und Nebenwirkungen
Shared State kann eine beliebige Variable sein , Objekt oder Speicherplatz, der in einem gemeinsam genutzten Bereich (globaler Bereich und Abschlussbereich) oder als Objekteigenschaft vorhanden ist, die an einen anderen Bereich übergeben wird. Bei der objektorientierten Programmierung teilen wir häufig ein Objekt, indem wir Eigenschaften zu anderen Objekten hinzufügen. Das Problem beim Shared State besteht darin, dass Entwickler, wenn sie die Rolle einer Funktion verstehen wollen, im Detail verstehen müssen, welche Auswirkungen diese Funktion auf jede Shared Variable haben kann. Wenn wir nun beispielsweise das vom Client generierte Benutzerobjekt auf dem Server speichern müssen, können wir mit der Funktion saveUser() eine Anfrage an den Server initiieren, die Codierung der Benutzerinformationen übergeben und auf die Antwort des Servers warten. Zur gleichen Zeit, als Sie die Anfrage initiierten, änderte der Benutzer den persönlichen Avatar und löste dadurch eine weitere Funktion updateAvatar() und eine weitere saveUser()-Anfrage aus. Normalerweise antwortet der Server zuerst auf die erste Anfrage und nimmt basierend auf den Änderungen der Benutzerparameter in der zweiten Anfrage entsprechende Änderungen an den im Speicher oder in der Datenbank gespeicherten Benutzerinformationen vor. Unter einigen unerwarteten Umständen kann es jedoch vorkommen, dass die zweite Anfrage vor der ersten Anfrage beim Server eintrifft, sodass der vom Benutzer ausgewählte neue Avatar durch den alten Avatar in der ersten Anfrage überschrieben wird. Die hier auf dem Server gespeicherten Benutzerinformationen sind der sogenannte Shared State, und die durch mehrere gleichzeitige Anforderungen verursachte Datenkonsistenzstörung ist die sogenannte Race Condition, die ebenfalls eines der typischen Probleme ist, die durch den Shared State verursacht werden. Ein weiteres häufiges Problem bei Shared State besteht darin, dass unterschiedliche Aufrufreihenfolgen unbekannte Fehler auslösen können, da Vorgänge im Shared State oft zeitabhängig sind.
const x = { val: 2 }; const x1 = () => x.val += 1; const x2 = () => x.val *= 2; x1(); x2(); console.log(x.val); // 6 const y = { val: 2 }; const y1 = () => y.val += 1; const y2 = () => y.val *= 2; // 交换了函数调用顺序 y2(); y1(); // 最后的结果也受到了影响 console.log(y.val); // 5
Nebenwirkungen beziehen sich auf alle beobachtbaren Änderungen des Anwendungsstatus, die nicht durch den Rückgabewert während des Funktionsaufrufs widergespiegelt werden, unter anderem:
● Ändern Sie alle externe Variablen oder externe Objekteigenschaften
● Ausgabeprotokolle in der Konsole
●写入文件
●发起网络通信
●触发任何外部进程事件
●调用任何其他具有副作用的函数
在函数式编程中我们会尽可能地规避副作用,保证程序更易于理解与测试。Haskell或者其他函数式编程语言通常会使用Monads来隔离与封装副作用。在绝大部分真实的应用场景进行编程开始时,我们不可能保证系统中的全部函数都是纯函数,但是我们应该尽可能地增加纯函数的数目并且将有副作用的部分与纯函数剥离开来,特别是将业务逻辑抽象为纯函数,来保证软件更易于扩展、重构、调试、测试与维护。这也是很多前端框架鼓励开发者将用户的状态管理与组件渲染相隔离,构建松耦合模块的原因。
不变性
不可变对象(Immutable Object)指那些创建之后无法再被修改的对象,与之相对的可变对象(Mutable Object)指那些创建之后仍然可以被修改的对象。不可变性(Immutability)是函数式编程的核心思想之一,保证了程序运行中数据流的无损性。如果我们忽略或者抛弃了状态变化的历史,那么我们很难去捕获或者复现一些奇怪的小概率问题。使用不可变对象的优势在于你在程序的任何地方访问任何的变量,你都只有只读权限,也就意味着我们不用再担心意外的非法修改的情况。另一方面,特别是在多线程编程中,每个线程访问的变量都是常量,因此能从根本上保证线程的安全性。总结而言,不可变对象能够帮助我们构建简单而更加安全的代码。
在JavaScript中,我们需要搞清楚const与不可变性之间的区别。const声明的变量名会绑定到某个内存空间而不可以被二次分配,其并没有创建真正的不可变对象。你可以不修改变量的指向,但是可以修改该对象的某个属性值,因此const创建的还是可变对象。JavaScript中最方便的创建不可变对象的方法就是调用Object.freeze()函数,其可以创建一层不可变对象
const a = Object.freeze({ foo: 'Hello', bar: 'world', baz: '!' }); a.foo = 'Goodbye'; // Error: Cannot assign to read only property 'foo' of object Object
不过这种对象并不是彻底的不可变数据,譬如如下的对象就是可变的:
const a = Object.freeze({ foo: { greeting: 'Hello' }, bar: 'world', baz: '!' }); a.foo.greeting = 'Goodbye'; console.log(`${ a.foo.greeting }, ${ a.bar }${a.baz}`);
如上所见,顶层的基础类型属性是不可以改变的,不过如果对象类型的属性,譬如数组等,仍然是可以变化的。在很多函数式编程语言中,会提供特殊的不可变数据结构Trie Data Structures来实现真正的不可变数据结构,任何层次的属性都不可以被改变。Tries还可以利用结构共享(Structural Sharing)的方式来在新旧对象之间共享未改变的对象属性值,从而减少内存占用并且显著提升某些操作的性能。JavaScript中虽然语言本身并没有提供给我们这个特性,但是可以通过Immutable.js与Mori这些辅助库来利用Tries的特性。我个人两个库都使用过,不过在大型项目中会更倾向于使用Immutable.js。估计到这边,很多习惯了命令式编程的同学都会大吼一句:在没有变量的世界里我又该如何编程呢?不要担心,现在我们考虑下我们何时需要去修改变量值:譬如修改某个对象的属性值,或者在循环中修改某个循环计数器的值。而函数式编程中与直接修改原变量值相对应的就是创建原值的一个副本并且将其修改之后赋予给变量。而对于另一个常见的循环场景,譬如我们所熟知的for,while,do,repeat这些关键字,我们在函数式编程中可以使用递归来实现原本的循环需求:
// 简单的循环构造 var acc = 0; for (var i = 1; i <= 10; ++i) acc += i; console.log(acc); // prints 55 // 递归方式实现 function sumRange(start, end, acc) { if (start > end) return acc; return sumRange(start + 1, end, acc + start) } console.log(sumRange(1, 10, 0)); // prints 55
注意在递归中,与变量i相对应的即是start变量,每次将该值加1,并且将acc+start作为当前和值传递给下一轮递归操作。在递归中,并没有修改任何的旧的变量值,而是根据旧值计算出新值并且进行返回。不过如果真的让你把所有的迭代全部转变成递归写法,估计得疯掉,这个不可避免地会受到JavaScript语言本身的混乱性所影响,并且迭代式的思维也不是那么容易理解的。而在Elm这种专门面向函数式编程的语言中,语法会简化很多:
sumRange start end acc = if start > end then acc else sumRange (start + 1) end (acc + start)
其每一次的迭代记录如下:
sumRange 1 10 0 = -- sumRange (1 + 1) 10 (0 + 1) sumRange 2 10 1 = -- sumRange (2 + 1) 10 (1 + 2) sumRange 3 10 3 = -- sumRange (3 + 1) 10 (3 + 3) sumRange 4 10 6 = -- sumRange (4 + 1) 10 (6 + 4) sumRange 5 10 10 = -- sumRange (5 + 1) 10 (10 + 5) sumRange 6 10 15 = -- sumRange (6 + 1) 10 (15 + 6) sumRange 7 10 21 = -- sumRange (7 + 1) 10 (21 + 7) sumRange 8 10 28 = -- sumRange (8 + 1) 10 (28 + 8) sumRange 9 10 36 = -- sumRange (9 + 1) 10 (36 + 9) sumRange 10 10 45 = -- sumRange (10 + 1) 10 (45 + 10) sumRange 11 10 55 = -- 11 > 10 => 55 55
高阶函数
函数式编程倾向于重用一系列公共的纯函数来处理数据,而面向对象编程则是将方法与数据封装到对象内。这些被封装起来的方法复用性不强,只能作用于某些类型的数据,往往只能处理所属对象的实例这种数据类型。而函数式编程中,任何类型的数据则是被一视同仁,譬如map()函数允许开发者传入函数参数,保证其能够作用于对象、字符串、数字,以及任何其他类型。JavaScript中函数同样是一等公民,即我们可以像其他类型一样处理函数,将其赋予变量、传递给其他函数或者作为函数返回值。而高阶函数(Higher Order Function)则是能够接受函数作为参数,能够返回某个函数作为返回值的函数。高阶函数经常用在如下场景:
●利用回调函数、Promise或者Monad来抽象或者隔离动作、作用以及任何的异步控制流
●构建能够作用于泛数据类型的工具函数
●函数重用或者创建柯里函数
●将输入的多个函数并且返回这些函数复合而来的复合函数
典型的高阶函数的应用就是复合函数,作为开发者,我们天性不希望一遍一遍地重复构建、测试与部分相同的代码,我们一直在寻找合适的只需要写一遍代码的方法以及如何将其重用于其他模块。代码重用听上去非常诱人,不过其在很多情况下是难以实现的。如果你编写过于偏向具体业务的代码,那么就会难以重用。而如果你把每一段代码都编写的过于泛化,那么你就很难将这些代码应用于具体的有业务场景,而需要编写额外的连接代码。而我们真正追寻的就是在具体与泛化之间寻求一个平衡点,能够方便地编写短小精悍而可复用的代码片,并且能够将这些小的代码片快速组合而解决复杂的功能需求。在函数式编程中,函数就是我们能够面向的最基础代码块,而在函数式编程中,对于基础块的组合就是所谓的函数复合(Function Composition)。我们以如下两个简单的JavaScript函数为例:
var add10 = function(value) { return value + 10; }; var mult5 = function(value) { return value * 5; };
如果你习惯了使用ES6,那么可以用Arrow Function重构上述代码:
var add10 = value => value + 10; var mult5 = value => value * 5;
现在看上去清爽多了吧,下面我们考虑面对一个新的函数需求,我们需要构建一个函数,首先将输入参数加10然后乘以5,我们可以创建一个新函数如下:
var mult5AfterAdd10 = value => 5 * (value + 10)
尽管上面这个函数也很简单,我们还是要避免任何函数都从零开始写,这样也会让我们做很多重复性的工作。我们可以基于上文的add10与mult5这两个函数来构建新的函数:
var mult5AfterAdd10 = value => mult5(add10(value));
在mult5AfterAdd10函数中,我们已经站在了add10与mult5这两个函数的基础上,不过我们可以用更优雅的方式来实现这个需求。在数学中,我们认为f ∘ g是所谓的Function Composition,因此`f ∘ g可以认为等价于f(g(x)),我们同样可以基于这种思想重构上面的mult5AfterAdd10。不过JavaScript中并没有原生的Function Composition支持,在Elm中我们可以用如下写法:
add10 value = value + 10 mult5 value = value * 5 mult5AfterAdd10 value = (mult5 << add10) value
这里的<<操作符也就指明了在Elm中是如何组合函数的,同时也较为直观的展示出了数据的流向。首先value会被赋予给add10,然后add10的结果会流向mult5。另一个需要注意的是,(mult5 << add10)中的中括号是为了保证函数组合会在函数调用之前。你也可以组合更多的函数:
f x = (g << h << s << r << t) x
如果在JavaScript中,你可能需要以如下的递归调用来实现该功能:
g(h(s(r(t(x)))))