Sorbetについて

昨日のAsakusa.rbに行ったらSorbetについて聞かれたので、少し話したんだけど、ちょっときちんと確認しないまま話したらぐだぐだだったので、もう少しまとめておく。

全体的な話として、Sorbetはとても良くできているので、これで良いと思う人はそれで良いと思います。

$ srb --version
Sorbet typechecker 0.4.4306 git 31ef906fe5eb572865f2b5d3ea5805793554c676 built on 2019-06-25 06:45:18 GMT with debug symbols

Rubyと少し合わないところはあるので、その辺を挙げる。

1. サブタイピング

いわゆるDuck typingというやつが、まあ良くない。

sig { params(arg: Eachable).void }
def push_from(arg)
  arg.each do |x|
    @array << x
  end
end

みたいなの。この引数のEachableが特定のクラスなら良いんだけど、each が定義された自作のクラスを渡したりArrayを渡したりするとちょっと難しいことになる。

なるんだけど、workaroundは用意されているので、それで良い人はそれで良いと思う。

# .rbiに欲しいメソッドが定義されたモジュールを定義する
module Eachable
  sig { params(blk: T.proc.params(x: String).void).void }
  def each; end
end

# .rbにモジュールを定義する(このファイルは型検査しない)
# typed: false
module Eachable
  # これはincludeできればいいので空で良い
end

# 別に.rbにクラスなどを定義する

class A
  # このincludeによってAのインスタンスはEachableになった
  include Eachable

  def each(&blk)
    yield "foo"
    yield "bar"
    yield "baz"
  end
end

push_from A.new   # これがokになる

動かしてないんだけど、これでいけるはず。

「既存のクラスについても後からincludeすることでEachableにできる」というのがポイントなんだけど、Arrayでやろうとしたら型が難しかったので諦めた……

2. 型の構文

好みの問題なので、これが良いと思う人も世の中にはいるのかもしれない。

結構強烈なのは

def initialize(x:)
  @x = T.let(x, String)
end

のような「T.letメソッドでインスタンス変数の型を書く」だと思う。

3. Overloading

できないみたい。Sorbetの.rbiには複数のsigを一つのdefに対して付けることができないみたいだ。

Array#map とかはどうなっているのかというと、標準ライブラリは特別扱いになっているようにみ見える。 # typed: __STDLIB_INTERNAL などと書いてある。

で、これだと困るような気がするのは

def fetch_people
  if block_given?
    yield people.get(id)
  else
    people.get(id)
  end
end

みたいな「ブロックがあったらyieldして返り値を返す」パターンで、これ僕は結構書くので、ちょっと苦しいと思う。

4. Runtime

ライブラリに使おうとすると、Rubyコードの実行に言語内DSLのためのランタイムが必要なので、まあそれどうなのという。

ランタイムがあることの利点はあって、ランタイムの型検査ができるようになるし、Typed Structsみたいな便利ライブラリもあるので、なかなか難しいところだと思う。

感想

僕は、

  • 絶対にRuntimeの挙動を変えたくないし、gemにはRuntimeの依存関係を増やしたくない
  • 型の構文が苦しい(何日かすれば慣れるかもしれないけど)
  • Overloadingが全然書けないのはさすがに問題では
  • 実はStructural subtypingにこだわるつもりはないんだけど、今のworkaroundに必要な構文はさすがに苦しいと思う。

くらいの意見があります。

JSONやYAMLのデータ構造をチェックするライブラリStrongJSONのご紹介

JSONYAMLを使うと、かなり複雑なデータが作成できますが、これが意図通りの形式になっているかを確認するのは自明ではありません。XMLにはXML Schemaがありますが、そんな感じのものがJSONにも欲しかったので、作りました。

github.com

こんな感じで使います。

Schema = StrongJSON.new do
  let :phone, object(phone: string)
  let :email, object(email: string)
  let :contact, enum(phone, email)
  let :person, object(name: string, contacts: array(contact))
end

