第4版が公開されています。こちらで読んでください

第5章高階関数

子利と子思は、最新のプログラムの規模について自慢していました。「20万行です」と子利は言いました。「コメントは数えていません!」子思は答えました。「ふーん、私のプログラムはもう100万行近くあるよ。」元馬師は言いました。「私の最高のプログラムは500行です。」これを聞いて、子利と子思は悟りました。

元馬師, プログラミングの書

ソフトウェア設計には2つの方法があります。1つは、欠陥がないほど単純にする方法であり、もう1つは、欠陥が明らかにならないほど複雑にする方法です。

C.A.R. ホエア, 1980年ACMチューリング賞受賞講演
Letters from different scripts

大規模なプログラムはコストのかかるプログラムであり、構築にかかる時間だけではありません。規模はほぼ常に複雑さと関連しており、複雑さはプログラマを混乱させます。混乱したプログラマは、プログラムに間違い(バグ)を導入します。したがって、大規模なプログラムはこれらのバグが隠れるための多くのスペースを提供し、発見を困難にします。

はじめにあった最後の2つのプログラムの例に簡単に触れましょう。最初のプログラムは自己完結型で、6行です。

let total = 0, count = 1;
while (count <= 10) {
  total += count;
  count += 1;
}
console.log(total);

2番目のプログラムは2つの外部関数に依存し、1行です。

console.log(sum(range(1, 10)));

どちらの方がバグが含まれている可能性が高いですか?

`sum`と`range`の定義のサイズを考慮すると、2番目のプログラムも大きくなります—最初のプログラムよりも大きくなります。しかしそれでも、私はそれがより正確である可能性が高いと主張します。

それは、解決策が解決されている問題に対応する語彙で表現されているため、より正確である可能性が高いです。数値の範囲の合計は、ループやカウンターに関するものではありません。範囲と合計に関するものです。

この語彙(関数`sum`と`range`)の定義には、ループ、カウンター、およびその他の付随的な詳細が含まれます。しかし、それらはプログラム全体よりも単純な概念を表しているため、正しく理解するのが容易です。

抽象化

プログラミングの文脈では、この種の語彙は通常、抽象化と呼ばれます。抽象化は詳細を隠蔽し、より高い(またはより抽象的な)レベルで問題について話す能力を与えてくれます。

例として、次の2つのエンドウ豆スープのレシピを比較してみましょう。最初のレシピは次のとおりです。

乾燥エンドウ豆を1人につき1カップ、容器に入れます。エンドウ豆が十分に浸るまで水を追加します。エンドウ豆を水に少なくとも12時間浸します。エンドウ豆を水から取り出し、鍋に入れます。1人につき水を4カップ追加します。鍋に蓋をして、エンドウ豆を2時間煮込みます。玉ねぎを1人につき半分取ります。ナイフで刻みます。エンドウ豆に加えます。セロリを1人につき1本取ります。ナイフで刻みます。エンドウ豆に加えます。ニンジンを1人につき1本取ります。刻みます。ナイフで!エンドウ豆に加えます。さらに10分間調理します。

そして、これは2番目のレシピです。

1人分:乾燥割れたエンドウ豆1カップ、刻んだ玉ねぎ半分、セロリ1本、ニンジン1本。

エンドウ豆を12時間浸します。1人あたり4カップの水で2時間煮込みます。野菜を刻んで加えます。さらに10分間調理します。

2番目のレシピは短く、解釈しやすいです。しかし、浸す煮込む刻む、そしておそらく野菜などの料理に関するいくつかの単語を理解する必要があります。

プログラミングでは、辞書で必要なすべての単語が待っているとは限りません。そのため、最初のレシピのパターンに陥る可能性があります—コンピュータが実行しなければならない正確な手順を1つずつ考え出し、それらが表現する上位レベルの概念を無視します。

プログラミングでは、抽象化のレベルが低すぎる場合に気づくことは有用なスキルです。

繰り返しを抽象化する

これまで見てきたように、プレーンな関数は抽象化を構築するための良い方法です。しかし、時としてそれは不十分です。

プログラムが特定の回数何かを行うことは一般的です。そのためには、次のような`for`ループを書くことができます。

for (let i = 0; i < 10; i++) {
  console.log(i);
}

