JavaScript の関数型プログラミングの概要

阿神
リリース: 2017-01-24 14:38:59
オリジナル
1232 人が閲覧しました

この記事の多くの部分では、関数型プログラミングの利点について説明しています。個人的に、著者は関数型プログラミングに一定の利点があることを認識していますが、特に複雑なアプリケーション ロジックの開発については、徹底的な関数型プログラミングを推奨しません。


JavaScript 関数型プログラミング

近年、関数型プログラミング (関数型プログラミング) は、このプログラミング概念を理解するかどうかに関係なく、JavaScript コミュニティで最もホットなトピックの 1 つになっていると思います。あなたはすでにそれについて何かを知っています。数年前、関数型プログラミングがまだ普及していなかったときでさえ、私は多くの大規模なアプリケーション コード ベースで関数型プログラミングの概念の詳細な実践が数多く行われていることを発見しました。関数型プログラミングとは、ソフトウェア開発プロジェクトにおける共有状態 (Shared State)、変更可能な状態 (Mutable Data)、および副作用 (Side Effects) の使用を避けることを意味します。関数型プログラミングでは、アプリケーション全体がデータによって駆動され、アプリケーションの状態がさまざまな純粋関数間を流れます。命令型プログラミングを好むオブジェクト指向プログラミングと比較して、関数型プログラミングは、コードがより単純で、より明確で、より予測可能で、よりテストしやすいものです。 。関数型プログラミングは本質的にプログラミング パラダイム (プログラミング パラダイム) であり、ソフトウェア システムを構築するための一連の基本的な定義原則を表します。他のプログラミング パラダイムには、オブジェクト指向プログラミングや手続き型プログラミングなどがあります。


純粋関数

名前が示すように、純粋関数は、多くの場合、入力パラメーターのみに基づいて出力を決定し、副作用を生成しない関数を指します。純粋関数の最も優れた機能の 1 つは、結果の予測可能性です。

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
ログイン後にコピー

z 変数は add 関数内で操作されません。つまり、 z の値は読み取られたり変更されません。これは単にパラメータとして入力された x 変数と y 変数を受け取り、その 2 つの合計を返します。この add 関数は典型的な純粋関数であり、add 関数に z 変数の読み取りまたは変更が含まれる場合、その純粋性は失われます。別の関数を見てみましょう:

function justTen() {
    return 10;
}
ログイン後にコピー

入力パラメーターのない関数の場合、純粋な関数のままにする場合、関数の戻り値は定数でなければなりません。ただし、固定定数を返すこのような関数は、特定の定数として定義することもできます。したがって、ほとんどの有用な純粋関数では少なくとも 1 つの入力パラメーターが許可されると考えられます。もう一度次の関数を見てください:

function addNoReturn(x, y) {
    var z = x + y
}
ログイン後にコピー

この関数は 2 つの入力パラメーター x と y を持ち、これら 2 つの変数を追加して z に代入することに注意してください。無意味と考えられます。ここで、ほとんどの有用な純粋関数には戻り値が必要であると言えます。要約すると、純粋関数には次の特殊効果が必要です。

●ほとんどの純粋関数は 1 つ以上のパラメーター値を持つ必要があります。

●純粋関数には戻り値が必要です。

●同じ入力を持つ純粋関数の戻り値は一貫していなければなりません。

●純粋な関数は副作用を生成できません。


共有状態と副作用

共有状態 (Shared State) は、共有スコープ (グローバル スコープとクロージャー スコープ) 内に存在することも、別のスコープの任意の変数に渡されるオブジェクト プロパティとして存在することもできます。オブジェクトまたはメモリ空間。オブジェクト指向プログラミングでは、他のオブジェクトにプロパティを追加することでオブジェクトを共有することがよくあります。共有状態の問題は、開発者が関数の役割を理解したい場合、その関数が各共有変数に与える影響を詳細に理解する必要があることです。たとえば、クライアントによって生成されたユーザー オブジェクトをサーバーに保存する必要がある場合、saveUser() 関数を使用してサーバーへのリクエストを開始し、ユーザー情報のエンコーディングを渡し、サーバーの応答を待つことができます。リクエストの開始と同時に、ユーザーは個人のアバターを変更し、別の関数 updateAvatar() と別の saveUser() リクエストをトリガーしました。通常、サーバーは最初に最初のリクエストに応答し、2 番目のリクエストのユーザー パラメータの変更に基づいて、メモリまたはデータベースに保存されているユーザー情報に対応する変更を加えます。ただし、予期しない状況によっては、最初のリクエストよりも 2 番目のリクエストがサーバーに到着し、ユーザーが選択した新しいアバターが最初のリクエストの古いアバターによって上書きされる場合があります。ここでサーバー上に保存されているユーザー情報はいわゆる共有状態であり、複数の同時リクエストによるデータ整合性の乱れはいわゆる競合状態であり、これも共有状態によって引き起こされる代表的な問題の一つである。共有状態に関するもう 1 つの一般的な問題は、共有状態に対する操作がタイミングに依存することが多いため、呼び出し順序が異なると不明なエラーが発生する可能性があることです。

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
ログイン後にコピー

副作用とは、関数呼び出し中の戻り値に反映されない、監視可能なアプリケーションの状態の変化を指します。一般的な副作用には以下が含まれますが、これらに限定されません。

● 外部変数または外部オブジェクトのプロパティを変更する

●コンソールの出力ログ

●写入文件

●发起网络通信

●触发任何外部进程事件

●调用任何其他具有副作用的函数

在函数式编程中我们会尽可能地规避副作用,保证程序更易于理解与测试。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)))))
ログイン後にコピー
ソース:php.cn
このウェブサイトの声明
この記事の内容はネチズンが自主的に寄稿したものであり、著作権は原著者に帰属します。このサイトは、それに相当する法的責任を負いません。盗作または侵害の疑いのあるコンテンツを見つけた場合は、admin@php.cn までご連絡ください。
最新の問題
人気のチュートリアル
詳細>
最新のダウンロード
詳細>
ウェブエフェクト
公式サイト
サイト素材
フロントエンドテンプレート