Chienomi

JavaScriptのOptional Chainingを解説

プログラミング::beginners

会社で同僚に「関数が定義されていたら呼び出したいのであればfunc?.()でいいじゃん」と言ったら驚かれた。

その人は結構Optional Chainingが好きなのだが、なぜその発想にならなかったのか疑問でしょうがなかった。 が、よくよく考えてみると、Optional Chianingに対する理解の仕方によってはありえなくはないということに気づいた。

ここでは、Rubyの同等機能であるぼっち演算子と対比しつつ解説しよう。

初歩的な説明

Optional Chainingはレシーバがnon-nullである場合にプロパティへのアクセスを行う機能である。

つまり、

foo?.x

というコードはおよそ

if (foo != null) foo.x

というものに近い。ただし、fooへのアクセスは一度しかされないため、fooが副作用のあるgetterだった場合、その副作用は1度しか発生しない。

Rubyの「ぼっち演算子」の場合もほとんど同じだが、レシーバがnon-nullである場合、メソッドを呼び出す。

foo&.x

これは

foo.x unless foo.nil?

に近い。

「言葉が微妙に違うだけ」と思うかもしれないが、実はこの言葉の違いが、この演算子の本質的な違いに直結してくる。

なお、JavaScriptのコードは比較対象はx != nullである。 x !== nullではない。つまり、nullだけでなくundefinedを含む。

Rubyでの挙動

まず:

x&.y

これを事前に何もなしにやるとエラーになる。 xnilであってもカバーされるが、NameErrorは別問題だからだ。

x = nil
x&.y

であれば許される。

では;

x = nil
x&.y.z

これはNoMethodErrorになる。x&.ynilと解釈され、nil.zになってしまうためだ。 つまり、x&.yというの自体が、xnilの場合はnilが返るひとつの式になっている。

そもそもRubyでは.の右辺はメソッド呼び出ししかなく、カッコがなくてもメソッド呼び出しである。 だからx&.yあるいはx&.y()は、「xnilでないのならばyメソッドを呼び出す」になる。

ちょっと特殊なものとして、Rubyでは.().call()の別名なので、

y = Object.new
def y.call
  "CALLED"
end

y&.()

ということは可能。これは主にProcオブジェクトで使われる。

結構困るのが添え字に対してぼっち演算子が効きにくいことだ。 例えば

a = [1, 2, 3]
a&.[0]

SyntaxErrorになる。 ただ、Rubyの添え字アクセスは[]というメソッドなので、

a = [1, 2, 3]
a&.[](0)

とかいうちょっと気持ち悪い書き方はできるし、

a = [1, 2, 3]
a&.[] 0

というもっと気持ち悪い書き方もできる。 添え字への代入も[]=メソッドだから

a = [1, 2, 3]
a&.[]=(3, 5)

とかいう読み解くのが困難な書き方もできる。

a&.[0]みたいな書き方ができないのは、Rubyでは割と痛い点。

JavaScriptでの挙動 ステップ1

JavaScriptですごく重要なこととして、JavaScriptのメソッドはUnbound methodであるということがある。 例えば次のコード

x = {}
x.val = 100
x.foo = function() { console.log(this.val) }
x.foo()
y = {}
y.foo = x.foo
y.foo()

x.fooにおけるthisxを示し、this.val100になる、というのはまぁ分かると思うのだが(これはアロー関数だとそうならない)、y.foo = x.yooにも関わらず、y.foo()undefinedになる。 つまり、foo関数はxに束縛されておらず、レシーバによって挙動が変わる。

この形でこの挙動に当たることはないだろうが、このために

const $i = document.getElementById
$i("FooElement")

みたいにgetElementByIdの別名をつけようとして失敗することはありがち。 getElementByIdはレシーバを必要とするから、レシーバが変わってしまうと機能しないのだ。

余談だが、この場合

const $i = (id) => document.getElementById(id)

とすれば機能する。

このため、JavaScriptにおけるx.yは「レシーバとプロパティ」という構図であり、これを崩すことはできない。 PythonとかLuaに慣れている人なら不思議には感じないかもしれない。

さて、JavaScriptもノーコンテキストで