json = Schema.person.coerce(JSON.parse(input, symbolize_names: true))  # symbolize_namesが必要です

最新バージョンは1.1とかで、最近「人間にも読めるエラーメッセージ」を実現しましたので、みなさまご活用ください。例えばテストにある例なんですが、

TypeError at $.items[0].price: expected=numeric, value=[]
 "price" expected to be numeric
  0 expected to be item
   "items" expected to be items
    $ expected to be checkout

Where:
  item = { "name": string, "count": numeric, "price": numeric }
  items = array(item)
  checkout = {
    "items": items,
    "change": optional(number),
    "type": enum(1, symbol),
    "customer": optional(
      {
        "name": string,
        "id": string,
        "birthday": string,
        "gender": enum("man", "woman", "other"),
        "phone": string
      }
    )
  }

とか出せるので、少し頑張れば人間にも読めるんじゃないかなあ。そのままアプリケーションに組み込んで使っている例としては、 sider/goodcheckがあります。そのままアプリケーションに組み込めば良いということで、YAMLファイルとかで設定を書かせるときに、validationできて便利ではないかと思います。

JSON Schema

さて、ところで世の中にはJSON Schemaというものがあることをご存じの人がいるでしょう。JSON Schemaではダメなのかというと、まあ別に良いんですけど、

  • SchemaもJSONになっていて欲しい気持ちが別にない → Ruby DSLでSchemaを書くようにした
  • 「数字」とか「文字列」とかそういう感じがテストしたいのであって、「電話番号」みたいなデータ型はいらない

という感じです。

YAMLでは

RubyYAMLライブラリだと、まあ大体JSONみたいなデータ構造になれるので、それでいけます。JSON以外のデータ構造もできたと思いますが、そういうのはダメです。

どういう名前なんこれ??

RailsのStrong parametersからの借用です。Strong parametersは、クエリパラメータみたいな構造は上手く処理できるのですが、JSONみたいなのを扱おうとすると幸せな感じではないので作りました。

が、実はこのライブラリは5年くらい前から作っていて、当時のStrong parametersと今のstrong parametersの違いとかは知らないので、もしかしたらRailsので良いのかもしれません。

型はつくの?

つきます。JSONは、まあ色々あるんで any にしちゃうのが良いと思いますが、そいつからレコード型のデータ、例えば { name: String, contacts: Array[contact] } に変換すると思ってください。変換できないものはエラーになる。

一方で、この型付けが一番便利かというとそんなことはなくて「頑張れば型がつくと強弁できる」くらいの程度で、まあSteepを直さないといけないですねえ……

Balkan Rubyに行ってきた

balkanruby.com

Genadiが誘ってくれたので、Balkan Rubyに行ってきたのだった。話はいつものやつ。

speakerdeck.com

RubyKaigiでMatzとか id:ku-ma-me とかが話してたんだけど、大体そんな感じになっているはずです。私の言葉になっているけど、内容的には新しいことはありません。

トークの話はこのくらいにして、Balkan Rubyが良かった話をしようと思う。

オーガナイザが手厚い

ホテルを取ってくれるし、空港からホテルも手配してくれているし、ホテルについたらSIMもあるし、ご飯もあるしで、特になにも考えずにトークの内容だけ準備してSofiaに行けば良いので楽だった*1。2日前の午後にSofiaについたんだけど、そのまま既にSofia入りしているスピーカと晩ご飯になっていた。その流れで、だいたい毎食スピーカ同士でご飯を食べていた。

f:id:soutaro:20190516031004j:plain

これはRakiaという酒でブルガリア人はこれを飲ませてくる。強い(40〜50%)ので注意すること。

なんでこんなにスピーカの世話が手厚いんだ、と聞いてみたら「スピーカの数が少ないから」などと言われたので、まあ確かにね、という感じ。

Sofiaが楽しい

