初心に帰るJavaScript関数
JavaScriptの基本に立ち返り「JavaScript」「関数」といったキーワードを軸に、関数の使い方やTipsをいくつか確認します。
環境
$ node -v
v20.11.0
純粋関数
純粋関数とは以下の2つを満たすような関数です。
- 外部の影響を受けない
- 外部に影響を与えない
純粋関数はJavaScriptに関係なく多くの言語で求められる関数だと思います。
コードの可読性を大きく向上させることで不具合の減少やテストのしやすさにも繋がります。ImmutableやCQRSといったキーワードに深く関連しており、純粋関数を書く意識はこの先役立ちます。
「1.」「2.」を満たす純粋関数
plusOne(n)
は純粋関数です。
import test from "node:test";
import assert from "node:assert";
function plusOne(n) {
return n + 1;
}
test("純粋関数", (t) => {
t.test("plusOne", () => {
assert.strictEqual(plusOne(0), 1); // 0の結果は常に1
assert.strictEqual(plusOne(1), 2); // 1の結果は常に2
let n = 2;
assert.strictEqual(plusOne(n), 3);
assert.strictEqual(n, 2); // nの値は変わっていない
});
});
「1. 外部の影響を受けない」に当てはまらない関数
now()
は現在の時刻の影響を受けています。
function now() {
return new Date().getMilliseconds();
}
test("純粋関数", async (t) => {
await t.test("now", async () => {
const time1 = now();
await new Promise((resolve) => setTimeout(resolve, 100));
const time2 = now();
assert.notStrictEqual(time1, time2);
});
});
「2. 外部に影響を与えない」に当てはまらない関数
Webアプリケーションを想像してください。
以下のコードは名前と希望の座席をリクエストとして受け取りバリデーションをします。
座席が行(横一列)すべて空席なら有効というバリデーションです。
(簡潔な悪い例をうまく書けませんでした。)
/**
* @param {{
* sheet:{row:number, col:number},
* name:string,
* }} request
* @param {string[][]} sheetMap
*/
function isValid(request, sheetMap) {
const getSheet = (position) => sheetMap[position.row][position.col];
// リクエストされた行の席を左端から1つずつ確認する
for (let col = 0; col < sheetMap[request.sheet.row].length; col++) {
request.sheet.col = col;
if (getSheet(request.sheet) !== "〇") {
return false;
}
}
return true;
}
test("純粋関数", async (t) => {
await t.test("reserveSeat", () => {
// ユーザからのリクエスト
const request = {
sheet: { row: 0, col: 1 },
name: "Bob",
};
// 座席表
const sheetMap = [
["〇", "〇", "〇"],
["〇", "Alice", "〇"],
["〇", "〇", "Charlie"],
];
// バリデーション
assert.strictEqual(isValid(request, sheetMap), true);
// リクエストが変更されている
assert.notDeepStrictEqual(
request,
{
sheet: { row: 0, col: 1 },
name: "Bob",
},
);
// saveIntoDB(request); をすると意図しないデータが保存される
});
});
リクエストの値がisValid(request, sheetMap)
で変更されている事を確認できます。
意図しない変更を防ぐためには、できるだけ分割代入等を使ってプリミティブ型を引数に指定したり、Immutableなオブジェクトを引数で受けるようにすると良いです。
(この手の不具合は特にDateオブジェクトがImmutableでは無い場合によく起きる印象です。気を付けます。)
クラスから関数へ
JavaScriptではクラスは関数です。
クラスの書き方と関数の書き方で同じような振る舞いができるのかを確認します。
数を数える「カウンター」をクラスの書き方で定義してみます。
初期値が0
の変数count
を唯一の状態(プロパティ)として持ち、increment
でその値を+1することができます。最後にゲッターとしてgetCurrentCount
を用意します。
class Counter {
#count;
constructor() {
this.#count = 0;
}
increment() {
this.#count += 1;
}
getCurrentCount() {
return this.#count;
}
}
これをクラスではなく関数を用いた書き方で定義すると以下のようになります。
関数内に変数count
を置き、返り値のオブジェクトからcount
を参照します。
function createCounter() {
let count = 0;
return {
increment: () => count += 1,
getCurrentCount: () => count,
};
}
同じ振る舞いができているのかテストで確認してみます。問題ないです。
import test from "node:test";
import assert from "node:assert";
test("クラスから関数へ", async (t) => {
await t.test("counter", () => {
const [counterClass, counterFunc] = [new Counter(), createCounter()];
assert.strictEqual(
counterClass.getCurrentCount(),
counterFunc.getCurrentCount(),
);
counterClass.increment();
counterFunc.increment();
assert.strictEqual(
counterClass.getCurrentCount(),
counterFunc.getCurrentCount(),
);
});
});
関数を明示的にnew
して使うこともできます。
function CounterFunc() {
this.count = 0;
return {
increment: () => this.count += 1,
getCurrentCount: () => this.count,
};
}
test("クラスから関数へ", async (t) => {
await t.test("counter2", () => {
const [counterClass, counterFunc] = [new Counter(), new CounterFunc];
assert.strictEqual(
counterClass.getCurrentCount(),
counterFunc.getCurrentCount(),
);
counterClass.increment();
counterFunc.increment();
assert.strictEqual(
counterClass.getCurrentCount(),
counterFunc.getCurrentCount(),
);
assert.strictEqual(
typeof Counter,
typeof CounterFunc,
);
});
});
合成
複数の関数をまとめて1つの関数にします。
1つの1つシンプルで独立した関数を組み合わせることで、複雑な処理を実装することができます。
1つの関数の関心事が1つの事柄に集中できるようになりますね。
function compose(f, g) {
return (x) => f(g(x));
}
prefixとsuffixを追加する関数をまとめる例です。
function addPrefix(c) {
return `^-^: ${c}`;
}
function addSuffix(c) {
return `${c} :^_^`;
}
test("合成", async (t) => {
await t.test("compose", () => {
const addPrefixAndSuffix = compose(addSuffix, addPrefix);
assert.strictEqual(
addPrefixAndSuffix("Hello"),
"^-^: Hello :^_^"
);
});
});
さらに残余引数と組み合わせることでより柔軟に合成をすることができます。
function addPrefix(c) {
return `^-^: ${c}`;
}
function addSuffix(c) {
return `${c} :^_^`;
}
function tabToSpace(s) {
return s.replace(/\t/g, " ");
}
function addCharCount(s) {
return `${s} (${s.length})`;
}
function composeMulti(...funcs) {
return function (x) {
return funcs.reduceRight((value, func) => func(value), x);
};
}
test("合成", async (t) => {
await t.test("composeMulti", () => {
const funcList = [
addSuffix,
addPrefix,
tabToSpace,
addCharCount,
];
const addPrefixAndSuffix = composeMulti(...funcList);
assert.strictEqual(
addPrefixAndSuffix("Hello\tworld"),
'^-^: Hello world (11) :^_^',
);
});
});
カリー化
複数の引数をとる関数を単独の引数ををとる関数に変更できます。
関数の引数を固定し新しい関数を生成することで変更を実現します。
import test from "node:test";
import assert from "node:assert";
function add(x, y) {
return x + y;
}
function curriedAdd(x) {
return function(y) {
return x + y;
};
}
test("カリー化", async (t) => {
await t.test("add", () => {
const add3 = curriedAdd(3); // xを3に固定
assert.strictEqual(add3(4), add(3, 4));
const add5 = curriedAdd(5); // xを5に固定
assert.strictEqual(add5(6), add(5, 6));
});
});
引数の固定をより柔軟にできるようにした汎用的なカリー化をする
function addx3(x, y, z) {
return x + y + z;
}
function curry(fn) {
return function curried(...args) {
if (args.length >= fn.length) {
return fn.apply(this, args);
} else {
return function(...args2) {
return curried.apply(this, args.concat(args2));
};
}
};
}
test("カリー化", async (t) => {
await t.test("curry", () => {
const addAddAdd = curry(addx3);
assert.strictEqual(addAddAdd(1)(2)(3), addx3(1, 2, 3));
assert.strictEqual(addAddAdd(1, 2)(3), addx3(1, 2, 3));
assert.strictEqual(addAddAdd(1)(2, 3), addx3(1, 2, 3));
});
});
タグ付きテンプレート
テンプレートリテラルを関数名の直後に続けて記述をすることで、関数の引数に文字列と変数部分を自動でわけて渡すことができます。タグ付きテンプレート
目にする機会も使う機会もあまりないので、不意に出くわすと戸惑います。
import test from "node:test";
import assert from "node:assert";
function numberToString(text, ...values) {
console.log(text, values);
// [ 'I am ', '', ' and ', ' ', '' ]
// [ 2, 2, 5, 1 ]
const numberString = {
0: "zero",
1: "one",
2: "two",
3: "three",
4: "four",
5: "five",
6: "six",
7: "seven",
8: "eight",
9: "nine",
};
let result = "";
for (let i = 0; i < text.length; i++) {
result += text[i];
if (i < values.length) {
result += numberString[values[i]];
}
}
return result;
}
test("タグ付きテンプレート", () => {
assert.strictEqual(
numberToString`I am ${2}${2} and ${5} ${1}`,
"I am twotwo and five one",
);
});
Discussion