Money Forward Developers Blog

株式会社マネーフォワード公式開発者向けブログです。技術や開発手法、イベント登壇などを発信します。サービスに関するご質問は、各サービス窓口までご連絡ください。

20230215130734

String#scan の型の難しさと、そのちょっとした緩和策について

こんにちは。 id:Pocke です。最近、お寿司を食べるとお寿司を食べたくなることに気が付きました。

今日はString#scanの型の話をします。

String#scanの挙動

さて、まずはString#scanの挙動についておさらいしましょう。String#scanはパターンを受け取り、受け取ったパターンにマッチした文字列の配列を返すメソッドです。次に簡単な例を示します。

str = "Hello, world!"

# 文字列に含まれる単語を列挙する
p str.scan(/\w+/)
# => ["Hello", "world"]

# ブロックを渡すこともできる
str.scan(/\w+/) do |word|
  p word
  # => "Hello"
  # => "world"
end

文字列中からマッチした部分文字列を列挙しています。文字列処理を書く際に便利そうですね。またブロックを渡して、マッチした文字列を引数にブロックを実行することもできます。

またString#scanはパターンとして文字列も受け付けます。

str = "Hello, world!"

# 'Hello' に完全一致する部分文字列を列挙する
p str.scan('Hello')
# => ["Hello"]

String#scanに渡す正規表現がグルーピングを含む場合、戻り値の配列のそれぞれの要素は、グルーピングの数の長さの配列になります。

str = <<~TXT
  file1.rb: foobar
  file2.rb: baz
TXT

p str.scan(/^(.+):\s*(.+)$/)
# => [
#      ["file1.rb", "foobar"],
#      ["file2.rb", "baz"]
#    ]

なんとなくString#scanの使い方がわかったでしょうか? より詳しく知りたい場合には、公式ドキュメントを参照すると良いでしょう。

String#scanの型

このメソッドの型は以下のRBSで表されます。

class String
  def scan: (Regexp | string pattern) -> Array[String | Array[String]]
          | (Regexp | string pattern) { (String | Array[String]) -> void } -> self
end

型の解説

RBSに不慣れな読者のために、簡単に解説をします。1 RBSに詳しい場合は、次の「この型を扱う際の問題点」まで読み飛ばして構いません。

まず、class String ... endはクラス定義を表します。これはRubyと同じです。

次に、def scan: ...の部分がscanメソッドの定義を表します。そして、3行目の先頭にある|がメソッドのオーバーロードを表します。 つまりこのメソッドは、次の2つの定義に分けられます。これはそれぞれブロックを受け取らない場合と、受け取る場合に対応しています。

  • (Regexp | string pattern) -> Array[String | Array[String]]
  • (Regexp | string pattern) { (String | Array[String]) -> void } -> self

今回はブロックの有無は大きな争点ではないので、ここでは前者のブロックを受け取らない場合を解説します。

前半部分のカッコの(Regexp | string pattern)は、引数の型を表します。これは、patternという名前の引数を1つ受け取り、その型はRegexpもしくはstringであることを表します。2

そして->の右側はこのメソッドの戻り値です。Array[String | Array[String]]は、戻り値はArrayであることを表し、そのArrayの要素はStringArray[Srting](文字列を要素に持つ配列)であることを表します。

この型を扱う際の問題点

この型定義を使ってString#scanを使ったコードの型検査を行おうとすると、問題が起きます。 たとえば次のコードをSteepを使って検査してみましょう。単語をscanして、それを大文字にして出力するだけのコードです。

# test.rb
str = 'Hello, world!'
words = str.scan(/\w+/).map do |word|
  word.upcase
end
p words
# => ["HELLO", "WORLD"]

Steepで解析するために、以下のSteepfileを置きます。

# Steepfile
target :lib do
  check "test.rb"
end

steep checkコマンドを実行すると、次のように出力されます。