キリル文字とか建物とかにロシアとかソビエトみたいな感じがあって、ヨーロッパとも違って楽しい。と言いつつ、私にとって初のヨーロッパの国だったので他のヨーロッパ地域のことは良くわからん。町並みは綺麗で、晴れていると遠くに山が見えて大変に楽しい。公園には人々がいて、小さい子どもとかが遊んでたりするので、平和で良い気持ちになれる。特に夜遅くまで明るいので(緯度のせいか経度のせいかはわからない)、カンファレンスが終わって外に出たときに「まだ4時くらいかな??」と思ったら実は7時過ぎだったりするので危ない。

f:id:soutaro:20190518220010j:plain f:id:soutaro:20190516203049j:plain

Balkan Rubyの参加者は大体ブルガリア人なんだけど、スピーカはヨーロッパ各国からで、それに加えてアメリカ・アジアが数人という顔ぶれだった。こんなにヨーロッパ人が周りにいることはあんまりなかったので、ちょっと不思議な感じだった。

f:id:soutaro:20190519172231j:plain

Balkan Rubyが終わった日曜日は英語で無料の市内ツアー(2時間)があって、そういうのに行ってた人もいる。

f:id:soutaro:20190519183321j:plain

ラーメン屋があったのでうっかり入ってしまったが、会計のタイミングでカード支払いができないことが発覚し、ATMまで現金を下ろしに走った。それ以外は全部カード支払いでいけたから、油断していた。

*1:というと、まるで私がトークの内容を準備してからSofiaに行ったように見えるが、実際はそんなことはなくて、Sofiaでずっと支度をしていた。

Siderで使っているTSLintの設定

今のところこんなんです。

{
  "extends": [
    "tslint:latest",
    "tslint-immutable"
  ],
  "rules": {
    "array-type": false,
    "arrow-parens": false,
    "interface-name": false,
    "interface-over-type-literal": false,
    "max-classes-per-file": false,
    "max-line-length": false,
    "member-access": false,
    "object-literal-key-quotes": [true, "as-needed"],
    "object-literal-shorthand": false,
    "object-literal-sort-keys": false,
    "ordered-imports": false,
    "quotemark": false,
    "semicolon": false,
    "trailing-comma": false,
    "variable-name": [
      true,
      "ban-keywords",
      "check-format",
      "allow-leading-underscore",
      "allow-pascal-case"
    ],
    "whitespace": false,
    "no-angle-bracket-type-assertion": false,
    "no-shadowed-variable": false,
    "no-namespace": false,
    "readonly-keyword": [true, "ignore-class"],
    "no-trailing-whitespace": false,
    "no-implicit-dependencies": [true, ["i18n-js"]],
    "no-submodule-imports": false
  }
}

特に解説はないのですが、

  • スタイル関連は基本的に無効
  • なんか怒られる度に無効にしていく
  • == って書くと怒るやつは欲しい

みたいな雑な設定でやっています。

一つだけ有効にしているプラグインがあって、tslint-immutableです。これは我々がreduxを使っているからで、stateのためのinterfaceのattributeをimmutableにするのを忘れないようにしています。このプラグインはなかなか便利なのですが、一つ問題があって、ある種の型適用に immutable と書きたくないという話があります。

こんなん。

const routes = {
  assignSeat: new ApiRoute<{orgId: number, memberId: number}>(
    "/api/c/gh/orgs/:orgId/members/:memberId/seat_assignments"
  )
}

const url = routes.assignSeat.stringify({ orgId: 123, memberId: 456 })

このApiRouteというのは、APIリクエストとかで使うURL生成のためのクラスで、 orgId に文字列を渡したり null が入ったりすることを防げる優れものですが、ここでいちいち readonly と書きたくないという問題があります。良い感じのオプションがないし、どんなときに readonly と書かなくて良いかしばらく考えたけど判定方法を思いつかないので、もう諦めてしまって誤爆が出ると無視するようにしています。(Siderを使います。)

Rubyでnilとfalseを区別する方法が他にもある話

soutaro.hatenablog.com

他にもいろいろあるという話をTwitterで聞きました。下のツイートから大体見られるんじゃないかな。

