こんにちは。 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
の要素はString
かArray[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"]
この例ではword
をw
変数に代入しています。そして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"]
word
がString
のインスタンスであることをcase
を使って明示的にチェックしています。これなら安全に型検査を行えますが、少し冗長になってしまいますね。
ここでword
がString
以外になることはないので、これは本来不要なチェックのはずです。
このように、String#scan
を使ったコードをRBS, Steepを使って検査しようとすると、問題が生じます。
ちょっとした緩和策
このように扱いづらいString#scan
ですが、今回ちょっとした緩和策を実装してみました。
この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に少しでも興味を持っていただければ嬉しいです。
- RBSについて詳しく知りたい場合は、RBSの公式ドキュメントを参照すると良いでしょう。↩
-
string
のS
が小文字であることが気になるかも知れませんが、今回はあまり気にしなくて良いです。String
と、暗黙にString
に変換できるなにかが該当します。string
の定義はこちら↩ - https://github.com/soutaro/steep/blob/v1.3.2/manual/annotations.md↩
-
w
変数への代入を避けて、(_ = word).upcase
のように書くこともできます。↩ -
実際、Stringを引数に渡すケースは少ないような気がしています。
String#scan
にブロックを渡して、$~
などでMatchData
を参照するような使い方ならあるかなあ……↩