2021-02-25 [長年日記]
_ turbolinksで実現したかったことって、こういうことだったのか
Turbo: The speed of a single-page web application without having to write any JavaScript.
Stimulus ほどあれこれ考察はしていないけど、Turbo を軽く触ってみて結構よさげという印象を受けた。Google Analytics などの 3rd party のツールとの組み合わせの部分には注意が必要になるが、特に初期実装ではかなりの威力になりそうだ。
※ 今回は Turbo Stream は試していない。あくまで Turbo Drive と Turbo Frame の話。
Stimulusはturbolinksに欠けていたもの
まず あーありがち - Stimulus、悪くない の方を読んでほしいんだけど、Turbo は turbolinks が洗練されて戻ってきたものであることから始めると導入しやすいと思う。
細かく引用はしないが、turbolinks 登場時、最も困ったのは当時全盛と言ってよい jQuery を利用したコードと極めて相性が悪かった点であった。なんなら Rails 標準の JavaScript のライブラリが jQuery なのにどうなってんだこれは、という話である。
どういうことかというと、turbolinks を有効にしてしまうと jQuery を利用した以下のような典型的なコード
jQuery(function() { .. })
が、初回のページロード時には動くが、ページ遷移やブラウザの「戻る」ボタンを押した際には動作しない、という問題があったのだ。というか今もこれはある。jQuery をやめて
document.AddEventListener('DOMContentLoaded', () => { .. })
にしても問題は解決しない。なにせ turblinks も Turbo の Turbo Drive も
ページ遷移自体はせず、DOMの一部とHistoryへの変更であたかも遷移したフリをする
ものだからだ。
turbolinks や Turbo のために監視するイベントを増やしたりする方法もあるかもしれないが、それなら今なら潔く VDOM 系のツールで SPA を組む方が賛同を得られやすいだろう。ただし開発コストは大きく膨らむが。
しかし、Stimulus を使うことでこれは解消される。Stimulus ではこの部分は Application.start() の中に隠蔽されていて、DOM がゼロベースで構築完了したかバックグラウンドで必要な HTML を取得して一部の書き換えを行ったかに関わらず Controller の connect() メソッドは呼ばれる。
つまり、Stimulus を使うことでページの読み込みを監視する処理をそもそも自分で書く必要がなくなり、それによって Turbo を利用しているかいないかに関わらず安定して同じように動作するのだ。
これは非常に助かる。これまで turbolinks を off にするのは割とよくある話だったと思うが、フロントエンドエンジニアを多く確保しなくても Turbo と Stimulus を組み合わせることで、よくある多くのパターンの動的な DOM 構築に対応でき、明らかに体感速度は向上する。
実際にブラウザの Developer Tools で見てみると HTML を取得するたった一つの connection で、JavaScript も Stylesheet も読み込み直すことなくシュッと遷移するのは実に快感だ。
方法は簡単。例えば Webpack や Vite などの bundle ツールを利用していたら import するだけである。それだけで有効になり、ページ遷移は爆速になる。具体的には
import * as Turbo from '@hotwired/turbo'
かな。これだけでよい。使っていなくても大丈夫。CDN から script タグで読み込むだけで動く。
Turbo Frameはiframeを使わないiframeのような何か
これも非常によくあると思うのだが、HTMLの中の一部のコンテンツを遅延ロードしたいという場合、以下のように書くと実現できる。
読み込む側 /importer
<turbo-frame id="export" src="/exporter"> </turbo-frame>
読み込まれる側 /exporter
<turbo-frame id="export"> .. </turbo-frame>
これで importer 側の <turbo-frame> の中身が exporter 側の <turbo-frame> の中身に挿し変わる。
id でコンテンツを紐付けて対応する DOM を入れ替えてしまうという方法である。
同じことを JavaScript で実現する側として正解を挙げると、従来なら
- サーバサイドで JSON を出力
- クライアントサイドで JSON を取得
- クライアントサイドで(サーバサイドとは異なる記法で)DOM を構築
という3ステップを要する。
しかし Turbo Frame を利用すれば全部端折ってサーバサイドで普通に HTML を書いて別な entry point を与えればよい。bot 避けに meta でも書いておけば変なお漏らしもないだろう。これだけで済む。
もちろん、そもそもコンテンツをどのように見せるかは import する側の責務なのでは? という指摘はあると思う。自分もそう思う。でもじゃあそのためにフロントエンドのツールでゼロから書きますか、というと「なんだかなぁ」という気がしないだろうか。DOM の書き換えだってせいぜい最初の一回しか起きないのに VirtualDOM とか必要ですか?という気持ちになるし、今書いているバックエンドのコードから完全に頭を切り変えてフロントエンドの海に潜らないといけない。
Turbo を使うとそこはずっとサーバというか HTML のままで実現できるので、スイッチングコストが発生しない。しかも Turbo Drive でのページ遷移と組み合わせると、遅延読み込みしたかどうかも人間にはよく分からない。
まとめ - Stimulus + Turboはよいぞ
以上のような形で
- サーバ側の routing (あるいは単なる HTML のパス)で
- サーバ側の HTML の生成で
- JavaScript をいちいち書かずに
ユーザーの体験を大きく向上させるのが Turbo の狙いだ。
そしてより細かい部分や 3rd party のツールと組み合わせる際には Turbo 固有の event を利用すればよい。
Hotwire という名前や DHH という名前で大袈裟に構えることはない。なんてことのない、だが確実によく利く勘所を押さえたツールと言えそうだ。
フロントエンドのツールの理解、VirtualDOM や reactive という考え方はそれはそれで必要になるし、例えばタイマーのようなより細粒度、高頻度の更新が必要なものは JavaScript で閉じた方がよいと思うが、ほとんどサーバ側が正解の情報(HTMLなど)を持っているのに JavaScript から遠回りして DOM に反映しているケースも実は多い。
そういう場合に .js.erb のように無理やり JavaScript に情報を渡すのではなく、もうそのまま HTML を渡せばいいじゃんという考え方は一見大胆だし、細かくベンチマークを測るとそこまで速くもないかもしれないけど、そこそこ十分に速くて開発コストが大きく抑えられるのもまた事実。混ぜて使う際にはやや注意が必要かもしれないが、少なくとも初手でコストを掛けずにユーザーの体験をよくするためには大きく貢献してくれそうだ。
※ テストが難しそうというかエラーハンドリング周りの情報が全然ない*1状態なのは確かに気になるので、そこは追試が必要。あと HTML を「直接」「普通に」取得するのでサーバサイドや昨今のフロントエンドを書く際に忘れていたキャッシュ周りに気をつけないとハマる可能性もある。あと地味にレビューが難しい。Stimulus も Turbo も生々しく自分で実装する量がグッと減るので、テストコードも恐らくかなり少なくなる代わりに、HTML 側の記述ミス防止は恐らく目視チェックになりやすいので、何か工夫があった方がよさそう。
*1 普通に console にエラーが出るんだけど、その情報が十分に出揃ってない。
2021-02-23 [長年日記]
_ Stimulus、悪くない
Stimulus: A modest JavaScript framework for the HTML you already have.
2020年末にリリースされた Stimulus 2.0 を試して気づいたことをメモしておく。Stimulus は Rails で有名な Basecamp のメンバーによって開発された生 DOM をベースに、できるだけ template に JS を書かずに動的な DOM の生成、変更を実現するためのライブラリである。
昨今流行りの React, Vue, Angular などの VirtualDOM 系のツールとは根本的に考え方が異なり、今からこれを採用するのはやや申し訳ないというか、正直に言うとよいイメージはなかったし、例えばフロントエンドエンジニアとしてスキルアップしたいぜーという人には合わないと思うが、ただ、どうせサーバサイドの開発が必要で、分業できるほどパワーに余裕のない現場においては VirtualDOM 系よりもかなり高い生産性を維持できると言えそうだ。
少なくとも多能工と多能工が組んでアジリティを確保していこうぜ、という2000年代の空気、Rails誕生前後の空気を再び感じることができる。実際に少ないコードでそこそこのものを高速に開発してオレツエー感を味わえるという意味ではかなりよい選択肢の一つと言えると思う。
StimulusはDOM操作の世界でのよいレール
Stimulusのイメージコード
<div data-controller="counter"> <span data-counter-target="count"></span> <button data-action="click->counter#up">up !</button> </div> class CounterController extends Controller { static targets = ['count'] static values = ['count'] up () { this.countValue++ } countValueChagend () { this.countTarget.innerText = countValue } } const app = Application.start() app.register('counter', CounterController)
簡単に説明すると
- data-controller で指定した DOM 要素と Controller インスタンスが紐づく*1
- Controller の中で変化を検知できる値として values があり、DOM 側からは data-*-value=<identifier> でアクセスできる
- Controller 側から操作可能な DOM 要素として targets がある*2
- Controller のメソッドを呼ぶには data-action を利用する
- event は default のものは省略することができ、この書き方を利用すると event を意識することなくメソッドを呼び出しているように見える
伝統的なDOM操作の世界でのStimulusのメリット
- DOM の影響範囲を data-* で限定し、そこに効力を発揮する JavaScript を Controller と呼び、関係する処理をこの中に閉じ込めることができる
- 要素が複数あった場合は自動的に別々の Controller インスタンスと bind されるのでお互いに干渉しない
- event driven と reactive への習熟という、伝統的なプログラミングスタイルからの飛躍を必要としない
- DOMContentLoaded 周りの繊細さがない
- CDN から読み込んだだけで動作する(ES2015 と Proxy に対応しているブラウザが必要)
- template 側に JS を一切書かずに済むので template 側は副作用込みの行儀悪いものにならない
伝統的なDOM操作コードの問題点とStimulusのアプローチ
残念ながらこの日記にはまとめていないんだけど、上のメリットについては自分も以前からとても頭を悩ませていた。
初歩的な jQuery や VanillaJS で書いたちょっとした装飾や効果、遅延読み込みを実現するコードはほぼ必ずと言っていいほど以下のような問題を抱えている。
- (event が起きてから DOM の走査を行うので)どこまで影響が広がっているのか想像がつかない
- やりたいことに名前がついていないので意図が分からない
これは本当に致命的な問題だと思っていた。*3
この問題を解消するには 対象と操作を明示する名前を付けたコードの中で、明示した範囲内の DOM だけを操作対象にする しかないと考えていて、自分でそういうツールを書いたりもしていたんだけど*4、Stimulus はほぼそのままの回答になっている。
VirtualDOM系ツールを使わないメリット
VirtualDOM系ツールを利用した開発時の課題
VirtualDOM と reactive という考え方は
- もろもろの「操作」系のコードを省くことでおかしな副作用が途中で混ざり込んでしまう可能性を排除できる
- event を stream として functional に処理できる
など、より複雑な処理により安全に対応するよい作法である。
しかし逆に、VirtualDOM 系のツールには以下のような問題がある。
- サーバサイドで組み立てていた HTML の世界に一切歩み寄りがなく、サーバサイドを書いていた手を止めてクライアンドサイドの考え方とツール群に完全に持ち替えないとコードが書けない。完全に分業できる組織ならそれでもよいかもしれないが、特にプロダクトの初期実装時にとても扱いにくく感じる。
- うまく作用する component を作ろうと思うととても小さいものになり、結果として DOM ツリー全体を構築するためには当初の想像の何倍もの数の component を作らなきゃいけなくなりがち
- 数多くの component を書いているうちにすべて component だけで解決しなきゃいけないような気になる → 誤った設計を誘発しがち
簡単に言うと VDOM 系は基本的に高くつく。設計ミスも誘発する。そこまでしても速くなるのは DOM の書き換えだけであり、最初の表示は逆に遅くなったりするので、意外と最適化しないと体感速度は改善しない。
VirtualDOM系ツールに対するStimulusのメリット
Stimulus はこれらの問題のすべてを回避できる。改めてまとめ直すと、以下のようにかなり低コストに動的な DOM を利用した体験を実現できる。
- VDOM 系のツールを動作させるセットアップが一切不要
- サーバサイドとクライアントサイドの両方をまたぐ処理を書く際に起きる分断がほとんどなく、概ねサーバサイドのコードのような書き味で作れる
- Stimulus の Controller は VDOM 系の component より少し大きな粒度になるので数をかなり減らせる
もっと細かく調整したり様々なイベントを扱いたいといったニーズはあると思うが、
ツールの大きな持ち替えで手が止まることなくだいたいのケースに対応できる
という実に Rails っぽい考え方でうまくまとまっていると思う。
もちろん、より複雑なロジックやグローバルなステートなどは Controller の守備範囲外だ。その辺りは当然の前提としたうえで考えると、実にニーズナブルでよい解決方法に見える。
そして、実はここが大きいんだけど
- template 側でのデザインと Controller 側での処理の分業も行いやすい
これ。
VDOM 系のツールを使うと必要以上にデザイナが VDOM 系のツールへの習熟を必要としてしまい、例えばデザイナがツールの習熟度で詰まった際にバックエンドの処理を書いているエンジニアにヘルプがくるとそこでエンジニアの手が止まってしまう。
つまり、
VDOM 系のツールは実はフロントエンドにある程度余裕のあるエンジニアを確保できていないと、デザインの実装に対してもバックエンドの実装に対してもブレーキになり得る
という問題を抱えている。これを解消できる Stimulus というアプローチがとても助かる現場は多いと思う。
Stimulusのデメリット
もちろんデメリットもある。
- テストしたければ結局フロントエンドのツールチェインの整備は必要
- そりゃそうだ。もしかしたら頑張って Selenium 経由で console のエラーを拾う、みたいなこともできなくもないかもしれないけど、それはたぶんかなりつらいと思う。
- Controller のユニットテストは難しい
- 生 DOM と紐づいてこそ意味があるので、独立したテストは意味を成しにくい。純粋な関数部分のテストはできなくもないが、そもそも Controller に複雑な処理を入れるな、でよいと思う*5
- テストしたければ小さな e2e のテストに
総じて テストコードを書くモチベーションが下がる と思う。これは凝ったことをし始める時、量が多くなった時に突然牙を剥く可能性がある。
※ もっとも、小さな e2e のテストというものはセットアップさえ分かってしまえば書くこと自体はそんなに難しくはない。少なくとも Turbo のように DOM 外の resource へのアクセスが絡まないうちは。
あとこれはデメリットじゃないけど、
VDOM + State Management の開発と異なり、生DOM相手なのでデバッグもブラウザの素の Developer Tools を利用する
ことになる。
参考
*1 DOM 側からは register した名前で紐づける
*2 どのように操作するかは決まっていない。上の例のように直接書き換えてもいいし、何らかの JavaScript の template を挟んでそこでコンテンツを生成してもよい
*3 少なくとも jQuery をやめれば解決するという話ではない。jQuery 的な神オブジェクトを採用するすべての SDK がこの問題を抱えていると言ってよい。
*4 しかも偶然にも class ベースで書かれている
*5 恐らくだが Controller へのアクセス方法を DOM の data-* だけに限定できていて「意図しない呼び出しが行われていない」ことを前提にした設計になっているので、そもそもカジュアルにインスタンスを生成して動かせないようになっていると予想される。
2021-01-17 [長年日記]
_ ついにCloud FunctionsにRuby Runtime登場!
今さら感がないと言えば嘘になるけど、
Ruby comes to Cloud Functions | Google Cloud Blog
ついに、Cloud Functions で Ruby が使えるようになりました! いやー長かったな。
個人的には closed beta の段階で申し込みはしてて動かすことだけはできていたのに結局試す時間がないまま public beta になってしまって申し訳ないという気持ち半分、いやでも単純に嬉しいという気持ち半分です。
何はともあれ、これで Ruby の実行環境にカジュアルな FaaS の二つ目*1が加わったわけです! いやめでたい。
というわけでこれまでの他の言語での経験と Ruby / Rack / Ruby版 Functions Framework の特徴をざっと眺めて、実際のプロダクトコードを書く際に気を付けることなどに触れて紹介に代えたいと、思います!
Functions Frameworkというものがあるよ
Ruby で Function を書く際には Functions Framework というものを利用する。
closed beta の頃にはすでに存在してて、こいつが何をするものかというと、
- Cloud Functions, Cloud Run または Knative ベースの仕組みの上で動かせる
- Cloud Run は Knative ベース
- local での開発にも利用できる
という代物で、先行している Node.js や Python 版と同様の役割を果たす。すでにこれらの言語で経験のある人には説明の必要はないんだけど、
特徴的なのは GCP 上のイベント(代表的なものは PubSub)に対応する関数も HTTP 関数も同等に扱える
点。
どういうことかと言うと、Functions Framework の中身を見ると分かるのだがイベントも頑張って Rack::Request に変換してくれているおかげで、対応するすべてのものを Rack アプリで処理できるようになっている。
つまり Ruby で HTTP のサーバサイドを扱う人には最も馴染みの方法で HTTP 関数だけでなく様々なアプリを書けるようになっているということです。素晴らしい。
Functions FrameworkやFunctionsの不便な点
ただ、Functions Framework は Node.js なんかもそうなんだけど、注力しているのはこの request の source の抽象化であって、実際のアプリ内の話については重視していない。そこで以下にいくつか不便な点を挙げていこうと思う。
reloaderの問題
Ruby では多くの場合でフレームワーク側で reloader を用意してくれていて、Sinatra も Rails も開発環境ではいい具合にソースコードの変更に応じて reload してくれるのだが、Functions Framework はそういう部分をケアしてくれるものではない。
ではどうするかと言うと、方法は大きく二つあって、
- Functions Framework を Sinatra など reloader を持つ framework と組み合わせる
- nodemon のような汎用の reloader ( restarter ) を利用する
1 の方がたぶん慣れている人には分かりやすい。具体的にどのように Function を書くかについては公式に記述があって、
File: Writing Functions ― Functions
にあるように、Sinatra などと組み合わせることができる。使い方としては要は Rack アプリに対して Rack アプリが期待する env を渡してやるだけである。公式の Sinatra サンプルは class ベースの modular style アプリだが、class を定義しない classic style でも同様に利用できる。
そのうえで、Sinatra のアプリケーションコード内で sinatra/reloader を require してやれば普通に development 環境では reload が有効になる。
ただ、後述するが実際には典型的な人間向けの Web アプリの実装に向いているこれらのフレームワークを利用する機会はそんなに多くないように思う。その際はフレームワークではなくこのあとに述べる reloader と組み合わせるとよい。
nodemon はもとは node コマンドを置き換えて
$ node app.js
の代わりに
$ nodemon app.js
のように使うとファイルの変更を検知して自動的にサーバを restart してくれるというものだったが、watch する拡張子と実行するコマンドを指定すると Ruby アプリでも問題なく利用できる。具体的には
$ nodemon -e rb --exec "functions-framework-ruby -t <func>"
のようにしてあげるとよい。ここに書いたものは npm run や bundle exec は省略してあるので必要に応じて適宜追加してほしい。
functions-framework-ruby 0.7時点でのNode.js版1.6との違い
functions-framework-nodejs は body-parser を含んでいたりするので、素の express の request, response を扱うこととは異なり、もう少し便利な機能が備わっている。
対して functions-framework-ruby 0.7 時点では今のところ追加の支援はなさそうなので、かなり生々しい Rack オブジェクトを扱うことになる。現実的なアプリを書くには、例えば比較的全部入りのアプリケーションフレームワークと組み合わせる*2か、あるいは
- hanami/router / sinatra-router みたいな router
を使いつつ
のような機能を追加して対応していく感じになるのかな。こっちのアプローチの方が express っぽいけど、Rails も Sinatra もあまり軽くはないので、特にリソースの制約の厳しい Functions 環境ではこれくらいの生々しさや軽量さへのこだわりはあってもいいかも。
cf.
Functions Framework独自の情報をどう引き回すか問題
これは Functions Framework が担う部分と従来の Web アプリケーション Framework の担う部分の違いの話。
Rails などの通常の Web アプリケーションフレームワークは request, response の入出力の方法からアプリケーションロジックに当たる部分、DBMS の読み書きの部分までトータルにサポートするのが一般的*3と言える。
対して Functions Framework の責任の範囲はあくまで HTTP と CloudEvents を抽象化し、Rack 互換のアプリケーションで処理できるようにするための繋ぎの部分であって、それ以外は対象外となっている。
そのうえで、Functions Framework 固有の情報もある。例えば global, set_global というメソッドは Functions Framework 内だけで利用できる global 変数のようなもので、これを利用すると以下のように request を受けるたびに実行するには重たい初期化処理を行ったり、その結果の情報を保持しておくことができる。
FunctionsFramework.on_startup do |function| set_global :config, heavy_initializing_process() end FunctionsFramework.http <name> do |request| config = global :config ... end
ただこれが通用するのは Functions Framework 内の話であって、例えば先ほど挙げたように Sinatra と組み合わせるような場合には Sinatra の中ではこの global にアクセスすることはできない。
そこでどうするかというと、Sinatra アプリに唯一渡せるのは rack env オブジェクトなので、この env の中に global をぶら下げる形になる。先ほどの例で言うと、
FunctionsFramework.http <name> do |request| config = global :config env = request.env env.config = config Sinatra::Application.call env end
のようになる。
Functionを書く際に考えなければいけないことは意外に多くなる
これも別に言語は関係なくて、今まで FaaS を使っていた人にとっては割と当たり前の話。
実際に Cloud Functions を書く際には恐らく従来の人間向けの Web アプリよりも小規模で、かつ逆にインフラにより近いものを直接扱うようになると思う。インフラというのは例えば Cloud Storage, Firestore などのストレージだったり、PubSub や他の Function など、Google Cloud 上のフルマネージドインフラ。Functions を選ぶということは GCE ではないということで、それはつまりアプリケーションの実行環境から直接同じ OS 上で管理できるような伝統的なインフラが存在していない、という意味になる。だからフルマネージドインフラのお世話になるだろうという推測である。
で、そうなると Sinatra や Rails などのアプリケーションフレームワークの機能や構造の分け方などはあまり役に立たない。これらの便利機能は主に
- Cookie や Session
- HTML や response の文字列(JSONなど)の生成
- (DBMSアクセスの抽象化)
は担ってくれるが、それ以外のインフラを扱う部分に対しては基本的には管轄外となってしまう。
例えば Memcached や Redis を利用する際にそのコードをどのように配置すべきかについては Rails には答えはなくて、利用用途がほぼ cache だろうから Rails.cache のバックエンドに置くことを支援してくれるまでに留まっている。実際には cache を扱うコードをどこに置くべきかについてはガイドがないので、作成および更新処理と読み込む処理が分散してコントロールが難しくなってしまうという問題を抱えていたりする。
こうした課題への対策には恐らくいわゆるアプケーションアーキテクチャを考えることになるだろう。いわゆるオニオンアーキテクチャやクリーンアーキテクチャと呼ばれる類のアレだ。
Functions は周知の通り実行時間や利用できるメモリに制限があり、あまり複雑な機能を実装することはできないが、代わりにいわゆる Web MVC のようなシンプルな構成とは異なり、アプリケーションの扱わなければいけないインフラが複雑になりやすい傾向があるので、その分で考えることは増える。
特に異なるインフラを扱うコードが密に結合してしまうと「本番でしかテストできません」みたいなことが容易に起き得るので注意が必要である。
注意深くインフラとロジックを分離し、依存の方向に気をつけて DI で組み立てていくようにする、そういうコードの量が増えるはず。
参考
2020-12-31 [長年日記]
_ 自分が思うRubyの「動的さ」が世間とずれているっぽいので書きとめておくメモ
何かに強く反論したいとかじゃなくて、自分で引用しやすいようにpermalinkを作っておく、くらいの意味。
Rubyは動的型であるという言い方
動的型というのは実行時にならないと型が決まらないという意味なので、それはそう。例えば Ruby には
int a;
のようなコードは存在しないので、
a = 1 a = '1'
も正しく動く。この時 a の型は実行時にしか決まらず、動的である。間違いない。
※ 1 や '1' は実行前に決まっているけど、こっちの話題は今回は取り上げない。
動的型言語に動的型変換の機能があるけど、Rubyは意外と厳格
1 + "2"を計算する
awk
$ awk 'BEGIN { print 1 + "2" }' # -> 3
3 が返ってくる。これは数値の 1 に対して文字列の 2 が数値の 2 に動的に変換されて計算された結果である。
PHP では
$ php -B 'print 1 + "2";' # -> 3
結果は同じ数値としての 3 になる。
JavaScript は
$ node -p '1 + "2"' // -> 12
これは文字列として連結して 12 になっている。
Ruby
$ ruby -e 'p 1 + "2"' Traceback (most recent call last): 1: from -e:1:in `<main>' -e:1:in `+': String can't be coerced into Integer (TypeError)
で実行できない。
これは Integer である 1 の + というメソッド(演算子のように見えるけど)が String である "2" を強制的に Integer にすることができず、Type が合わないという例外で死んでしまっている。
"1" + 2を計算する
awk では
$ awk 'BEGIN { print "1" + 2 }' # -> 3
先ほどと同じ 3 になる。awk では + に文字列の連結としての機能は存在せず、常に数値で解釈される。
PHP では
$ php -B 'print "1" + 2;' # -> 3
先ほどと同じ 3 になる。PHP も + は必ず数値の演算として解釈される。文字列の連結には . という専用の演算子がある。
JavaScript では
$ node -p '"1" + 2' // -> 12
JavaScript では + は数値の演算にも文字列の演算にも利用できるが、混ざると文字列としての演算が優先される設計になっている。
Ruby では
$ ruby -e 'p "1" + 2' Traceback (most recent call last): 1: from -e:1:in `<main>' -e:1:in `+': no implicit conversion of Integer into String (TypeError)
先ほどと同じように思いっきり TypeError となる。若干エラーメッセージは異なるが、String である "1" の + メソッドが 2 という Integer を強制的に String にできず Type が合わないという例外で死ぬ。
もちろん揃っていれば普通に動く。
$ ruby -e 'p "1" + "2"' # -> "12"
実はRubyは型について厳しいのだが、静的に強制できないことが問題視されるようになった
Rubyの挙動を整理し直すと、
- Ruby は文字列の連結も数値の加算も + を利用する
- ただし + は演算子ではなくメソッドであり、メソッドは左辺に当たる値の class に定義されている
- String#+ は Integer を受け取っても文字列の連結を行うことはできず、Integer#+ は String を受け取っても数値として加算することはできない
オブジェクト、メソッド、class で考えると至極自然な動作をしている。
このように Ruby は型については実は意外と厳しい。よく分からない挙動で悩まされることは少ない。少なくとも動的変換で変な踏み抜き方はしないので、そういう意味では「型に関する挙動ではだいぶ安全」である。もちろん明示的に変換を指示する際にヘマをすることはできる。そこはプログラマの自由だ。
ただし、標準の機能では変数への代入、関数の引数への割り当て、戻り値について型を強制することができないという問題はある。(動的に死ぬようなコードを作ることはできる。)
逆に、PHP や JavaScript はエラーが起きずに意図しない動作をすることが問題となり、開発規模の拡大とともに型を指定したいという要求が高まり、TypeScript や PHP 7 以降の型の扱いに結実した。結果、Ruby より静的に解釈しやすくなり「より安全に『開発できる』」と言われるようになった。
「動作としての安全性よりも人間の頑張りに期待する安全性の方がよりよいものとして評価されている」ような状況なので個人的にはあまり納得はいっていないのだけど、周辺のツールを含めて「結果としてどうなのか」だけに注目すると「実際に動作させる前に検知できる」のはバグの発見の早期化という意味でよいことだと思う。
これについての Ruby の 2020 年時点での回答は Ruby 3 なので、Ruby 3.0.0 Released など、関連情報を漁ってもらうとよいと思う。少なくとも TypeScript 的なアプローチで静的に問題を検知することはできるようになっている。
エコシステムとしては TypeScript ほどの充実はまだ実現できていないけれど、システム自体は Ruby 2.6 以降でも利用できるので、今すぐ実践投入することもできる。
Rubyの本当の動的さはそっちじゃない
上の例に挙げた String や Integer では不可能だが、PHP や JavaScript のようなふわふわした挙動の演算子のようなものを持った値も以下のように作ることができる。
class AmbigiousValue def initialize(val) .. end def +(other) .. end end
さらに、上のように普通に class で定義したものについてはインスタンスの状態でメソッドを上書きできるので、あるオブジェクトだけ + の意味が違う、みたいなこともできる。
※ 残念ながら(?) Integer ( Numeric ) や String は継承ツリーに Class を持っておらず、Module に定義されている動的なメソッド定義を実現するメソッド群を持っていないので、Ruby の構文解析を維持したままいきなり 'a' + 1 が 'a1' になるようなコードを作ることはたぶんできない。
もちろんオブジェクト単位で挙動が異なるようなことは「できる」というだけで推奨してる人はたぶんいないけど、この点については自分は以下のようなことだと理解している。
午前4時。あと数時間でユーザが出勤してくる。それまでにシステムをまがりなりにも 動く状態にしておかねば… どうもサードパーティ製のあるライブラリ(ソース無し)の 挙動が怪しいのが問題の原因のようだが、APIにどういうパラメータを与えた時に バグが再現できるか絞りこめていない。もちろんサポートが開くのは明朝、それでは 間に合わないッ。だがッ! ライブラリの内部のみで使われる ある関数に、まれに異常な引数が渡っていることが分かったッ! この内部関数の呼び出しをフックして引数を修正すればとりあえず動かせるッ!---とか、
そういう状況において、「どんなに汚くても、打てる手段がある」というのは 何物にも替え難い救いなのです。というより、そういう予想外の事態に対して エスケープポッドが備えられていない処理系を使うなんて恐くて出来ません。 Lisperは臆病なんです。
Ruby が安全なだけの言語でないのは間違いないと思う。