TracePointで私のアイディアを騙すとか、引数の評価に入れるとTracePointを回避できるとか、 $@false を代入すると例外が上がるとか、 false..nil にして確実に例外が上がるようにするとか、いろいろな方法があることがわかりました。Rubyの勉強になりますね。

今の時点で決定版かなーと思っているのは

ですかねぇ。(わかりませんが。)

一つだけわかったこととしては、「Ruby 2.3より前では nilfalse が区別できなかった」というのは間違いかも、というところで、一筋縄ではいきませんでしたが2.3以降でやるときと同程度の仮定で nilfalse を区別できていたような気がします。

ちなみに、「 is_nil? を呼び出す方法がない」というのがオチのつもりでした。(誰かに上書きされないことが保証できないので。)

Rubyでnilとfalseを区別する方法

補足があります。 Rubyでnilとfalseを区別する方法 - soutaroブログ

Rubyでは、ついこの間の2.3のリリースまで、 nilfalse を区別する方法がありませんでした。

nilfalse 」とそれ以外を区別することはできます。 if とか unless でも良いですし、 && でも良いです。Rubyの構文の中には、真理値に応じてなんらかの処理をしたりしなかったりするものがありますので、「偽」である「 nil または false 」と、「真」である「それ以外の値」は区別できるのです。が、「偽」同士の nilfalse は区別することができませんでした。

と、進めると、Ruby初心者の皆さんは混乱するかもしれませんね。 nil? があるじゃないかと。 x == nil でいいじゃないかと。しかし、こいつらはメソッドでありユーザーが自由に再定義できるので、一般的な話をするときには、信頼してはいけません。「 nil?nil かどうかをテストできる」と言うためには「ただし nil? は再定義されていないものとする」と前提条件を示す必要があります。

# x.nil? を殺す
def nil.nil?
  false
end

# x == nil を殺す
def nil.==(other)
  false
end

# nil.object_id を殺す
def nil.object_id
  false.__id__
end

# nil.class == NilClass を殺す
def nil.class
  ::FalseClass
end

さて、それではどうするかというと、Safe Navigation Operatorを使います。 &. を使うと、レシーバが nil のときのみメソッドが呼ばれません。つまり、 falsenil を区別することができるようになったのです。さらに、一昨日くらいに気づいたのですが、 nil のときには引数の評価もされません。なんて便利!

こんな感じでどうでしょうか。

def is_nil?(value)
  result = true
  value&.__id__(result = false)
  result
rescue
  result
end

&. を使うという方法には3年くらい前に気づいていたのですが、引数が評価されたりされなかったりすることには気づいていなかったため、メソッド呼び出しがあったかどうかをメソッドの定義に依存する形でしか判定できず、特定のメソッドの定義を前提とするのがちょっとずるいなあって思ってずっと眠らせていたTipsでした。一昨日くらいに気づいたブレイクスルーとして、これは引数の評価だけ見るので、メソッドがあってもなくてもかまわないので、前提が十分に少なくなったと言えると考えています。

他に方法があったり、上の is_nil? を騙す方法を思いついた方は、ぜひ教えてください。

型付き ⊄ 型なし

最近、TypeScriptについて考えることが多い。SideCIでWebフロントエンドの開発に使いはじめたこともあるし、Steepの開発をしていて「TypeScriptだとどうなるんだっけ??」などと言いながら試してみることもある。

TypeScriptは型付きのJavaScriptである。構文はほとんど同じで、使えるライブラリもかなり近い。JavaScriptへの変換はかなり自明で、ランタイムは全く同じ。性能の差はない。Webpackやnpmを初めとするツール群もかなり共通しているし、アプリケーションも似たようなもん。書いている気分には、ほとんど差がない。つまり、TypeScriptとJavaScriptでプログラミングしているときに、なにか違いを感じるとすれば、それは(ほとんど)型付きの言語と型なしの言語の差と考えて良い。

