はじめに
Discordの通話の状態をSlackに知らせるBotを最近つくりました。
こんな感じのイメージのもの
このプログラム自体は小さめです。しかし、読み返してみたときに、計算効果について考える上で良い題材な気がしたのでまとめてみます。
これについて考えることで、どうしてTypeScriptを使わなかったのかを考えていきます。
題材のプログラム
通知まわりをまとめた notification.js です。まず、どんな関数があるかを一覧にします。
| 関数 | 引数 | 何をするか |
|---|---|---|
pickCategory |
count, previousCount
|
人数と直前の人数から、文言のカテゴリ(empty/firstJoin/small/large)を決める |
pickTemplate |
category, hasNames
|
カテゴリ内の文言からランダムに1つ選ぶ |
buildNotificationText |
{ count, previousCount, memberNames } |
通知の本文テキストを組み立てる |
buildSlackPayload |
{ text, members } |
Slack 送信用の JSON(Block Kit)を組み立てる |
sendToSlack |
webhookUrl, payload
|
組み立てた payload を実際に Slack へ送信する |
ファイルの冒頭はこうなっています。
./message.jsから送信するメッセージを取るように設定しています。また、大人数かどうかを判定する変数LARGE_GROUP_THRESHOLDでは5人以上いる場合を大人数と判定します。
'use strict';
// 文言の候補を並べた別ファイル
const messages = require('./messages');
// 通知メッセージ/Slack payload を組み立てる関数群。
// Discord/Slackに接続しないので、単体でテストしやすい。
const LARGE_GROUP_THRESHOLD = 5;
各関数のコードは畳んであります。中身を見たいものを開いてください。
pickCategory — 人数からカテゴリを決める
// count/previousCountから、どの文言カテゴリ(messages.jsのキー)を使うか決める
function pickCategory(count, previousCount) {
if (count === 0) return 'empty';
if (previousCount === 0 && count === 1) return 'firstJoin';
if (count >= LARGE_GROUP_THRESHOLD) return 'large';
return 'small';
}
pickTemplate — カテゴリ内の文言を1つ選ぶ
// カテゴリ内の文言からランダムに1つ選ぶ。{names}を含む文言は名前が無い時は除外する
function pickTemplate(category, hasNames) {
const templates = messages[category] || [];
const withoutNames = templates.filter(t => !t.includes('{names}'));
const pool = hasNames && templates.length > 0 ? templates : withoutNames;
const usable = pool.length > 0 ? pool : withoutNames;
if (usable.length === 0) return null;
return usable[Math.floor(Math.random() * usable.length)];
}
buildNotificationText — 通知の本文を組み立てる
// count: 在室人数
// previousCount: 変化前の人数。不明な場合はnull(起動直後など)
// memberNames: 在室メンバーの表示名の配列。取得できない/空なら[](人数のみにフォールバック)
function buildNotificationText({ count, previousCount = null, memberNames = [] }) {
const hasNames = memberNames.length > 0;
const namesText = memberNames.join('、');
const category = pickCategory(count, previousCount);
const template = pickTemplate(category, hasNames);
if (!template) {
return hasNames
? `ボイチャに${count}人居ます: ${namesText}`
: `ボイチャに${count}人居ます`;
}
return template.replace(/\{count\}/g, String(count)).replace(/\{names\}/g, namesText);
}
buildSlackPayload — Slack 送信用の JSON を組み立てる
// text: Slack通知本文(プレーンテキスト。ブロック未対応クライアントやフォールバック表示に使われる)
// members: [{ name, avatarUrl }] 在室メンバー。avatarUrlが無いメンバーはアイコン表示から除外される
function buildSlackPayload({ text, members = [] }) {
const blocks = [{ type: 'section', text: { type: 'mrkdwn', text } }];
// Block Kitのcontext要素は最大10個までなので超過分は表示しない
const avatarElements = members
.filter(m => m.avatarUrl)
.slice(0, 10)
.map(m => ({ type: 'image', image_url: m.avatarUrl, alt_text: m.name || 'メンバー' }));
if (avatarElements.length > 0) {
blocks.push({ type: 'context', elements: avatarElements });
}
return { text, blocks };
}
sendToSlack — 実際に送信する(副作用あり)
// webhookUrlにpayloadをPOSTする(副作用あり)。テストではこの関数自体を呼ばずにモックする。
async function sendToSlack(webhookUrl, payload) {
await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
}
次の章から、このコードを「計算効果」という視点で読み直していきます。
計算効果とは — このプログラムのどこにあるか
まず言葉を4つ整理します。
副作用(side effect) とは、関数が「引数を受け取って値を返す」以外に持つ観測できる影響のことです。ファイルの読み書き、ネットワーク送信、関数の外にある変数の書き換えなどが当てはまります。
参照透過性(referential transparency) とは、ある式を、その計算結果の値でそのまま置き換えても、プログラムの意味が変わらない性質のことです。これが成り立つには2つの条件が要ります。
- 決定的であること(同じ入力なら必ず同じ出力)
- 副作用がないこと
この2つを両方満たす関数を 純粋関数 と呼びます。数学の関数と同じで、入力から出力への対応でしかないものです。
そして、この参照透過性を壊す要因をまとめて、この記事では 計算効果 (Computational Effects) と呼ぶことにします1。副作用は計算効果の一種です。ただし計算効果は副作用より広く、次のように説明されることがあります。
副作用がないことは、参照透過性の必要条件ではあるが、十分条件ではない。
英語版 Wikipedia のsideffect(副作用)ではこのように説明されています。たとえば乱数がそうです。Math.random() は外の世界を何も変えないので、副作用はありません。しかし同じ引数で呼んでも返す値が変わるため、決定的ではありません。2つの条件のうち片方だけを破っている、というわけです。
このプログラムを分類してみる
では、題材のコードを計算効果の目で見ていきます。
| 関数 | 計算効果 | 内訳 |
|---|---|---|
pickCategory |
なし | 引数だけで結果が決まる。純粋 |
buildSlackPayload |
なし | 引数から JSON を組み立てるだけ。純粋 |
pickTemplate |
あり |
Math.random() を使う(決定的でない) |
buildNotificationText |
あり | 内部で pickTemplate を呼ぶので、間接的に乱数を含む |
sendToSlack |
あり |
fetch でネットワーク送信(副作用) |
まず、関数pickCategory を見てみます。
function pickCategory(count, previousCount) {
if (count === 0) return 'empty';
if (previousCount === 0 && count === 1) return 'firstJoin';
if (count >= LARGE_GROUP_THRESHOLD) return 'large';
return 'small';
}
引数の count と previousCount だけを見て、分岐して文字列を返しているだけです。外の世界を見ることも変えることもなく、同じ引数なら必ず同じ結果になります。これは純粋関数です。
一方で関数 sendToSlack は分かりやすく計算効果を持ちます。
async function sendToSlack(webhookUrl, payload) {
await fetch(webhookUrl, { /* ... */ });
}
fetch で Slack にデータを送っています。これは外の世界に影響する、典型的な副作用です。
ここで、関数 pickTemplate をみていきます。
return usable[Math.floor(Math.random() * usable.length)];
この関数は Slack にもファイルにも触れず、外の世界を何も変えません。だから副作用はない、と言えます。しかしMath.random() を使っているため、同じ引数で呼んでも、返ってくる文言は毎回変わります。したがって、決定的ではないです。
型システムは計算効果を保証しない
ここからが本題です。「計算効果を管理したい」という目的に対して、TypeScript の型システムは何をしてくれるのか(あるいは、してくれないのか)を考えます。
型システムが保証するのは「データの形」
TypeScript を使う主な動機は、型による正しさのチェックです。Node.js の公式ドキュメントには、こんな例が載っています。
type User = {
name: string;
age: number;
};
function isAdult(user: User): boolean {
return user.age >= 18;
}
関数isAdult は User 型を受け取って boolean を返す、と型で宣言されています。この宣言に反する使い方をすれば、TypeScript がコンパイル時に教えてくれます。引数や戻り値のデータ型をTypeScriptは保証してくれます。
ここで注目したいのは、宣言するデータ型に計算効果の情報が一切含まれないことです。仮に isAdult の中で Math.random() を呼んでも、Slack に通信しても、型は (user: User) => boolean のまま変わりません。TypeScript は「User を受け取って boolean を返す」ことは保証します。しかし、その過程で何をしたかは追跡しません。
私のコードに当てはめる
前章で分類した関数を、型を付けて並べてみます。
pickCategory: (count, previousCount) => string // 純粋
pickTemplate: (category, hasNames) => string // Math.random を使う
型が書いているのは、どんな値を受け取ってどんな値を返すか、それだけです。片方は純粋で、もう片方は Math.random() を使うのに、型としては同じ形になります。TypeScript の型システムは、この違いを区別しません。
効果を型で追跡する仕組みもある
一方で、計算効果を型で追跡する仕組みは存在します。エフェクトシステム(effect system) と呼ばれるもので、型を拡張して計算効果の有無や種類まで型に持たせます。
代表例が Haskell です。外部と通信する処理の型には IO が現れ、純粋な計算とは型のうえではっきり区別されます。「この関数は純粋か、それとも効果を持つか」を、型を見るだけで判定できるわけです。
TypeScript の型システムは、これを標準では追跡しません。値の形は追いますが、計算効果は追わないのです。
型で守れるものと、守りたかったもの
それでは、最初の問い——「なぜ TypeScript ではなく JavaScript にしたのか」——に戻ります。
TypeScript を導入して得られる主な利点は、値の形の保証でした。しかし今回のコードは、引数も戻り値も単純な文字列や数値ばかりで、型システムで守ってもらってうれしい場面がほとんどないです。
そして、このプログラムでいちばん気を配りたいのは計算効果のほうです。ところが前章で見た通り、計算効果は TypeScript の型システムでは保証できません。一番管理したい部分について、TypeScript は何も助けてくれないのです。
整理すると、こうなります。
- 値の形の保証 → このコードでは恩恵が薄い
- 計算効果の保証 → TypeScript の型システムは追いかけてくれない
型システムが守ってくれるものと、このコードで守りたかったものが噛み合っていない。だから JavaScript のままにしました。
もちろん、規模が大きくなり、扱うデータの形が複雑になれば話は変わります。今回はそういうケースではなかった、というだけです。
計算効果という視点でコードを眺めてみると、型システムで守れるものと守れないものの境界が見えてきます。その境界を、自分の小さなコードで確かめられたのは、良い収穫でした。
参考
- Side effect (computer science) - Wikipedia
- Introduction to TypeScript - Node.js
- モナド (プログラミング) - Wikipedia
-
「計算効果(computational effect)」という語に、厳密で統一された定義があるわけではなく、文脈によって指す範囲が異なります。この記事では「参照透過性を壊す要因の総称」という意味で使うことにします。 ↩