「何かを*N*回行う」を関数として抽象化できますか?まあ、`console.log`を*N*回呼び出す関数を記述するのは簡単です。

function repeatLog(n) {
  for (let i = 0; i < n; i++) {
    console.log(i);
  }
}

しかし、数値のログ出力以外の何かを行いたい場合はどうでしょうか?「何かを行う」ことは関数で表すことができ、関数は単なる値であるため、アクションを関数値として渡すことができます。

function repeat(n, action) {
  for (let i = 0; i < n; i++) {
    action(i);
  }
}

repeat(3, console.log);
// → 0
// → 1
// → 2

`repeat`に事前に定義された関数を渡す必要はありません。多くの場合、その場で関数値を作成する方が簡単です。

let labels = [];
repeat(5, i => {
  labels.push(`Unit ${i + 1}`);
});
console.log(labels);
// → ["Unit 1", "Unit 2", "Unit 3", "Unit 4", "Unit 5"]

これは`for`ループと少し似ていますが、最初にループの種類を記述し、次に本体を提供します。ただし、本体は関数値として記述されており、`repeat`への呼び出しの括弧で囲まれています。そのため、閉じ括弧と閉じ括弧で閉じる必要があります。この例のように、本体が小さな単一の式である場合は、中括弧を省略し、ループを1行で記述することもできます。

高階関数

他の関数に対して操作を行う関数(引数として受け取るか、返すか)は、高階関数と呼ばれます。関数は通常の値であることをすでに見てきたため、そのような関数が存在するという事実に特に注目すべきことはありません。この用語は数学に由来しており、関数と他の値の区別がより真剣に受け止められています。

高階関数は、値だけでなくアクションについても抽象化できます。いくつかの形式があります。たとえば、新しい関数を作成する関数を持つことができます。

function greaterThan(n) {
  return m => m > n;
}
let greaterThan10 = greaterThan(10);
console.log(greaterThan10(11));
// → true

そして、他の関数を変更する関数を持つことができます。

function noisy(f) {
  return (...args) => {
    console.log("calling with", args);
    let result = f(...args);
    console.log("called with", args, ", returned", result);
    return result;
  };
}
noisy(Math.min)(3, 2, 1);
// → calling with [3, 2, 1]
// → called with [3, 2, 1] , returned 1

新しいタイプの制御フローを提供する関数を書くことさえできます。

function unless(test, then) {
  if (!test) then();
}

repeat(3, n => {
  unless(n % 2 == 1, () => {
    console.log(n, "is even");
  });
});
// → 0 is even
// → 2 is even

`for`/`of`ループのようなものを高階関数として提供する組み込みの配列メソッド`forEach`があります。

["A", "B"].forEach(l => console.log(l));
// → A
// → B

スクリプトデータセット

高階関数が優れている分野の1つはデータ処理です。データを処理するには、実際のデータが必要です。この章では、ラテン文字、キリル文字、アラビア文字などの筆記システムに関するデータセットを使用します。

第1章で説明したUnicode、つまり書かれた言語の各文字に番号を割り当てるシステムを覚えていますか?これらの文字のほとんどは、特定のスクリプトに関連付けられています。この標準には140種類の異なるスクリプトが含まれています。そのうち81種類は現在も使用されており、59種類は歴史的なものです。

私はラテン文字しか流暢に読むことができませんが、少なくとも80の他の筆記システムで人々がテキストを書いていることを高く評価しています。それらの多くは、私ですら認識できないでしょう。たとえば、タミル語の手書きのサンプルを次に示します。

Tamil handwriting

このサンプルデータセットには、Unicodeで定義されている140個のスクリプトに関するいくつかの情報が含まれています。この章のコーディングサンドボックスで`SCRIPTS`バインディングとして利用できます。このバインディングには、オブジェクトの配列が含まれており、それぞれがスクリプトを記述しています。

{
  name: "Coptic",
  ranges: [[994, 1008], [11392, 11508], [11513, 11520]],
  direction: "ltr",
  year: -200,
  living: false,
  link: "https://en.wikipedia.org/wiki/Coptic_alphabet"
}