RubyJavaを比較するのとは、全然話が違う。構文も意味も違うし、ランタイムも違うし、使えるライブラリも違う。当然アプリケーションも違う。RubyJavaを比較して、型付き言語と型なし言語の差を感じ取るのは、かなり難しい。


型なしのプログラミング言語に型検査器を入れると、型付きのプログラミング言語になる。逆の方がわかりやすいかな……型付きのプログラミング言語の型検査器を常にacceptするように変更すると、型なしのプログラミング言語ができる。さて、型検査器は、型なしの言語で書かれたプログラムの一部をrejectするものなのだから、型なしのプログラミング言語でプログラムとして受け付けられるものの集合は、型付き言語のそれよりも大きい集合になる。自明だ。

TypeScriptの型検査器をハックして常にacceptするようにすれば、JavaScriptと同じことができる。*1TypeScriptは、JavaScriptプログラムのうちの一部を型エラーが含まれるとしてrejectするものだから、TypeScriptで書けるプログラムの集合は、JavaScriptで書けるプログラムの集合の部分集合になる。TypeScriptプログラムは常にJavaScriptプログラムになるが、JavaScriptプログラムの中にはTypeScriptプログラムでないものがいる。自明だ。

ところがここに「プロダクションにデプロイするプログラム」と注釈をつけると、この部分集合の関係は必ずしも成り立たないことを最近実感している。型検査器を前提にしたときに書けるプログラムの一部は型検査器なしでは怖くて書けないし、型検査器を前提にしたときにできるプログラミングのうちには型検査器なしでは現実的でない行為がある。


代数的データ構造を例として挙げよう。代数的データ構造自体は、型検査器の存在を前提としない。JavaScriptでも書ける。

適当にAjax的なリクエストの結果だと思って見てほしい。

type ErrorResult = {
  id: "ErrorResult",
  code: number
}

type SuccessResult = {
  id: "SuccessResult",
  body: any
}

type HttpResult = ErrorResult | SuccessResult

const errorResult = { id: "ErrorResult", code: 404 }
const successResult = { id: "SuccessResult", body: [1,2,3] }

function processResult(result: HttpResult): string {
  switch (result.id) {
    case "ErrorResult":
      return "Error"
    case "SuccessResult":
      return "Success"
  }
}

別にJavaScriptでも書ける。

const errorResult = { id: "ErrorResult", code: 404 }
const successResult = { id: "SuccessResult", body: [1,2,3] }

function processResult(result) {
  switch (result.id) {
    case "ErrorResult":
      return "Error"
    case "SuccessResult":
      return "Success"
  }
}

確かに書けるんだけど、まあJavaScriptでこんなコードを書くのは、結構肝が据わっていないとできないことだと思う。どう考えても問題があるコードで、

  • codebody を間違えたら困る
  • 将来的に新しい結果の種類を増やしたいときに、全ての switch を忘れずに更新できるかどうかの賭けはしたくない

などがすぐに思いつくはずだ。

一方、このプログラムはTypeScriptでは問題がない。

  • code とか body とかを間違えても型検査が見つけてくれる
  • 新しい型を増やしたときにも switch の分岐が網羅的か、型検査器がチェックしてくれる

プロダクションに自信を持って送り込めるコードになった。つまり、この前提では、型検査器によって書けるコードが増えている。


こんなことを考えるようになったのは、やっぱりTypeScriptを書いているからだと思う。型検査の有無しか違わない二つの言語で、プログラミングの心構えが変わるのは、型検査の有無が原因と考えるのが妥当だろう。Objective Cを書いていたときには、コンパイラのバージョンが上がって機能が増えたことがけっこうあったが、「機能が増えたから書けるプログラムが増えるのは当然」としか思わなかった。大体の新機能は制約を緩めるために導入されるものなので、書けるプログラムが増えるのはやっぱり当然である。

「ある種の検査器を導入すると、原理的には書けるプログラムの集合は小さくなるはずなのに、逆にこれまでは(現実的に)書けなかった種類のプログラムが書けるようになる」という体験は、面白いと思う。

*1:全部any型を付ければ大体なる。