「キレイなコード」について考える記事、2つめです。
上記の記事では、Null 安全に関わる ?.
, ??
演算子を取り扱いましたが、その最後で
- 通常の条件分岐(および三項演算子)では処理の流れ・フローが「二股の分岐」 になる
-
?.
,??
を使うと正常系という「幹」と異常系という「枝」として捉え直すことで扱いやすくなる
と述べました。
この「幹と枝」の発想に注目してみようと思います。
Null 合体・オプショナルチェーン
ふたたび Null 合体・オプショナルチェーンを使ったコードと、修正前の三項演算子を使った例を見てみましょう。
// 三項演算子
const name = personObj
? personObj.address
? personObj.address.prefecture
? personObj.address.prefecture
: ""
: ""
: "";
// オプショナルチェーン
const name = personObj?.address?.prefecture ?? ""
真値/偽値が混じっていたものが、nullish さえ扱えば良いようになったのは勿論のことですが、
このコードの正常系のフロー(水色)と異常系のフロー(ピンク色)を可視化すると、次の画像のようになります。これを見ると、
三項演算子を使ったコードはふた股の分岐がいくつもニョキニョキと伸びていて、?
と :
の対応関係を捉えるのも面倒だったものが、
- 一直線な正常系という「幹」
- 一つの既定値にフォールバックする異常系という「枝」
というフローの構造に変わることで、三項演算子のときよりもパッと見で分かりやすくなっていると思います。
ただし、ご覧の通り、フォールバックのしかたが単純になってしまうので、フォールバックの処理の仕方が多様な場合には使いにくいと思います。(あまり心当たりはありませんが...)
しかし、用途が限られている機能を使うと意図の読み取りやすい良いコードが書けると言われているので、むしろ正しく使えば強みだと思います。
分割代入の既定値・デフォルト引数を使う
さらに限られたケースであれば、ロジックをさらに簡単にする方法があります。
それは、 分割代入の既定値 です。
Null 合体・オプショナルチェーンと違って、こちらは null のときにフォールバックされないので注意!
2023/07/21 既定値を変更しました。
// オブジェクト・配列の分割代入
const { name = "" } = personObj
const [ head = 0 ] = numbersArray
オブジェクトの分割代入は仮引数でも使えます。これを使えば、関数の中で見た場合に、異常系の分岐に進む前に、即座に正常系に戻す という流れになっていて、 ??
を使うケースよりもさらにフローを単純化することができます。
const someFn = ({
name = "",
}) => {
デフォルト引数 という機能もあります。(ややこしいので多用はしませんが、オブジェクト形式の最終引数とよく使われます。)
// 第2引数に undefined が渡されたら false にフォールバックする
const someFn = (
param1,
param2 = false,
) => {
オブジェクトの最終引数と分割代入を併用するのはかなり便利で私はよく使います。
// someFn("a", { bubble: false })
// someFn("a") // -> bubble: false でフォールバックされる。
// のように呼び出せる関数
const someFn = (
param1,
options = {}
) => {
const { bubble: false } = options
もっと短く書く方法もあり、場合によってはこちらを使うこともあります。
// 上と同じように呼び出せる
const someFn = (
param1,
{ bubble: false } = {}
) => {
2023/07/21補足 真偽値への変換の煩わしさを避けられる
そんなにこだわることかと思われるかもしれませんが、false, "" のような値を使ってフォールバックすると、型の厳密さと手軽な記述の両立を助けてくれます。
type Options = { bubble?: boolean }
const someFn = (
param1: string,
{ bubble: boolean }: Options
) => {
if (bubble) {
if のあとの条件部分に入れた値は真偽値に強制変換されるので、普通は上のように書いても問題はありません。 しかし、 eslint を使うと、それを禁止することができます。
そのようなルールのもとでは、 Boolean(bubble)
のように書くこともできますが、個人的には isTruthy(bobble)
(ライブラリ Remeda の関数) や bubble ?? false
が明示的でわかりやすいと思います。
型の厳密さを保ったままで考え方を少し変えると 「bubble というオプションは、何も指定していないときは false になる」と考えられますが、これを最も的確に表現できているのが「false
でフォールバックする」方法だと思います。
// 関数の引数リストを見るだけで、
// 「bubble は何も指定しないと false になる」とわかる
const someFn = (
param1,
{ bubble: false } = {}
) => {
if (bubble) {
何もしない関数 (と恒等関数)
実は、プリミティブ型や一般のオブジェクトだけでなく、関数であっても既定値を使ってシンプルにできる場合があります。
手続きとしての関数 の場合は、 直感通りに () => {}
と書けば、何もしない関数が出来ます。 (このような関数を noop と呼ぶことがあるようです。)
// props: { onClick?: (event: MoueEvent) => void }
const { onClick } = props
// before
if (onClick) {
onClick(event)
}
// または
onClick && onClick(event)
// good
onClick?.(event)
// most simple
const { onClick = () => {} } = props
onClick(event)
変換するための関数 の場合は、もっとも無難な変換関数を用意しますが、
特に「文字列→文字列」のような同じ型の変換の場合で、かつ「何も指定しなかった場合は変換せずそのままが欲しい」というケースもあります。
そんなケースでは (s) => s
のように、 入力値をそのまま返す関数 を既定値として使うことで、コードをスマートにできます。 ちなみに、このような関数を 恒等関数、または恒等写像 と呼びます。 id
という名称で標準ライブラリに含んでいる言語もあります。
// options: { format: (rawStr: string) => string }
// before
const { format } = options
format ? format(rawStr) : rawStr
// after
const { format = (s) => s } = options
format(rawStr)
早期リターン
早期リターンについては、すでに有名なので多くを語る必要はないでしょう。
欠点としては、??
によるフォールバックと同様、「フォールバックの枝側で複雑なことをするのに向いていない」くらいですが、そのようなケースを除けば、異常系のロジックを理解しやすくできるでしょう。
ただ、 finally
や、 2023/07/20 現在は stage 3 で、 TypeScript 5.2 に入ることが予定されている using
は、この欠点をある程度補ってくれるかも知れません。
余談 ― impl 関数
余談ですが、 Scala では同じような場面 (早期リターンだけでなく、再帰を使う場合など)で、 impl (implemention, つまり関数の動作を 実装 する関数) という名前の関数を用意することがあります。 個人的にはこのテクニックをよく使います。
JS だと、どうしてもジェネレータ関数が使いたいケースもあるので、そんなときにも使いますね。 もちろん早期リターンとも組み合わせられそう。
// こっちはエクスポートしない
function* impl(someIterable) {
const itr = someIterable[Symbol.iterator]()
let result = itr.next();
while(!result.done) {
// 略
}
}
export const someFn = (someIterable) => {
return Array.from(impl(someIterable));
}
モジュール分割にも使える考え方
話は少しそれますが、「幹」と「枝」に近い考え方をもう一つ、
たくさんの型定義やモジュールが散らかるのを防ぐために、モジュールを「主要なもの」と「それにぶら下がってるもの」として整理し、まとめて配置する、という方法があります。
詳細な説明は省略しますが、下の図は React の簡略的なディレクトリ構造の例です。
src/
├ components/
│ ├ Pagination/
│ ├ Pagination.tsx
+ │ └ getPaginationProps.ts
├ utils/
- │ └ getPaginationProps.ts
ページネーションは、「表示/非表示の切り替え」や、「ページあたりの件数と、総件数から、総ページ数を計算する」のような処理が必要なので、 getPaginationProps
のような関数を別に用意することがあると思います。
そんな getPagintaionProps 関数を「関数だから utils に入れる」という安易な分類法で utils ディレクトリに入れてしまうと、コンポーネント本体 (Pagination.tsx) と離れ離れになってしまいます。
getPaginationProps は、 「Pagination にわたす Props を計算する」ためだけに存在する関数なので、 Pagination に付随する関数 とみなして、 同じディレクトリの中に入れる、というのが適切な解の一つだと思います。
用途の限られたモジュールを、主従関係を気にしつつ密に結合させて、同じディレクトリに入れる
個人的には、教条主義的に「疎結合」を求めるよりも、柔軟にこのような戦略も取ります。
(またディレクトリ構造の話してる...)