このようなオブジェクトは、スクリプトの名前、それに割り当てられたUnicode範囲、書き込み方向、(おおよその)起源時間、現在も使用されているかどうか、および詳細情報のリンクを教えてくれます。方向は、左から右に"ltr"、右から左に"rtl"(アラビア語やヘブライ語のテキストの書き方)、上から下に"ttb"(モンゴル語の書き方など)です。

ranges プロパティは、Unicode 文字範囲の配列を含んでいます。各範囲は、下限と上限を含む2要素の配列です。これらの範囲内の任意の文字コードは、スクリプトに割り当てられます。下限は包含的(コード994はコプト文字です)、上限は非包含的(コード1008ではありません)。

配列のフィルタリング

データセットで使用されているスクリプトを見つけるには、次の関数が役立つ場合があります。これは、テストに合格しない配列の要素を除外します。

function filter(array, test) {
  let passed = [];
  for (let element of array) {
    if (test(element)) {
      passed.push(element);
    }
  }
  return passed;
}

console.log(filter(SCRIPTS, script => script.living));
// → [{name: "Adlam", …}, …]

この関数は、testという名前の引数(関数値)を使用して、計算の「ギャップ」—どの要素を収集するかを決定するプロセス—を埋めます。

filter関数は、既存の配列から要素を削除するのではなく、テストに合格した要素のみを含む新しい配列を作成することに注意してください。この関数は *純粋* です。渡された配列を変更しません。

forEachと同様に、filterは標準的な配列メソッドです。この例では、内部で行われていることを示すためだけに関数を定義しました。これからは、代わりにこのように使用します。

console.log(SCRIPTS.filter(s => s.direction == "ttb"));
// → [{name: "Mongolian", …}, …]

map による変換

何らかの方法でSCRIPTS配列をフィルタリングすることによって生成された、スクリプトを表すオブジェクトの配列があるとします。しかし、検査しやすい名前の配列が必要な場合があります。

mapメソッドは、すべての要素に関数を適用し、返された値から新しい配列を作成することにより、配列を変換します。新しい配列の長さは入力配列と同じになりますが、内容は関数の適用によって新しい形式に *マップ* されます。

function map(array, transform) {
  let mapped = [];
  for (let element of array) {
    mapped.push(transform(element));
  }
  return mapped;
}

let rtlScripts = SCRIPTS.filter(s => s.direction == "rtl");
console.log(map(rtlScripts, s => s.name));
// → ["Adlam", "Arabic", "Imperial Aramaic", …]

forEachfilterと同様に、mapは標準的な配列メソッドです。

reduce による集計

配列で一般的に行われるもう一つのことは、それらから単一の値を計算することです。繰り返し例である、数値の集合の合計は、これの一例です。別の例としては、最も多くの文字を持つスクリプトを見つけることが挙げられます。

このパターンを表す高階演算は、*reduce*(時には*fold*とも呼ばれます)と呼ばれます。これは、配列から単一の要素を繰り返し取得し、現在の値と組み合わせることで値を作成します。数値を合計する場合、0から始め、各要素に対してその値を合計に加えます。

reduceのパラメータは、配列とは別に、結合関数と開始値です。この関数はfiltermapほど単純ではありませんので、よく見てください。

function reduce(array, combine, start) {
  let current = start;
  for (let element of array) {
    current = combine(current, element);
  }
  return current;
}

console.log(reduce([1, 2, 3, 4], (a, b) => a + b, 0));
// → 10

もちろんこの関数に対応する標準的な配列メソッドreduceには、利便性が追加されています。配列に少なくとも1つの要素が含まれている場合、start引数を省略できます。メソッドは配列の最初の要素を開始値として使用し、2番目の要素から削減を開始します。

console.log([1, 2, 3, 4].reduce((a, b) => a + b));
// → 10

最も多くの文字を持つスクリプトを見つけるためにreduce(2回)を使用するには、次のように記述できます。

function characterCount(script) {
  return script.ranges.reduce((count, [from, to]) => {
    return count + (to - from);
  }, 0);
}

console.log(SCRIPTS.reduce((a, b) => {
  return characterCount(a) < characterCount(b) ? b : a;
}));
// → {name: "Han", …}

characterCount関数は、スクリプトに割り当てられた範囲のサイズを合計することで削減します。reducer関数の仮引数リストでのデストラクチャリングの使用に注目してください。次に、2番目のreduce呼び出しはこれを用いて、2つのスクリプトを繰り返し比較し、より大きい方を返すことで、最大のスクリプトを見つけます。

