Maison > Article > interface Web > Introduction à la programmation fonctionnelle en JavaScript
De nombreuses parties de cet article expliquent les avantages de la programmation fonctionnelle. Personnellement, l'auteur reconnaît que la programmation fonctionnelle présente certains avantages, mais ne recommande pas une programmation fonctionnelle complète, notamment pour le développement de logiques d'application complexes.
Programmation fonctionnelle JavaScript
Ces dernières années, les fonctions La programmation fonctionnelle est devenue l'un des sujets les plus brûlants de la communauté JavaScript. Que vous appréciiez ou non ce concept de programmation, je pense que vous en savez déjà quelque chose. Même lorsque la programmation fonctionnelle n'était pas encore populaire il y a quelques années, j'ai découvert de nombreuses pratiques approfondies des concepts de programmation fonctionnelle dans de nombreuses grandes bases de code d'application. La programmation fonctionnelle signifie éviter l'utilisation de l'état partagé (Shared State), de l'état mutable (Mutable Data) et des effets secondaires (Side Effects) dans les projets de développement logiciel. En programmation fonctionnelle, l’ensemble de l’application est piloté par les données et l’état de l’application circule entre différentes fonctions pures. Par rapport à la programmation orientée objet, qui préfère la programmation impérative, la programmation fonctionnelle préfère la programmation déclarative. Le code est plus simple, plus clair, plus prévisible et plus testable. . La programmation fonctionnelle est essentiellement un paradigme de programmation (Programming Paradigm), qui représente une série de principes de définition de base pour la construction de systèmes logiciels. D'autres paradigmes de programmation incluent la programmation orientée objet et la programmation procédurale.
Fonction pure
Comme le nom suggère, les fonctions de fonction pure font souvent référence à des fonctions qui déterminent la sortie en fonction uniquement des paramètres d'entrée et ne produisent aucun effet secondaire. L'une des meilleures caractéristiques des fonctions pures est la prévisibilité de leurs résultats :
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
La variable z n'est pas manipulée dans la fonction add, c'est-à-dire que la valeur de z n'est ni lue ni modifiée. Il prend simplement les variables x et y entrées comme paramètres et renvoie la somme des deux. Cette fonction d'ajout est une fonction pure typique. Si la fonction d'ajout implique la lecture ou la modification de la variable z, elle perd sa pureté. Regardons une autre fonction :
function justTen() { return 10; }
Pour une fonction sans aucun paramètre d'entrée, si elle doit rester une fonction pure, la valeur de retour de la fonction doit être une constante. Cependant, une fonction comme celle-ci qui renvoie une constante fixe pourrait tout aussi bien être définie comme une certaine constante. Il n'est donc pas nécessaire d'abuser de la fonction. Par conséquent, nous pouvons penser que les fonctions pures les plus utiles autorisent au moins un paramètre d'entrée. Regardez à nouveau la fonction suivante :
function addNoReturn(x, y) { var z = x + y }
Notez que cette fonction ne renvoie aucune valeur. Elle a deux paramètres d'entrée x et y, puis ajoute les deux variables et les affecte à z, donc ceci La fonction peut également être considérée comme dénuée de sens. Ici, nous pouvons dire que les fonctions pures les plus utiles doivent avoir une valeur de retour. En résumé, les fonctions pures devraient avoir les effets spéciaux suivants :
● La plupart des fonctions pures devraient avoir une ou plusieurs valeurs de paramètre.
● Les fonctions pures doivent avoir une valeur de retour.
●Les valeurs de retour des fonctions pures avec la même entrée doivent être cohérentes.
Les fonctions pures ne peuvent produire aucun effet secondaire.
État partagé et effets secondaires
L'état partagé peut être n'importe quelle variable , objet ou espace mémoire qui existe dans une portée partagée (portée globale et portée de fermeture) ou en tant que propriété d'objet transmise à une portée différente. En programmation orientée objet, nous partageons souvent un objet en ajoutant des propriétés à d'autres objets. Le problème avec l’état partagé est que si les développeurs veulent comprendre le rôle d’une fonction, ils doivent comprendre en détail l’impact que cette fonction peut avoir sur chaque variable partagée. Par exemple, si nous devons maintenant enregistrer l'objet utilisateur généré par le client sur le serveur, nous pouvons utiliser la fonction saveUser() pour lancer une requête au serveur, transmettre l'encodage des informations utilisateur et attendre que le serveur réponde. Au même moment où vous avez lancé la requête, l'utilisateur a modifié l'avatar personnel, déclenchant une autre fonction updateAvatar() et une autre requête saveUser(). Normalement, le serveur répondra d'abord à la première demande et apportera les modifications correspondantes aux informations utilisateur stockées dans la mémoire ou la base de données en fonction des modifications des paramètres utilisateur dans la deuxième demande. Cependant, dans certaines circonstances inattendues, la deuxième requête peut arriver au serveur avant la première requête, de sorte que le nouvel avatar sélectionné par l'utilisateur sera écrasé par l'ancien avatar lors de la première requête. Les informations utilisateur stockées sur le serveur ici sont ce qu'on appelle l'état partagé, et le trouble de cohérence des données provoqué par plusieurs requêtes simultanées est ce qu'on appelle la condition de concurrence critique, qui est également l'un des problèmes typiques causés par l'état partagé. Un autre problème courant avec l'état partagé est que différents ordres d'appel peuvent déclencher des erreurs inconnues, car les opérations sur l'état partagé dépendent souvent du timing.
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
Les effets secondaires font référence à tout changement observable dans l'état de l'application qui n'est pas reflété par la valeur de retour lors de l'appel de fonction. Les effets secondaires courants incluent, sans s'y limiter :
● Modifier tout. variables externes Ou propriétés d'objets externes
● Journaux de sortie dans la console
●写入文件
●发起网络通信
●触发任何外部进程事件
●调用任何其他具有副作用的函数
在函数式编程中我们会尽可能地规避副作用,保证程序更易于理解与测试。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)))))