関数型プログラミングについて議論するとき、多くの場合、議論は基本原理ではなくメカニズムについてです。 関数型プログラミングはモナドやモノイドに関するものではなく、主に一般化された関数を使用してプログラムを記述することです。 この記事では、TypeScriptコードのリファクタリングで機能的思考を使用する方法について説明します。
翻訳者からの注記: リポジトリ内のすべてのコードを便宜上設計しました。
これを行うには、3つの手法を使用します。
- プリミティブの代わりに機能する
- パイプラインを介したデータ変換
- 一般(汎用)関数の割り当て
始めましょう!
したがって、2つのクラスがあります。
従業員
export default class Employee { constructor(public name: string, public salary: number) {} }
部門
export default class Department { constructor(public employees: Employee[]) {} works(employee: Employee): boolean { return this.employees.indexOf(employee) > -1; } }
労働者には名前と給与があり、部門は労働者の通常のリストにすぎません。
averageSalary関数は、リファクタリングするものです。
export default function averageSalary(employees: Employee[], minSalary: number, department?: Department): number { let total = 0; let count = 0; employees.forEach((e) => { if(minSalary <= e.salary && (department === undefined || department.works(e))){ total += e.salary; count += 1; } }); return total === 0 ? 0 : total / count; }
この関数は、従業員のリスト、最低賃金、およびオプションの部門を受け入れます。 設定されている場合、この部門の平均給与を計算し、そうでない場合は、すべての部門の平均を計算します。
describe("average salary", () => { const empls = [ new Employee("Jim", 100), new Employee("John", 200), new Employee("Liz", 120), new Employee("Penny", 30) ]; const sales = new Department([empls[0], empls[1]]); it("calculates the average salary", () => { expect(averageSalary(empls, 50, sales)).to.equal(150); expect(averageSalary(empls, 50)).to.equal(140); }); });
かなり明確な条件にもかかわらず、コードは少し混乱し、拡張が困難であることが判明しました。 条件をもう1つ追加すると、関数のシグネチャ(およびそのためのパブリックインターフェイス)が変更される可能性があり、他のコンストラクトがコードを実際のモンスターに変える可能性があります。
この関数をリファクタリングするために、関数型プログラミングのいくつかの手法を適用してみましょう。
プリミティブの代わりの関数
プリミティブの代わりに関数を使用することは、最初は非論理的なステップのように見えますが、実際にはコードを一般化するための非常に強力な手法です。 私たちの場合、これは、minSalaryおよびdepartmentパラメーターを条件チェック付きの2つの関数に置き換えることを意味します。
ステップ1 (述語はtrueまたはfalseを返す式です)
type Predicate = (e: Employee) => boolean; export default function averageSalary(employees: Employee[], salaryCondition: Predicate, departmentCondition?: Predicate): number { let total = 0; let count = 0; employees.forEach((e) => { if(salaryCondition(e) && (departmentCondition === undefined || departmentCondition(e))){ total += e.salary; count += 1; } }); return total === 0 ? 0 : total / count; } // ... expect(averageSalary(empls, (e) => e.salary > 50, (e) => sales.works(e))).toEqual(150);
給与と部門のサンプリングインターフェイスが統合されています。 この統合により、すべての条件を配列の形で転送できます。
ステップ2
function averageSalary(employees: Employee[], conditions: Predicate[]): number { let total = 0; let count = 0; employees.forEach((e) => { if(conditions.every(c => c(e))){ total += e.salary; count += 1; } }); return (count === 0) ? 0 : total / count; } //... expect(averageSalary(empls, [(e) => e.salary > 50, (e) => sales.works(e)])).toEqual(150);
条件配列は、より読みやすくすることができる条件の構成です。
ステップ3
function and(predicates: Predicate[]): Predicate{ return (e) => predicates.every(p => p(e)); } function averageSalary(employees: Employee[], conditions: Predicate[]): number { let total = 0; let count = 0; employees.forEach((e) => { if(and(conditions)(e)){ total += e.salary; count += 1; } }); return (count == 0) ? 0 : total / count; }
「and」関数は一般的であり、さらに再利用するために別のライブラリに移動する必要があることに注意してください。
中間結果
averageSalary関数の信頼性が向上しました。 関数のインターフェースを変更したり、実装を変更したりすることなく、新しい条件を追加できます。
パイプラインを介したデータ変換
関数型プログラミングのもう1つの優れたプラクティスは、すべてのデータ変更をストリームとしてモデリングすることです。 私たちの場合、これはループからフィルタリングを抽出することを意味します。
ステップ4
function averageSalary(employees: Employee[], conditions: Predicate[]): number { const filtered = employees.filter(and(conditions)); let total = 0 let count = 0 filtered.forEach((e) => { total += e.salary; count += 1; }); return (count == 0) ? 0 : total / count; }
この変更により、カウンターは使用できなくなります。
ステップ5
function averageSalary(employees: Employee[], conditions: Predicate[]): number{ const filtered = employees.filter(and(conditions)); let total = 0 filtered.forEach((e) => { total += e.salary; }); return (filtered.length == 0) ? 0 : total / filtered.length; }
さらに、給与を個別に割り当てる場合、合計には通常のreduceを使用できます。
ステップ6
function averageSalary(employees: Employee[], conditions: Predicate[]): number { const filtered = employees.filter(and(conditions)); const salaries = filtered.map(e => e.salary); const total = salaries.reduce((a,b) => a + b, 0); return (salaries.length == 0) ? 0 : total / salaries.length; }
ジェネリック関数の割り当て
さらに、最後の2行のコードには従業員または部門に関する情報が含まれていないことに注意してください。 実際、これは単に平均を計算するための関数です。 したがって、一般化できます。
ステップ7
function average(nums: number[]): number { const total = nums.reduce((a,b) => a + b, 0); return (nums.length == 0) ? 0 : total / nums.length; } function averageSalary(employees: Employee[], conditions: Predicate[]): number { const filtered = employees.filter(and(conditions)); const salaries = filtered.map(e => e.salary); return average(salaries); }
したがって、抽出された関数は汎用になりました。
給与の計算とフィルタリングのロジックを分割した後、最終ステップに進みます。
ステップ8
function employeeSalaries(employees: Employee[], conditions: Predicate[]): number[] { const filtered = employees.filter(and(conditions)); return filtered.map(e => e.salary); } function averageSalary(employees: Employee[], conditions: Predicate[]): number { return average(employeeSalaries(employees, conditions)); }
最終的なソリューションを比較すると、前のソリューションよりも優れていると言えます。 まず、コードがより一般化されています(関数インターフェイスを壊さずに新しい条件を追加できます)。 次に、不変の状態になり、コードがより読みやすく、より理解しやすくなりました。
停止するとき
関数型プログラミングスタイルは、値のコレクションを取得して新しいコレクションを返す小さな関数を作成することです。 これらの機能は、さまざまな場所で再利用または結合できます。 このスタイルの唯一の欠点は、コードがより抽象的になる可能性がありますが、これらすべての機能の役割を理解することがより難しくなる可能性があることです。
私は、レゴの例えを使いたいと思います。レゴキューブはさまざまな方法で組み合わせることができます。簡単に組み立てることができます。 ただし、すべてのキューブが同じサイズであるとは限りません。 したがって、この記事で説明する手法を使用してリファクタリングする場合、たとえばArray<T>
取得するがArray<U>
返す関数を作成しようとしないでください。 もちろん、まれにデータが混在する場合もありますが、このアプローチではコードの論理チェーンの理解が非常に複雑になります。
まとめると
この記事では、TypeScriptコードをリファクタリングしながら機能的思考を適用する方法を示しました。 ルールに従って、単純な変換関数を使用してこれを行いました。
- プリミティブの代わりに機能する
- パイプラインを介したデータ変換
- 汎用機能の強調表示
何を読む
Reginald Braithwaiteによる「JavaScript Allonge」
Michael Fogusによる「関数型JavaScript」