漢字には、Unicode標準で89,000以上の文字が割り当てられており、データセットの中で最も大きな文字体系です。漢字は、中国語、日本語、韓国語のテキストに使用される(場合もある)スクリプトです。これらの言語は多くの文字を共有していますが、書き方は異なります。 (米国を拠点とする)Unicodeコンソーシアムは、文字コードを節約するために、それらを単一の文字体系として扱うことにしました。これは*漢字統一*と呼ばれ、今でも一部の人々を非常に怒らせています。

合成可能性

高階関数なしで前の例(最大のスクリプトを見つける)をどのように記述するかを考えてみましょう。コードはそれほど悪化していません。

let biggest = null;
for (let script of SCRIPTS) {
  if (biggest == null ||
      characterCount(biggest) < characterCount(script)) {
    biggest = script;
  }
}
console.log(biggest);
// → {name: "Han", …}

バインディングがいくつか追加され、プログラムは4行長くなります。しかし、それでも非常に読みやすいです。

高階関数は、操作を *合成* する必要がある場合に威力を発揮し始めます。例として、データセットの存続しているスクリプトと消滅したスクリプトの平均起源年を見つけるコードを作成しましょう。

function average(array) {
  return array.reduce((a, b) => a + b) / array.length;
}

console.log(Math.round(average(
  SCRIPTS.filter(s => s.living).map(s => s.year))));
// → 1165
console.log(Math.round(average(
  SCRIPTS.filter(s => !s.living).map(s => s.year))));
// → 204

したがって、Unicodeの消滅したスクリプトは、平均して、存続しているスクリプトよりも古いことがわかります。これは、それほど意味のある統計でも驚くべき統計でもありません。しかし、それを計算するために使用されるコードが読みやすいことに同意していただけると思います。これはパイプラインとして見ることができます。すべてのスクリプトから始まり、存続している(または消滅した)スクリプトを除外し、それらの年から取得し、平均を計算し、結果を四捨五入します。

この計算を1つの大きなループとして記述することもできます。

let total = 0, count = 0;
for (let script of SCRIPTS) {
  if (script.living) {
    total += script.year;
    count += 1;
  }
}
console.log(Math.round(total / count));
// → 1165

しかし、何がどのように計算されているのかを見ることが困難です。中間結果が首尾一貫した値として表現されていないため、averageのようなものを別の関数に抽出する作業が大幅に増加します。

コンピューターが実際に実行していることについては、これら2つのアプローチも大きく異なります。最初のものは、filtermapを実行するときに新しい配列を作成しますが、2番目のものは数値をいくつか計算するだけで、作業量が少なくなります。通常は読みやすいアプローチを使用できますが、巨大な配列を何度も処理する必要がある場合は、抽象度の低いスタイルの方が高速になる場合があります。

文字列と文字コード

データセットの1つの用途は、テキストが使用しているスクリプトを判断することです。これを行うプログラムについて説明します。

各スクリプトには、それに関連付けられた文字コード範囲の配列があることを思い出してください。したがって、文字コードが与えられると、次のような関数を使用して対応するスクリプト(存在する場合)を見つけることができます。

function characterScript(code) {
  for (let script of SCRIPTS) {
    if (script.ranges.some(([from, to]) => {
      return code >= from && code < to;
    })) {
      return script;
    }
  }
  return null;
}

console.log(characterScript(121));
// → {name: "Latin", …}

someメソッドは、別の高階関数です。テスト関数を取得し、その関数が配列の要素のいずれかに対してtrueを返すかどうかを返します。

しかし、文字列の文字コードを取得するにはどうすればよいでしょうか?

第1章で、JavaScriptの文字列は16ビット数のシーケンスとしてエンコードされていることを述べました。これらは*コードユニット*と呼ばれます。Unicode文字コードは当初、このようなユニット内に収まることになっていました(65,000以上の文字が得られます)。それが不可能であることが明らかになると、多くの開発者が、文字ごとに多くのメモリを使用する必要があることに難色を示しました。これらの懸念に対処するために、JavaScript文字列で使用されている形式であるUTF-16が発明されました。これは、最も一般的な文字を単一の16ビットコードユニットを使用して記述しますが、他の文字には2つのユニットのペアを使用します。

