Kernel.#systemの型はむずかしい
この記事は ruby-signature advent calendar 23日目の記事です。 今日が祝日じゃないと今月に入ってから知りました。
私はruby-signatureにKernel.#system
メソッドの型を修正するPRを出しました。
https://github.com/ruby/ruby-signature/pull/76
systemメソッドの型は思ったより難しかったので、それについて紹介しようと思います。
今までの型定義
まずは、Kernel.#system
メソッドの型定義を見てみましょう。
このPR以前は次のような型定義でした。
def system: (*String args) -> (NilClass | FalseClass | TrueClass)
単純ですね。system('ls' '-al')
のように任意個の文字列を受け取るメソッドとして定義されています。
これは多くのケースをカバーする型定義だと思いますが、少し考えると間違っているのがわかると思います。
例えばこの型定義だとsystem({ 'RUBYOPT' => '-w' }, 'ruby', 'foo.rb')
のような環境変数を指定するメソッド呼び出しは考慮されていません。
また、system()
のような引数が1つもないケースで、通るべきではない型検査が通ってしまいます。
修正された型定義
上記のような問題を修正したより正しい型定義は次のようになりました。
def system: ( # env: on, redirect: on Hash[String, String] env, ([String, String] | String) cmd, *String args, spawn_redirect_hash, ?unsetenv_others: (TrueClass | FalseClass), ?pgroup: (TrueClass | Integer | NilClass), ?new_pgroup: (TrueClass | FalseClass), ?umask: Integer, ?close_others: (TrueClass | FalseClass), ?chdir: String, # See Process.setrlimit ?rlimit_as: (Integer | [Integer, Integer]), ?rlimit_core: (Integer | [Integer, Integer]), ?rlimit_cpu: (Integer | [Integer, Integer]), ?rlimit_data: (Integer | [Integer, Integer]), ?rlimit_fsize: (Integer | [Integer, Integer]), ?rlimit_memlock: (Integer | [Integer, Integer]), ?rlimit_msgqueue: (Integer | [Integer, Integer]), ?rlimit_nice: (Integer | [Integer, Integer]), ?rlimit_nofile: (Integer | [Integer, Integer]), ?rlimit_nproc: (Integer | [Integer, Integer]), ?rlimit_rss: (Integer | [Integer, Integer]), ?rlimit_rtprio: (Integer | [Integer, Integer]), ?rlimit_rttime: (Integer | [Integer, Integer]), ?rlimit_sbsize: (Integer | [Integer, Integer]), ?rlimit_sigpending: (Integer | [Integer, Integer]), ?rlimit_stack: (Integer | [Integer, Integer]), # exception is available only for system ?exception: (TrueClass | FalseClass), ) -> (NilClass | FalseClass | TrueClass) | ( # env: on, redirect: off Hash[String, String] env, ([String, String] | String) cmd, *String args, ?unsetenv_others: (TrueClass | FalseClass), ?pgroup: (TrueClass | Integer | NilClass), ?new_pgroup: (TrueClass | FalseClass), ?umask: Integer, ?close_others: (TrueClass | FalseClass), ?chdir: String, # See Process.setrlimit ?rlimit_as: (Integer | [Integer, Integer]), ?rlimit_core: (Integer | [Integer, Integer]), ?rlimit_cpu: (Integer | [Integer, Integer]), ?rlimit_data: (Integer | [Integer, Integer]), ?rlimit_fsize: (Integer | [Integer, Integer]), ?rlimit_memlock: (Integer | [Integer, Integer]), ?rlimit_msgqueue: (Integer | [Integer, Integer]), ?rlimit_nice: (Integer | [Integer, Integer]), ?rlimit_nofile: (Integer | [Integer, Integer]), ?rlimit_nproc: (Integer | [Integer, Integer]), ?rlimit_rss: (Integer | [Integer, Integer]), ?rlimit_rtprio: (Integer | [Integer, Integer]), ?rlimit_rttime: (Integer | [Integer, Integer]), ?rlimit_sbsize: (Integer | [Integer, Integer]), ?rlimit_sigpending: (Integer | [Integer, Integer]), ?rlimit_stack: (Integer | [Integer, Integer]), # exception is available only for system ?exception: (TrueClass | FalseClass), ) -> (NilClass | FalseClass | TrueClass) | ( # env: off, redirect: on ([String, String] | String) cmd, *String args, spawn_redirect_hash, ?unsetenv_others: (TrueClass | FalseClass), ?pgroup: (TrueClass | Integer | NilClass), ?new_pgroup: (TrueClass | FalseClass), ?umask: Integer, ?close_others: (TrueClass | FalseClass), ?chdir: String, # See Process.setrlimit ?rlimit_as: (Integer | [Integer, Integer]), ?rlimit_core: (Integer | [Integer, Integer]), ?rlimit_cpu: (Integer | [Integer, Integer]), ?rlimit_data: (Integer | [Integer, Integer]), ?rlimit_fsize: (Integer | [Integer, Integer]), ?rlimit_memlock: (Integer | [Integer, Integer]), ?rlimit_msgqueue: (Integer | [Integer, Integer]), ?rlimit_nice: (Integer | [Integer, Integer]), ?rlimit_nofile: (Integer | [Integer, Integer]), ?rlimit_nproc: (Integer | [Integer, Integer]), ?rlimit_rss: (Integer | [Integer, Integer]), ?rlimit_rtprio: (Integer | [Integer, Integer]), ?rlimit_rttime: (Integer | [Integer, Integer]), ?rlimit_sbsize: (Integer | [Integer, Integer]), ?rlimit_sigpending: (Integer | [Integer, Integer]), ?rlimit_stack: (Integer | [Integer, Integer]), # exception is available only for system ?exception: (TrueClass | FalseClass), ) -> (NilClass | FalseClass | TrueClass) | ( # env: off, redirect: off ([String, String] | String) cmd, *String args, ?unsetenv_others: (TrueClass | FalseClass), ?pgroup: (TrueClass | Integer | NilClass), ?new_pgroup: (TrueClass | FalseClass), ?umask: Integer, ?close_others: (TrueClass | FalseClass), ?chdir: String, # See Process.setrlimit ?rlimit_as: (Integer | [Integer, Integer]), ?rlimit_core: (Integer | [Integer, Integer]), ?rlimit_cpu: (Integer | [Integer, Integer]), ?rlimit_data: (Integer | [Integer, Integer]), ?rlimit_fsize: (Integer | [Integer, Integer]), ?rlimit_memlock: (Integer | [Integer, Integer]), ?rlimit_msgqueue: (Integer | [Integer, Integer]), ?rlimit_nice: (Integer | [Integer, Integer]), ?rlimit_nofile: (Integer | [Integer, Integer]), ?rlimit_nproc: (Integer | [Integer, Integer]), ?rlimit_rss: (Integer | [Integer, Integer]), ?rlimit_rtprio: (Integer | [Integer, Integer]), ?rlimit_rttime: (Integer | [Integer, Integer]), ?rlimit_sbsize: (Integer | [Integer, Integer]), ?rlimit_sigpending: (Integer | [Integer, Integer]), ?rlimit_stack: (Integer | [Integer, Integer]), # exception is available only for system ?exception: (TrueClass | FalseClass), ) -> (NilClass | FalseClass | TrueClass) type Kernel::spawn_redirect_hash = Hash[(spawn_redirect_fd | Array[spawn_redirect_fd]), spawn_redirect_target] type Kernel::spawn_redirect_fd = :in | :out | :err | Integer | IO type Kernel::spawn_redirect_target = spawn_redirect_fd | String | [String] | [String, String] | [String, String, Integer] | [:child, spawn_redirect_fd] | :close
うーん、長いですね。100行以上あります。
こんなに長くなってしまったのはKernel.#system
が普通のRubyのメソッド定義では定義できないような引数の解釈をしていて、それが長いunion typeを生んでしまっているためです。
何がうまく解釈できないのか1つ1つ見てみましょう。
環境変数
冒頭でも説明しましたが、RubyのKernel.#system
は第一引数に環境変数を受け取れます。
# 自身と同じ環境変数で子プロセスの ruby コマンドを実行する system('ruby', 'foo.rb') # RUBYOPT環境変数に -w をセットして ruby コマンドを実行する system({ 'RUBYOPT' => '-w' }, 'ruby', 'foo.rb')
つまり、第一引数が省略可能になっているわけですね。 Rubyのメソッド定義ではそのようなメソッドは定義できません。
# systemメソッドの定義はこんな感じ…だけどこれはSyntaxError def system(env = {}, cmd, *args) end
Rubyでは「デフォルト式のある引数(env
)」の後に「デフォルト式のない引数(cmd
)」を書くと、その後に「*
を伴う引数(*args
)」を書くことはできません。
そしてruby-signatureではRubyのメソッド定義と同じような構文で型定義をするので、当然RubyでSyntaxErrorになるような型定義は書けません。 そのため2つの型定義をunion typeを使って分けて書く必要があります。
# これはSyntaxError def system: (?Hash[String, String] env, String cmd, *String args) -> (NilClass | TrueClass | FalseClass) # こう書く def system: # コマンドと引数を受け取る定義 (String cmd, *String args) -> (NilClass | TrueClass | FalseClass) # 環境変数を第一引数で受け取って、その後にコマンドと引数を受け取る定義 | (Hash[String, String] env, String cmd, *String args) -> (NilClass | TrueClass | FalseClass)
うーん、難しそうな気配がしてきましたね。
リダイレクト
RubyのKernel.#system
は、コマンド列の後にリダイレクト先を指定することもできます。
# ファイルディスクリプタ2(標準エラー出力)をファイルディスクリプタ1(標準出力)に # リダイレクトしてコマンドを実行する system('ls', 2 => 1) # 上に同じ system('ls', :err => :out) # 標準出力と標準エラー出力を /tmp/foo にリダイレクトする system('ls', [1, 2] => '/tmp/foo') # 標準出力と標準エラー出力を /tmp/foo にリダイレクトする # /tmp/foo がなければパーミッションを0600でファイルを作る # /tmp/foo があれば追記を行う system('ls', [1, 2] => ['/tmp/foo', 'a+', 0600])
……便利ですね!
なお、ここで説明したのはリダイレクトの機能の一部です。
リダイレクトに指定できる要素については、記事の最後に参考文献として上げているKernel.#spawn
メソッドのドキュメントを参考にしてください。
そしてこの引数も普通のRubyのメソッド定義では定義できません。
# SyntaxError def system(cmd, *args, redirect = nil) end
なぜならば「*
を伴う引数(*args
)」の後に「デフォルト式のある引数(redirect
)」を書けないためです。
そのため、この場合も2つの型定義をunion typeを使って分けて書く必要があります。
# これはSyntaxError def system: (String cmd, *String args, ?spawn_redirect_hash) -> (NilClass | TrueClass | FalseClass) # こう書く def system: # コマンドと引数を受け取る定義 (String cmd, *String args) -> (NilClass | TrueClass | FalseClass) # コマンドと引数を受け取って、その後にリダイレクト関連のHashを受け取る定義 | (String cmd, *String args, spawn_redirect_hash) -> (NilClass | TrueClass | FalseClass)
……ところで、勘のいい読者の方は、この型定義に環境変数の定義が含まれていないことに気がついたかもしれません。 正しくは環境変数の型定義も含める必要がありますね。それも含めると、union typeの組み合わせの数は2×2で4つになります。
def system: # envがなくて、redirectがない (String cmd, *String args) -> (NilClass | TrueClass | FalseClass) # envがなくて、redirectがある | (String cmd, *String args, spawn_redirect_hash) -> (NilClass | TrueClass | FalseClass) # envがあって、redirectがない | (Hash[String, String] env, String cmd, *String args) -> (NilClass | TrueClass | FalseClass) # envがあって、redirectがある | (Hash[String, String] env, String cmd, *String args, spawn_redirect_hash) -> (NilClass | TrueClass | FalseClass)
だんだんと型定義が伸びてきましたね。 これにいくつかの引数を足すと完成です。 union typeの組み合わせはもうこれ以上増えない(はず)なので安心してください。
ちなみにおもむろに出てきたspawn_redirect_hash
型は次のような型のエイリアスになっています。
これはこれでちょっとした難しさがありますね。
type Kernel::spawn_redirect_hash = Hash[(spawn_redirect_fd | Array[spawn_redirect_fd]), spawn_redirect_target] type Kernel::spawn_redirect_fd = :in | :out | :err | Integer | IO type Kernel::spawn_redirect_target = spawn_redirect_fd | String | [String] | [String, String] | [String, String, Integer] | [:child, spawn_redirect_fd] | :close
その他の要素
さて、あとは細かいものだけなのでさくっと説明します。
コマンド名に配列を受け取れる
まず、system
はコマンド名として配列を受け取れます。
# ls -l を実行 system('ls', '-l') # ls -l を実行、ただしコマンド名は foobar になる system(['ls', 'foobar'], '-l')
この挙動は実行するコマンドをsleep
になどに変えてps
コマンドから実行されているコマンドを見るとわかりやすいです。
これは型定義を書くと次のようになります。
# コマンド名として2要素のタプルもしくは文字列を受け取る定義 def system: (([String, String] | String) cmd, *String args) -> (NilClass | TrueClass | FalseClass)
実装前はこれも([String, String] cmd, *String args) -> res | (String cmd, *String args) -> res
のように全体を包むunion typeになってしまって更にunion typeが2倍になるのではと怯えていました。
ですが例のようにcmd
の型をunion typeにすれば良いことに気がついて軽傷で済みました。
沢山のキーワード引数を受け取れる
system
は実にたくさんのキーワード引数を受け取ります。
たとえばディレクトリを移動した上でコマンドを実行するchdir
キーワード引数などは便利ですね。
# /tmp ディレクトリで ls -l コマンドを実行する system('ls', '-l', chdir: '/tmp')
system
ではこのような便利なキーワード引数を20種類以上受け取ることができます。
これは別に複雑ではないのですが、なにしろ数が多いので型定義の縦の長さを増す要因になっています。
具体的にどのようなキーワード引数を使えるかは型定義やKernel.#spawn
のドキュメントを参考にしてください。
exception キーワード引数を受け取れる
また、system
メソッドはexception
キーワード引数を受け取れます。
exception: true
を指定すると、実行したコマンドが非0のステータスで終了した時に例外を投げます。
# ls コマンドを実行する。失敗したらエラーを投げる。 system('ls', exception: true)
注意すべき点として、他のキーワード引数とは違いこれはKernel.#spawn
などの他の外部コマンドを実行するメソッドにはない引数です。
つまりKernel.#spawn
などのメソッド定義を書く場合、Kernel.#system
とは少しだけ違う定義を書く必要があります。
型定義の生成
ところで、こんなに長い型定義を手で書くのは大変ですね。 そのため、簡単なスクリプトを書いて型定義を生成するようにしました。
def indent(str, level) str.lines.map{|line| "#{' ' * (level * 2)}#{line}"}.join end def flag(name, val) "#{name}: #{val ? 'on' : 'off'}" end def gen_part(env:, redirect:, method:) kwargs = <<~SIG ?unsetenv_others: (TrueClass | FalseClass), ?pgroup: (TrueClass | Integer | NilClass), ?new_pgroup: (TrueClass | FalseClass), ?umask: Integer, ?close_others: (TrueClass | FalseClass), ?chdir: String, # See Process.setrlimit ?rlimit_as: (Integer | [Integer, Integer]), ?rlimit_core: (Integer | [Integer, Integer]), ?rlimit_cpu: (Integer | [Integer, Integer]), ?rlimit_data: (Integer | [Integer, Integer]), ?rlimit_fsize: (Integer | [Integer, Integer]), ?rlimit_memlock: (Integer | [Integer, Integer]), ?rlimit_msgqueue: (Integer | [Integer, Integer]), ?rlimit_nice: (Integer | [Integer, Integer]), ?rlimit_nofile: (Integer | [Integer, Integer]), ?rlimit_nproc: (Integer | [Integer, Integer]), ?rlimit_rss: (Integer | [Integer, Integer]), ?rlimit_rtprio: (Integer | [Integer, Integer]), ?rlimit_rttime: (Integer | [Integer, Integer]), ?rlimit_sbsize: (Integer | [Integer, Integer]), ?rlimit_sigpending: (Integer | [Integer, Integer]), ?rlimit_stack: (Integer | [Integer, Integer]), SIG kwargs_for_sytem = <<~SIG # exception is available only for system ?exception: (TrueClass | FalseClass), SIG res = +'' res << indent("# #{flag :env, env}, #{flag :redirect, redirect}\n", 2) res << indent("Hash[String, String] env,\n", 2) if env res << indent("([String, String] | String) cmd,\n", 2) res << indent("*String args,\n", 2) res << indent("spawn_redirect_hash,\n", 2) if redirect res << indent(kwargs, 2) res << indent(kwargs_for_sytem, 2) if method == 'system' if method == 'exec' res << indent(") -> bot\n", 1) else res << indent(") -> (NilClass | FalseClass | TrueClass)\n", 1) end res end def gen(method_name) res = [true, false].repeated_permutation(2).map do |env, redirect| gen_part(env: env, redirect: redirect, method: method_name) end.join("| (\n") header = indent("def #{method_name}: (\n", 1) puts header + indent(res, (header.chomp.size / 2) - 1) end gen(ARGV.first || raise)
今回は環境変数とリダイレクトについてそれぞれありなしの4パターンが欲しかったので、[true, false].repeated_permutation(2)
のようにして4つのパターンを生成しています。
これならさらにunion typeの組み合わせが増えても安心ですね。
まとめ
Kernel.#system
メソッドに型定義を足した話から、Kernel.#system
メソッドの様々な機能の紹介をしました。
ちなみに、Rubyではほかにも外部コマンドを呼び出すメソッドはいくつかあり、そのそれぞれが似たようで違う引数を受け取ります。
たとえばopen3
ライブラリの型定義を書くのは歯ごたえがあって楽しそうですね。
ここまで読んでくださったみなさんもruby-signatureで型をつけてみてはいかがでしょうか? Rubyの機能に思わぬ発見があるかもしれません。
参考文献
Rubyのメソッド定義については、 https://docs.ruby-lang.org/ja/latest/doc/spec=2fdef.html#method が参考になります。
また、Kernel.#system
メソッドに渡せる引数については https://docs.ruby-lang.org/ja/latest/method/Kernel/m/spawn.html が詳しいです。