pockestrap

Programmer's memo

Kernel.#systemの型はむずかしい

adventar.org

この記事は ruby-signature advent calendar 23日目の記事です。 今日が祝日じゃないと今月に入ってから知りました。

私はruby-signatureにKernel.#systemメソッドの型を修正するPRを出しました。

https://github.com/ruby/ruby-signature/pull/76

systemメソッドの型は思ったより難しかったので、それについて紹介しようと思います。

今までの型定義

まずは、Kernel.#systemメソッドの型定義を見てみましょう。 このPR以前は次のような型定義でした。

https://github.com/ruby/ruby-signature/blob/55824c808d6f07c61f9e60a52e4b9ad2ddeb2a42/stdlib/builtin/kernel.rbs#L568

def system: (*String args) -> (NilClass | FalseClass | TrueClass)

単純ですね。system('ls' '-al')のように任意個の文字列を受け取るメソッドとして定義されています。

これは多くのケースをカバーする型定義だと思いますが、少し考えると間違っているのがわかると思います。

例えばこの型定義だとsystem({ 'RUBYOPT' => '-w' }, 'ruby', 'foo.rb')のような環境変数を指定するメソッド呼び出しは考慮されていません。 また、system()のような引数が1つもないケースで、通るべきではない型検査が通ってしまいます。

修正された型定義

上記のような問題を修正したより正しい型定義は次のようになりました。

https://github.com/ruby/ruby-signature/blob/c09226a1b4b98ece4429f3316ac600e6499e25de/stdlib/builtin/kernel.rbs#L687-L819

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つ見てみましょう。

環境変数

冒頭でも説明しましたが、RubyKernel.#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)

うーん、難しそうな気配がしてきましたね。

リダイレクト

RubyKernel.#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 が詳しいです。