UTF-16は、現在では一般的に悪い考えと見なされています。意図的にミスを誘うように設計されているように見えます。コードユニットと文字が同じものだと想定するプログラムを書くのは簡単です。そして、あなたの言語が2ユニットの文字を使用していない場合、それはうまく機能するようです。しかし、誰かがそのようなプログラムをあまり一般的ではない中国語の文字で使用しようとすると、壊れます。幸いなことに、絵文字の登場により、誰もが2ユニットの文字を使用するようになり、そのような問題に対処する負担はより公平に分散されています。

残念ながら、lengthプロパティを使用して長さを取得したり、角括弧を使用して内容にアクセスしたりするなど、JavaScript文字列に対する明白な操作は、コードユニットのみを処理します。

// Two emoji characters, horse and shoe
let horseShoe = "🐴👟";
console.log(horseShoe.length);
// → 4
console.log(horseShoe[0]);
// → (Invalid half-character)
console.log(horseShoe.charCodeAt(0));
// → 55357 (Code of the half-character)
console.log(horseShoe.codePointAt(0));
// → 128052 (Actual code for horse emoji)

JavaScriptのcharCodeAtメソッドは、完全な文字コードではなくコードユニットを返します。後で追加されたcodePointAtメソッドは、完全なUnicode文字を返します。したがって、それを文字列から文字を取得するために使用できます。しかし、codePointAtに渡される引数は、依然としてコードユニットのシーケンスへのインデックスです。したがって、文字列のすべての文字をループ処理するには、文字が1つのコードユニットを占めるか2つのコードユニットを占めるかの問題を依然として処理する必要があります。

前の章で、for/ofループも文字列で使用できることを説明しました。codePointAtと同様に、このタイプのループは、人々がUTF-16の問題を痛感していた時期に導入されました。文字列をループ処理するためにこれを使用すると、コードユニットではなく、実際の文字が取得されます。

let roseDragon = "🌹🐉";
for (let char of roseDragon) {
  console.log(char);
}
// → 🌹
// → 🐉

文字(1つまたは2つのコードユニットの文字列になります)がある場合、codePointAt(0)を使用してそのコードを取得できます。

テキストの認識

characterScript関数と、文字を正しくループ処理する方法があります。次のステップは、各スクリプトに属する文字をカウントすることです。そこで、次のカウント抽象化が役立ちます。

function countBy(items, groupName) {
  let counts = [];
  for (let item of items) {
    let name = groupName(item);
    let known = counts.findIndex(c => c.name == name);
    if (known == -1) {
      counts.push({name, count: 1});
    } else {
      counts[known].count++;
    }
  }
  return counts;
}

console.log(countBy([1, 2, 3, 4, 5], n => n > 2));
// → [{name: false, count: 2}, {name: true, count: 3}]

countBy関数は、コレクション(for/ofでループ処理できるもの)と、指定された要素のグループ名を求める関数を期待します。各グループの名前と、そのグループで見つかった要素の数を示すオブジェクトの配列を返します。

別の配列メソッドであるfindIndexを使用します。このメソッドはindexOfと幾分似ていますが、特定の値を探す代わりに、指定された関数がtrueを返す最初の値を見つけます。indexOfと同様に、そのような要素が見つからない場合は-1を返します。

countByを使用すると、テキストで使用されているスクリプトを示す関数を記述できます。

function textScripts(text) {
  let scripts = countBy(text, char => {
    let script = characterScript(char.codePointAt(0));
    return script ? script.name : "none";
  }).filter(({name}) => name != "none");

  let total = scripts.reduce((n, {count}) => n + count, 0);
  if (total == 0) return "No scripts found";

  return scripts.map(({name, count}) => {
    return `${Math.round(count * 100 / total)}% ${name}`;
  }).join(", ");
}

console.log(textScripts('英国的狗说"woof", 俄罗斯的狗说"тяв"'));
// → 61% Han, 22% Latin, 17% Cyrillic

この関数はまず、characterScriptを使用して名前を割り当て、スクリプトに属さない文字には文字列"none"をフォールバックして、名前で文字をカウントします。filter呼び出しは、それらの文字には関心がないため、結果の配列から"none"のエントリを削除します。