$ steep check
(snip)
test.rb:4:7: [error] Type `(::String | ::Array[::String])` does not have method `upcase`
│ Diagnostic ID: Ruby::NoMethod
│
└   word.upcase
         ~~~~~~

おや、Rubyコードとしては実行できるのに、型検査は失敗してしまいましたね。

失敗の原因は、引数の正規表現にグルーピングが含まれているかどうかで、戻り値の型が変わることにあります。 このケースでは戻り値の型がArray[Array[String]]になることはありません。ですが、Steepは正規表現のグルーピングの情報を知らないため、その可能性を排除できません。 そのため、Array[String]に対してupcaseメソッドを呼び出せないというエラーが発生してしまいます。

ワークアラウンド

型検査を通したい場合、次のワークアラウンドが有効です。

# test.rb
str = 'Hello, world!'
words = str.scan(/\w+/).map do |word|
  # @type var w: String
  w = _ = word
  w.upcase
end
p words
# => ["HELLO", "WORLD"]

この例ではwordw変数に代入しています。そしてw変数の型を、# @typeコメントでStringと宣言しています。3

なお_変数に代入しているのは型のキャストです。Steepは_に代入された値をuntypedとして扱います。直接w = wordとしてしまうとArray[String]String型の変数に代入できずエラーになってしまうため、_を使ってuntypedを介して代入する必要があります。4

この方法の欠点は安全ではないことです。wが本当にArray[String]ではなくStringであることをSteepは保証せず、プログラマが気をつける必要があります。 たとえば正規表現を後からグルーピングありのものに変えた場合、実際の型とSteepが想定する型に違いが出てしまいます。 この欠点は型検査の利点を損なってしまいます。

もしくは次のように書いて型エラーを回避することもできます。

# test.rb
str = 'Hello, world!'
words = str.scan(/\w+/).map do |word|
  case word
  when String
    word.upcase
  else
    raise 'unexpected'
  end
end
p words
# => ["HELLO", "WORLD"]

wordStringのインスタンスであることをcaseを使って明示的にチェックしています。これなら安全に型検査を行えますが、少し冗長になってしまいますね。 ここでwordString以外になることはないので、これは本来不要なチェックのはずです。

このように、String#scanを使ったコードをRBS, Steepを使って検査しようとすると、問題が生じます。

ちょっとした緩和策

このように扱いづらいString#scanですが、今回ちょっとした緩和策を実装してみました。

github.com

このPRがマージされると、引数が文字列の場合にこの問題が発生しなくなります。たとえば以下のコードは現在は型エラーが発生してしまいますが、このPRが適用されるとエラーにならなくなります。

# test.rb
str = 'Hello, world!'
words = str.scan('Hello').map do |word|
  word.upcase
end
p words
# => ["HELLO"]

このPRでは、引数が文字列の場合にはArray[Array[String]]が返る可能性を排除して、Array[String]だけが返るように変更しました。 引数が文字列の場合はグルーピングが存在することはないため、Array[Array[String]]を排除できます。

とはいえ正規表現を引数に渡した場合には、この記事で説明した問題が依然として発生してしまいます。5

最後に

String#scanの型と、それによって起こる問題を紹介しました。またその問題に対応するPRも紹介しました。 String#scanやRBSに少しでも興味を持っていただければ嬉しいです。


  1. RBSについて詳しく知りたい場合は、RBSの公式ドキュメントを参照すると良いでしょう。
  2. stringSが小文字であることが気になるかも知れませんが、今回はあまり気にしなくて良いです。Stringと、暗黙にStringに変換できるなにかが該当します。 stringの定義はこちら
  3. https://github.com/soutaro/steep/blob/v1.3.2/manual/annotations.md
  4. w変数への代入を避けて、(_ = word).upcaseのように書くこともできます。
  5. 実際、Stringを引数に渡すケースは少ないような気がしています。String#scanにブロックを渡して、$~などでMatchDataを参照するような使い方ならあるかなあ……