これはAdvent Calendar 2021「タイムリープTypeScript 〜TypeScript始めたてのあの頃に知っておきたかったこと〜」6日目です。
switch文使ってますか?
使い道なくって悩みますよね。
代数的データ型(ADT)とよばれるデータ構造を知ると、場合分けがかっこよく書けます。
switch文はなぜ敬遠されているのか?ADTとは何なのか?
OSSを参考にその疑問に答えた後、
ECMAScriptのproposalの内容にも触れて万人に楽しめる記事を目指しました。
目次
長くなっちゃったので、目次で飛ばしながらよんでください↓
1. switch文の何がよくないのか?
2. 脱switch 2選 〜配列と関数化〜
3. よい使い方とは? 〜ESLintとNotionの例〜
4. 【重要】見極めどころはリテラル型! 〜ADTの実装〜
5. proposalのパターンマッチ
6. Python3.10のパターンマッチ
7. まとめ
8. Next Article
エンジニアがはじめに習うswitch文
switch文ってプログラミング結構初期の方に学びますよね。
以前見た紹介プログラムがかなりやばかったです。
/**
* やばいコード例
* 曜日をインデックスにして返す
*/
function weekIndex(week: string) {
switch (week) {
case "Monday":
console.log(1);
break;
case "Tuesday":
console.log(2);
break;
case "Wednesday":
console.log(3);
break;
case "Thursday":
console.log(4);
break;
case "Friday":
console.log(5);
break;
case "Saturday":
console.log(6);
break;
case "Sunday":
console.log(7);
break;
default:
console.log("Invalid day");
break;
}
}
printWeek("Monday");
// → 1
そのブログラム、ちょっと待って!
このサンプルコードのデメリットを挙げると、
-
if-else文と違いがない
コードの一貫性が悪くなります。エンジニアの好みで場所によってif-elseとswitch文が混在してたら読みにくいですよね。
-
breakつけ忘れ
breakつけ忘れると意図していた挙動でなくなってしまいます。
-
長すぎ
コード行数がO(m*n)で増えていきます。1週間が30日になっても続けるのですか?
switchにはこの他にもgotoによるコードの可読性を下がる可能性があり使い方には注意すべきです。
switchのいらないケース
ここからは、このコードをよくする工夫をいくつか紹介していきます。
そもそも、このようなケースで switch 文は必要でしょうか?
ケース1:配列でおけ
競プロとがでよく出てくるアプローチです。
配列で情報を持っておき、そのインデックスで場合分けする とシンプルに書けます。
以下はArray.prototype.indexOfを利用した例です。
const weekData = [
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday",
];
/**
* 曜日の差分日数を表示
*/
function subWeek(weekA: string, weekB: string): string {
const indexA = weekData.indexOf(weekA); // 配列のutil、[indexOf](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Array/indexOf)を活用
const indexB = weekData.indexOf(weekB); // これを分岐でやっちゃうと、かなりの行数に
if (indexA === -1 || indexB === -1) {
throw new Error("Invalid day");
}
return Math.abs(indexA - indexB);
}
console.log(subWeek("Wednesday", "Monday"));
// → 2
分岐を利用した例に比べ行数が短くなってわかりやすくなったのではないでしょうか?
ケース2:letとbreakを増やさない
TypeScriptではブロック内で定義した値を外部で使いたい場合はletをつかいます。
これはswitchが文であり、スコープがブロックの中に閉じているからです。(スコープとは変数の利用できる範囲のこと)
type FetchResponse = {
status: number;
data: any;
};
declare const response: FetchResponse;
let data; // letで宣言
switch (response.status) {
case 200:
data = response.data; // ここで宣言しても外側で使えない
break;
case 400:
throw new Error("Bad request");
default:
throw new Error("Unknown error");
}
console.log(data);
より安全なコードにしようと思ったら以下のようにすると良いでしょう。
これは代入したい値を返す関数を定義することでlet
やbreak
を使わず実装できます。
// 改善例
type FetchResponse = {
status: number;
data: any;
};
declare const response: FetchResponse;
function handleResponse(response: FetchResponse) {
switch (response.status) {
case 200:
return response.data; // returnする
case 404:
throw new Error("Not found");
default:
throw new Error("Something went wrong");
}
}
const data = handleResponse(response); // constで宣言できる
console.log(data);
TypeScriptから離れますが、式指向言語(Rustや関数型言語など)では上記のような処理が楽になります。
switch式 になってそのまま代入できちゃうからです。
実際に上記の処理をRustで書くと以下のようになります↓
struct Response {
status: u16,
}
fn main() {
let response = Response { status: 200 };
let data: &str = match response.status { // 値として代入できちゃう!
200 => "OK",
404 => panic!("Not Found"),
500 => panic!("Internal Server Error"),
_ => panic!("Unknown Error"),
};
println!("{}", data);
}
よい使い方とは?
いいコードを書く上でもっとも参考になるのがOSSです。
ここからは実際にOSSで使われているswitchでの実装例を見ながら、どういった場面でswitchを利用すべきかを見ていきます。
ESLintでの例
typescript-eslint/typescript-eslint
から。
ESLintはコードを検査してくれるツールです。
ESLintで利用されるAST、ESTreeではnode
と呼ばれる文法毎にtypeが提供されています。
ノードの種類を判定してルールを作成する方法としてswitch文での場合分けが紹介されています。
import {
AST_NODE_TYPES,
TSESTree,
} from '@typescript-eslint/experimental-utils';
export function describeNode(node: TSESTree.Node): string {
switch (node.type) {
case AST_NODE_TYPES.ArrayExpression: // ノードが配列のとき
return `Array containing ${node.elements.map(describeNode).join(', ')}`;
case AST_NODE_TYPES.Literal: // ノードがリテラルのとき
return `Literal value ${node.raw}`;
default:
return '🤷';
}
}
AST_NODE_TYPES.ArrayExpression
(配列)の時はnode.elements
が配列に、
AST_NODE_TYPES.Literal
(値)の時はnode.row
で文字列を取得しています。
switch対象となっているnode.type
はenumで定義された値です。
interface ArrayExpression {
type: AST_NODE_TYPES
}
export enum AST_NODE_TYPES {
ArrayExpression = 'ArrayExpression',
ArrayPattern = 'ArrayPattern',
ArrowFunctionExpression = 'ArrowFunctionExpression',
AssignmentExpression = 'AssignmentExpression',
AssignmentPattern = 'AssignmentPattern',
AwaitExpression = 'AwaitExpression',
BinaryExpression = 'BinaryExpression',
...
}
node
はこれらをユニオン型でまとめた集合の形をしています。
export type Node =
| ArrayExpression
| ArrayPattern
| ArrowFunctionExpression
| AssignmentExpression
| AssignmentPattern
| AwaitExpression
| BinaryExpression
| BlockStatement
| BreakStatement
...
ユニオン型になっている分岐を行なうswitchだと、統一感がめっちゃ上がるようです。
Notionでの例
Notion最近有名ですよね。ブロックという単位で文書を構成します。
NotionAPIを利用したミニアプリを作りたいときにswitch文が役立ちます。
ブロックには、「箇条書き」「見出し」「画像」といったtypeが割り当ててあるので、そのtypeで場合分けするのです。
NotionデータをReactコンポネントにするreact-notion-x
の例を参考にしてみましょう。
// blockの種類ごとに描画を変える
switch (block.type) {
case 'collection_view_page': // ページ関連
case 'page':
// 'collection_view_page'と'page'のときはページを描画する。
case 'header': // 見出し関連
case 'sub_header':
case 'sub_sub_header': {
return (
<h2 className={classNameStr} data-id={id}>
{innerHeader}
</h2>
)
}
case 'divider': // 分けるやつ
return <hr className={cs('notion-hr', blockId)} />
...
ブロックのタイプに応じてレンダリングの処理を分けています。
fallthrough(breakせずに処理をまとめるやつ)をうまく使った実用的な例 として紹介しました。
ここまで出てきたメリットをまとめてみましょう。
メリット1:型がつきます
やっとTypeScriptっぽいことを言えます!
なんとcaseで型ガード(型を決定づけるやつ)が行えます!!
caseブロック内ではその分岐に合わせた型になっているので型安全な開発が容易になります。↓
switchでの型ガードの例 - TypeScript Playground
メリット2:fallthroughで分岐がきれいにかけます
Notionの例で出てきました。
switch (block.type) {
case 'collection_view_page': // ページ関連
// fallthrough
case 'page':
... rendering 'page' & 'collection_view_page'
case 'header': // 見出し関連
// fallthrough
case 'sub_header':
// fallthrough
case 'sub_sub_header': {
// 'header' 'sub_header' 'sub_sub_header'の処理をまとめてかく
const isH1 = block.type === 'header'
const isH2 = block.type === 'sub_header'
const isH3 = block.type === 'sub_sub_header'
...
ifでもできるのでは??
どちらのメリットもifで享受することは可能です、
実際にif-elseで書いたときと比べてみましょう。↓
ifでの型ガードの例 - TypeScript Playground
どちらもきれいにまとまっているのですが、
if内に分岐以外の関心事が入っていることに気がつくでしょう。
if文は場合分けでもあり条件分岐でもあるため、node.type
以外の分岐も加えることができるでしょう。
そうすると、コードの関心がtypeの比較から離れてしまいます。
これは**凝集度を下げてしまう危険**があるため注意が必要です。
このくらいの量なら全然問題ないのですが、昔の自分は汚いif文をよく生成していました。関心の分離は良いコードをかけるようになるきっかけだったので早く学びたかったです。。
そのswitch、本当にプロダクトで必要なコードですか?
「if-elseでいいじゃん〜」なところにswitch使ってませんか?
逆にswitch使うべき場面でif-else使っていませんか?
判断が難しいと感じたTypeScriptユーザーにオススメな判断基準を紹介します。
注意:ここから先は持論を含みます。関数型言語由来のパターンマッチのようにswitchを使うといったものです。
参考程度に利用してください。
見極めどころはリテラル型!(ADTの実装)
これまで紹介してきたOSSの型定義を見てみましょう。
2つに共通点が見えてくると思います。
TypeScript ESLintの実装
// enumで集合を定義
// https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/ast-spec/src/ast-node-types.ts
export enum AST_NODE_TYPES {
ArrayExpression = 'ArrayExpression',
ArrayPattern = 'ArrayPattern',
ArrowFunctionExpression = 'ArrowFunctionExpression',
...
}
// 場合分け対象のベースとなる型
// https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/ast-spec/src/base/BaseNode.ts
export interface BaseNode {
type: AST_NODE_TYPES; // さっきつくったやつ
}
// ベースを元に型をつくる
// https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/ast-spec/src/expression/ArrayExpression/spec.ts
export interface ArrayExpression extends BaseNode {
type: AST_NODE_TYPES.ArrayExpression;
elements: Expression[];
}
// Union型で全体の型を作成
// https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/ast-spec/src/unions/Node.ts#L170
export type Node =
| ArrayExpression
| ArrayPattern
| ArrowFunctionExpression
...
React Notion Xの実装
// リテラル型で集合を定義
// https://github.com/NotionX/react-notion-x/blob/master/packages/notion-types/src/block.ts
export type BlockType =
| 'page'
| 'text'
| 'bulleted_list'
| 'header'
| 'sub_header'
...
// 場合分け対象のベースとなる型
export interface BaseBlock {
id: ID
type: BlockType // さっきつくったやつ
content?: ID[]
...
}
// ベースを元に型をつくる
export interface BaseTextBlock extends BaseBlock {
format?: {
block_color: Color // テキストには色がつけられる
}
}
// Union型で全体の型を作成
export type Block =
| TextBlock
| PageBlock
| BulletedListBlock
...
これらを参考に型定義書くと以下ようになるでしょう。
これが場合分けに便利なタグ付きオブジェクトのユニオン型です。(いわゆるADTというデータ構造)
// 代数的データ型(ADT)の実装例
type Role = "admin" | "member" // 1. リテラル型
// 2. ベースとなるインターフェイスを作成(先程作成したリテラルをいれる)
interface BaseUser {
type: Role,
name: string,
}
// ベースを拡張してそれぞれの型を作成
interface Admin extends BaseUser {
type: "admin",
delete: VoidFunction
}
interface Member extends BaseUser {
type: "member",
}
// 集合の完成
type User = Admin | Member; // 3. ユニオン型
1, 2, 3の手順で作成したデータ構造をADT(代数的データ型, Algebraic data type)と呼ぶらしい。リテラル型じゃなくenumを使うケースもあります。(TypeScriptでは非推奨)
ADTで作成した分岐に対して場合分けを行なうと、このようにきれいなswitch文が書けます。
switch(user.type) {
case "admin":
// userの型は`Admin`に
break;
case "member":
// userの型は`Member`に
break;
}
しっかりした型定義を行なうことでswitch文も使いやすくなるでしょう。
(ほとんどif-elseや参考演算子で事足りると思うが。)
おまけ1:proposalのパターンマッチについて
https://github.com/tc39/proposal-pattern-matching
先程Rustでのパターンマッチに触れたように、
switchを式として扱うパターンマッチをECMAScriptに導入しようとする動きが存在します。
数年前からstage1の段階のようなので、これから変わっていくかもしれません。
// APIのエラーハンドリングの例
match (res) {
// match (matchable) {
when ({ status: 200, body, ...rest }) {
// when (pattern) { … }
// ───────↓────── ───↓───
// LHS RHS (sugar for do-expression)
// ───────────↓──────────
// clause
handleData(body, rest);
}
when ({ status: 301 | 304, destination: url }) {
// ↳ `|` (pipe) is the “or” combinator
// ↳ `url` is an irrefutable match, effectively a new name for `destination`
handleRedirect(url);
}
when({ status: 404 }) { retry(req); }
else { throwSomething(); }
// ↳ cannot coexist with top-level irrefutable match, e.g. `when (foo)`
}
パターンマッチには値の場合分け以外にも、
正規表現や非同期での条件一致機能が提供されるため、従来のswitchにはないさまざまな場合分けを行なうことができそうです。
おまけ2:Python3.10のパターンマッチについて
**今年(2021年)**Pythonにもパターンマッチが追加されました!
もともとはif-elseでのみ場合分けをしていたPythonですが、なぜ追加されたのでしょう。
フォーラムによると、
- プログラム解析器のようにオブジェクト指向言語でもパターンマッチを行なう需要
- 場合分けの関数型的アプローチ
にif文にない便利さがあるからだそうです。
構文としてはオーソドックスなswitchですね。最近大学の授業でPythonでプログラム言語処理を行い、お世話になりました。
"""
構文解析器のパターンマッチの例
文法:
match 式:
case パターン:
処理 ←fallthroughはないので、breakは必要ない!
case パターン:
処理
case _:
処理
"""
exp = BinExp('+', Leaf(1), Leaf(3))
match exp:
case Leaf(v):
print(v)
case BinExp(op, l, r):
print(f"( {l} {op} {r} )")
case _:
raise Exception("Unknown exp:" + str(exp))
さいごに
ここまで見ていただきありがとうございました。
switch文は読みにくく、if-elseの下位互換として敬遠されがちですが、
上手く型設計を行なうことで、パターンマッチのように便利な使い方もできることがわかったのではないでしょうか?
リテラル型のメリットや、型設計とADTの重要性がわかっていただけたら幸いです。
他にも工夫点は多々あるかと思います。ぜひコメントで教えてください。
この記事の執筆にあたって、インターン先の HERP で一緒に働いている @e_ntyo や @Nymphium にたくさんツッコミ(推敲)もらいました。ありがとうございます。
Next Article
- 代数的データ型(ADT)について詳しく↓
https://zenn.dev/eagle/articles/ts-coproduct-introduction
- タグ付きユニオンの応用↓
https://zenn.dev/uhyo/articles/ts-4-6-destructing-unions
- TypeScriptに関数型プログラミングを取り入れる。ライブラリ「ts-pattern」の紹介など↓
https://kentutorialbook.github.io/functional-programming-2022/#n0.8875809361655769
注意
TypeScriptと関数型言語でいうADTは結構違う(ユニオンなど)ので他言語を見るときは注意してください。
記事中では同じ概念(データ構造)を実装するという意味でADTを用いています。
参考記事・文献
https://www.wizforest.com/diary/130220.html
https://note.com/cyberz_cto/n/n26f535d6c575
https://github.com/typescript-eslint/typescript-eslint
https://github.com/NotionX/react-notion-x
https://zenn.dev/eagle/articles/ts-coproduct-introduction
https://zenn.dev/uhyo/articles/ts-4-6-destructing-unions
https://kentutorialbook.github.io/functional-programming-2022/#n0.8875809361655769
https://nymphium.github.io/2020/12/03/%E8%B2%A7%E8%80%85%E3%81%AE-Algebraic-Data-Types-%E3%81%A8(%E6%B5%85%E3%81%84)%E7%B6%B2%E7%BE%85%E6%80%A7%E5%88%A4%E5%AE%9A.html
https://github.com/tc39/proposal-pattern-matching
https://www.python.org/dev/peps/pep-0635