パーセンテージを計算するには、最初にスクリプトに属する文字の総数を求める必要があります。これはreduceで計算できます。そのような文字が見つからない場合、関数は特定の文字列を返します。そうでない場合は、mapを使用してカウントエントリを読み取り可能な文字列に変換し、joinで結合します。

概要

関数の値を他の関数に渡すことができることは、JavaScriptの非常に便利な側面です。これにより、「ギャップ」のある計算をモデル化する関数を記述できます。これらの関数を呼び出すコードは、関数の値を提供することでギャップを埋めることができます。

配列は、多くの便利な高階関数メソッドを提供します。forEachを使用して配列の要素をループ処理できます。filterメソッドは、述語関数をパスした要素のみを含む新しい配列を返します。各要素を関数に通すことで配列を変換するには、mapを使用します。reduceを使用して、配列内のすべての要素を単一の値に結合できます。someメソッドは、指定された述語関数に一致する要素があるかどうかをテストします。そして、findIndexは述語関数に一致する最初の要素の位置を見つけます。

演習

平坦化

concatメソッドと組み合わせてreduceメソッドを使用して、配列の配列を、元の配列のすべての要素を持つ単一の配列に「平坦化」します。

let arrays = [[1, 2, 3], [4, 5], [6]];
// Your code here.
// → [1, 2, 3, 4, 5, 6]

独自のループ

forループ文のようなものを提供する高階関数loopを作成します。値、テスト関数、更新関数、本体関数を取得します。各反復では、最初に現在のループ値に対してテスト関数を実行し、falseを返す場合は停止します。次に、本体関数を呼び出し、現在の値を渡します。最後に、更新関数を呼び出して新しい値を作成し、最初から開始します。

関数を定義する際、通常のループを使用して実際のループ処理を行うことができます。

// Your code here.

loop(3, n => n > 0, n => n - 1, console.log);
// → 3
// → 2
// → 1

すべて

someメソッドと同様に、配列にはeveryメソッドもあります。これは、指定された関数が配列のすべての要素に対してtrueを返す場合にtrueを返します。ある意味、someは配列に対して作用する||演算子のバージョンであり、every&&演算子のようです。

配列と述語関数をパラメーターとして取得する関数としてeveryを実装します。ループを使用するものとsomeメソッドを使用するものの2つのバージョンを作成します。

function every(array, test) {
  // Your code here.
}

console.log(every([1, 3, 5], n => n < 10));
// → true
console.log(every([2, 4, 16], n => n < 10));
// → false
console.log(every([], n => n < 10));
// → true

&&演算子と同様に、everyメソッドは、一致しない要素が1つ見つかった時点で、それ以上の要素の評価を停止できます。そのため、ループベースのバージョンは、述語関数がfalseを返す要素に遭遇するとすぐに、breakまたはreturnを使用してループからジャンプアウトできます。ループがそのような要素を見つけずに最後まで実行された場合、すべての要素が一致したことがわかり、trueを返す必要があります。

someの上にeveryを構築するには、ド・モルガンの法則を適用できます。これは、a && b!(!a || !b)に等しいと述べています。これは、一致しない要素がない場合、配列内のすべての要素が一致するという、配列に一般化できます。

主要な書き込み方向

テキストの文字列における主要な書き込み方向を計算する関数を作成します。各スクリプトオブジェクトには、"ltr"(左から右)、"rtl"(右から左)、"ttb"(上から下)とできるdirectionプロパティがあることを思い出してください。

主要な方向は、スクリプトが関連付けられている文字の大部分の方向です。この章で前に定義されたcharacterScript関数とcountBy関数は、ここで役立つ可能性があります。

function dominantDirection(text) {
  // Your code here.
}

console.log(dominantDirection("Hello!"));
// → ltr
console.log(dominantDirection("Hey, مساء الخير"));
// → rtl

あなたのソリューションは、textScripts例の前半部分と非常によく似ているかもしれません。再び、characterScriptに基づいて基準で文字をカウントし、興味のない(スクリプトのない)文字を参照する結果の一部を除外する必要があります。

最も文字数の多い方向を見つけるには、reduceを使用できます。方法が不明な場合は、この章の前の例を参照してください。その例では、reduceを使用して最も多くの文字を含むスクリプトを見つけました。