高階関数

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

C.A.R. ホア, 1980年 ACM チューリング賞講演
Illustration showing letters and hieroglyphs from different scripts—Latin, Greek, Arabic, ancient Egyptian, and others

大きなプログラムはコストのかかるプログラムであり、構築にかかる時間だけが理由ではありません。サイズはほぼ常に複雑さを伴い、複雑さはプログラマーを混乱させます。混乱したプログラマーは、今度はプログラムに間違い(バグ)を導入します。したがって、大きなプログラムはこれらのバグが隠れるための多くのスペースを提供し、見つけるのを困難にします。

入門の最後の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)));

どちらがバグを含んでいる可能性が高いでしょうか?

sumrangeの定義のサイズを数えると、2番目のプログラムも大きく、最初よりも大きいです。しかし、それでも、より正しい可能性が高いと私は主張します。

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

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

抽象化

プログラミングの文脈では、この種の語彙は通常、抽象化と呼ばれます。抽象化により、興味のない詳細に気を取られることなく、より高い(またはより抽象的な)レベルで問題について話すことができます。

例として、2つのエンドウ豆スープのレシピを比較してください。最初のレシピは次のようになります。

乾燥エンドウ豆を1人あたり1カップ容器に入れます。エンドウ豆が十分に覆われるまで水を加えます。エンドウ豆を少なくとも12時間水に浸したままにします。エンドウ豆を水から取り出し、調理鍋に入れます。1人あたり4カップの水を加えます。鍋に蓋をして、エンドウ豆を2時間煮込みます。1人あたり半分タマネギを取ります。それをナイフで細かく切ります。それをエンドウ豆に加えます。1人あたりセロリの茎を取ります。それをナイフで細かく切ります。それをエンドウ豆に加えます。1人あたりニンジンを取ります。それを細かく切ります。ナイフで!それをエンドウ豆に加えます。さらに10分間調理します。

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

1人あたり:乾燥割エンドウ豆1カップ、水4カップ、みじん切りにしたタマネギ半分、セロリの茎、ニンジン。

エンドウ豆を12時間浸します。2時間煮込みます。野菜を刻んで加えます。さらに10分間調理します。

2番目の方が短く、解釈が容易です。ただし、浸す煮込む刻む、そしておそらく野菜などの、料理関連の単語をさらに理解する必要があります。

プログラミングをするとき、私たちは必要なすべての単語が辞書の中で私たちを待っているとは限りません。したがって、最初のレシピのパターンに陥る可能性があります。つまり、コンピューターが実行する必要のある正確なステップを、それらが表現する高レベルの概念を無視して、1つずつ処理します。

プログラミングにおいて、抽象化のレベルが低すぎることに気づくのは役立つスキルです。

反復の抽象化

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

プログラムが特定の回数何かを行うのは一般的です。次のようにforループを記述できます。

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

「何かをN回行う」ことを関数として抽象化できますか?ええと、console.logN回呼び出す関数を簡単に記述できます。

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

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

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

スクリプトデータセット

高階関数が輝く分野の1つは、データ処理です。データを処理するには、実際のサンプルデータが必要です。この章では、ラテン文字、キリル文字、アラビア文字などの文字体系に関するデータセットを使用します。

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

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

A line of verse in Tamil handwriting. The characters are relatively simple, and neatly separated, yet completely different from Latin.

サンプルデータセットには、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による集計

配列に対してよく行われるもう1つのことは、それらから単一の値を計算することです。数値のコレクションを合計するという、再発する例がその一例です。もう1つの例は、最も多くの文字を持つスクリプトを見つけることです。

このパターンを表す高階演算は、reducefoldと呼ばれることもあります)と呼ばれます。これは、配列から単一の要素を繰り返し取得し、それを現在の値と組み合わせることで値を構築します。数値を合計する場合は、ゼロから開始し、要素ごとにそれを合計に加算します。

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関数は、スクリプトに割り当てられた範囲を合計してサイズを縮小します。リデューサー関数のパラメータリストで分割代入を使用していることに注意してください。次に、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ビットコードユニットを使用して記述しますが、他の文字にはそのようなユニットのペアを使用します。

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.find(c => c.name == name);
    if (!known) {
      counts.push({name, count: 1});
    } else {
      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でループ処理できるもの)と、指定された要素のグループ名を計算する関数を期待します。これは、グループに名前を付け、そのグループで見つかった要素の数を知らせるオブジェクトの配列を返します。

これは、別の配列メソッドであるfindを使用します。これは、配列内の要素を調べ、関数がtrueを返す最初の要素を返します。そのような要素が見つからない場合は、undefinedを返します。

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 メソッドは、任意の要素が指定された述語関数に一致するかどうかをテストし、find は述語に一致する最初の要素を検索します。

演習

平坦化

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

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 を使用して文字数が最も多いスクリプトを検索しました。