x?.y

とするとReferenceErrorになる。 同じ問題でありながらRubyがコンパイルエラーなのに対し、JavaScriptがランタイムエラーなのはちょっとおもしろい。

ただ、JavaScriptの場合undefinednull扱いなので、undefinedが取れる状況であればOK。

let x
x?.y

で、ここからなのだが、Rubyではエラーになったさらなるチェインだが、JavaScriptではエラーにならない。

let x
x?.y.z    // undefined

どういうことかというと、ここではわかりやすさのためにnull checkの代わりに論理評価を用いるが、Rubyの場合は

(x && x.y).z

という解釈になるのに対して、JavaScriptだと

x && x.y.z

という解釈になる。

JavaScriptでの挙動 ステップ2

さて、Rubyではそもそもドットの右辺はメソッド呼び出しだが、JavaScriptではプロパティへのアクセスである。 そのため、プロパティに対するアクセスというのが可能だ。

例えば、次のコードはxのプロパティyにアクセスし、それを関数として呼び出し、その戻り値のプロパティzにアクセスする。

x.y().z

添え字アクセスというのもある。 添え字アクセスは結局のところプロパティへのアクセスなので、次のコードは一度もメソッドを呼び出さない。

x.y["abc"].z

Rubyでも条件次第で同じコードが書けるが、Rubyだと2回メソッドが呼び出される。

x.y["abc"].z

JavaScriptはプロパティのアクセスにOptional Chainingが使える。 添え字アクセスでも例外ではない。

x?.["y"].z

そして、「メソッドを呼び出す」という行為に対してもOptional Chainingが使える。

x.y?.()

そして、この挙動はJavaScriptの比較的深いところにある。

まず、関数オブジェクトは「呼び出されたときの自分のレシーバ」を覚えている。だから、

foo = function() { console.log(this) }
x.foo = foo
y.foo = foo

とした場合、実体はひとつのfoo()であるにも関わらず、x.foo()と呼び出すか、y.foo()と呼び出すかで別の関数として振る舞う。 これは、一言で言えば「JavaScriptのthisはふわっとしている」ということになるし、そもそもそれはDOM操作などイベントコールバックで使う上でthisが意図したものとして振る舞うようにするにはunboundにする必要があるという意図的なものだ。

だが、unboundで、なおかつthisという帰属概念があるということは、呼び出されたときのレシーバを知らなければならない。 だから、x.y()とした場合、トークンとしてはx, .y, ()という3つになるのだが、yは次のトークンである()で呼び出されたときに、手前のトークンであるxを知っている。

JavaScriptのOptional Chainingはトークンの間に挟むことができるから、y()の間に入れることができる。 そして、どのトークンに挟んだかに関係なく、左のトークンがnon-nullでなければ右のトークンへと進む。nullであればそこでチェーン全体を打ち切って左のトークンの値を返す。

これは、ドット形式のプロパティアクセスでも、添え字形式でのプロパティアクセスでも、関数呼び出しでも同じだ。 だから

x?.()?.y

とかも書ける。

ただし、関数呼び出しは前述のように、その関数自身がレシーバをコンテキストとして持っているので、その関数のレシーバまで絡んでくる。 感覚的には自然に、使いたいところで使えば動くのに対して、挙動としては案外複雑だ。

これが言語仕様として優れているかというのは宗派次第ではあるのだが、JavaScriptのほうが柔軟で使い心地がいい。 Rubyはシンプルな原則を守っているが、そのために添え字が犠牲になっているのでちょっと残念な感じがある。

おまけ

Rubyは一般的には演算子であるものもメソッドになっているので、かなり気持ち悪いぼっち演算子が書ける。

x&.-(10)          # x - 10
x&.**(15)         # x ** 15
x&.==(20)         # x == 20
x&.===(25)        # x === 25
x&.+@             # +x
x&.|10&.&20       # x | 10 & 20
x&.^&.*&.+&.**&.<<

最後のやつは右辺値のない式になるので式で書けない。

さらに、

x = nil
x == nil

xnilなのでtrueだが、

x = nil
x&.==nil

x&.method自体がxnilであればnilを返すため、結果はnilになる。