RubyKaigi 2022 で動的に RBS を生成してみた話をする

前日の宣伝になってしまうんですが明日の RubyKaigi 2022 で動的に RBS を生成してみた話をします。

内容としては上に書いてある通りなんですが Ruby の実行中に型情報を収集して RBS を生成してみたって内容の話になります。 やったことに対する先出しをすると以下のように Ruby を実行しつつ RBS を出力するような gem をつくりました(つくっています)。

# sample.rb
class FizzBuzz
  def initialize(value)
    @value = value
  end

  def value; @value end

  def apply
      value % 15 == 0 ? "FizzBuzz"
    : value %  3 == 0 ? "Fizz"
    : value %  5 == 0 ? "Buzz"
    : value
  end
end

p (1..20).map { FizzBuzz.new(_1).apply }
# sample.rb を実行して、その結果を元にして RBS を出力する
$ rbs-dynamic trace sample.rb
# RBS dynamic trace 0.1.0

class FizzBuzz
  private def initialize: (Integer value) -> Integer

  def apply: () -> (Integer | String)

  def value: () -> Integer

  @value: Integer
end
$

内容的には苦労話メインになりそうなんですが、RBSRuby の型について気になる人がいればぜひぜひ聞きに来てくださいー。 参加されない方も後日スライドは公開するのでぜひ読んでもらえるとー。

2022/09/02 今回の気になった bugs.ruby のチケット

今週は Numbered Parameters の _1it に置き換えるチケットがありました。

[Raises Exception for Range#last(n) with Float::INFINITY]

  • Range の終端が Float::INFINITY の場合に無限ループする
(6..Float::INFINITY).last(1)  # => infinite loop!!
(6..).last(1)   # cannot get the last element of endless range (RangeError)
(-Float::INFINITY..4).last(1) # can't iterate from Float (TypeError)
(..4).last(1)                 # can't iterate from Float (TypeError)
  • これを RangeError で例外にしようというチケット
  • そういえば昔 Range#first で似たようなチケット立てた記憶
  • このあたり、微妙に挙動が違っていて無限に難しい
  • 引数がない場合は以下のような挙動にもなる
p (1..10).last               # => 10
p (1..Float::INFINITY).last  # => Infinity
p (1..).last                 # Error: in `last': cannot get the last element of endless range (rangeerror)

[Feature #18159] Integrate functionality of syntax_suggest gem into Ruby

[Feature #18980] Re-reconsider numbered parameters: it as a default block parameter

  • _1 の変わりに it を Numbered parameters で利用したいというチケット
    • _1 は『未使用のローカル変数』に見えてしまうらしい
    • わたしは結構カジュアルに _1 を使っているんですが今の所名前に関して困ったとか混乱しとかはないですねえ(そういう話も聞いたことはない
    • 慣れの問題なんですかね?
  • どっちかって言うとナンパラ自体が特別な構文なので it のような一般的に使用されるような名前よりも _1 のように『特別な名前に見える方』が好みではありますね
  • チケット内でかなり議論が進んでいるので気になる人は読んでみてみるとよいかも
    • it じゃなくて @ を使おう、みたいな話もでてますね
    • it よりも @ の方が特別な意味があるように見えるので it よりはマシだと思うんですが @ にするぐらいなら _1 のままの方がいいかなあ…

[Feature #18951] Object#with to set and restore attributes around a block

  • 以下のように一時的に設定を変えたりインスタンス変数を変えたいときがある
def test_something_when_enabled
  # 一時的に SomeLibrary.enabled を有効にしたい
  enabled_was, SomeLibrary.enabled = SomeLibrary.enabled, true
  # test things
ensure
  SomeLibrary.enabled = enabled_was
end
def with_something_enabled
  # ブロック引数内では enabled_was を有効な状態で実行したい
  enabled_was = @enabled
  @enabled = true
  yield
ensure
  @enabled = enabled_was
end
  • この時に以下のような場合だと意図しない挙動になるケースがある
def test_something_when_enabled
  # some_call_that_may_raise で例外が発生した場合 SomeLibrary.enabled = nil になってしまう
  # これはまだ enabled_was に対して値が割り当てられていないため
  some_call_that_may_raise
  enabled_was, SomeLibrary.enabled = SomeLibrary.enabled, true
  # test things
ensure
  SomeLibrary.enabled = enabled_was
end
  • これを解決するために以下のような Object#with を導入したいというチケット
class Object
  def with(**attributes)
    old_values = {}
    attributes.each_key do |key|
      old_values[key] = public_send(key)
    end
    begin
      attributes.each do |key, value|
        public_send("#{key}=", value)
      end
      yield
    ensure
      old_values.each do |key, old_value|
        public_send("#{key}=", old_value)
      end
    end
  end
end

def test_something_when_enabled
  SomeLibrary.with(enabled: true) do
    # test things
  end
end

GC.with(measure_total_time: true, auto_compact: false) do
  # do something
end
  • こういう制御は結構するので便利そう

2022/08/25 今回の気になった bugs.ruby のチケット

今週は lazy.take(0) した時に期待した挙動にならないというバグ報告がありました。

[Bug #18972] String#byteslice should return BINARY (aka ASCII-8BIT) Strings

# 今はレシーバのエンコーディングになっている
p "fée".byteslice(1).encoding
# => #<Encoding:UTF-8>
p "fée".byteslice(1).valid_encoding?
# => false
  • 期待する挙動は以下の通り
p "fée".byteslice(1).encoding
# => #<Encoding:ASCII-8BIT>
p "fée".byteslice(1).valid_encoding?
# => true

[Bug #18971] Enumerator::Lazy.take(0) leaks first element into next operation

  • 以下のように lazytake(0) を使用した時は期待する挙動になる
(2..10).take(0).to_a # => []
(2..10).take(0).map(:&itself).to_a # => []
(2..10).lazy.take(0).to_a # => []
  • しかし、以下のように take(0) した後に別の操作をすると期待する挙動にならないというバグ報告
p (2..10).lazy.take(0).map(&:itself).to_a # => [2]
p (2..10).lazy.take(0).select(&:even?).to_a # => [2]
p (2..10).lazy.take(0).select(&:odd?).to_a # => []
p (2..10).lazy.take(0).reject(&:even?).to_a # => []
p (2..10).lazy.take(0).reject(&:odd?).to_a # => [2]
p (2..10).lazy.take(0).take(1).to_a # => [2]
p (2..10).lazy.take(0).take(0).take(1).to_a # => [2]
p (2..10).lazy.take(0).drop(0).to_a # => [2]
p (2..10).lazy.take(0).find_all {|_| true}.to_a # => [2]
p (2..10).lazy.take(0).zip((12..20)).to_a # => [[2, 12]]
p (2..10).lazy.take(0).uniq.to_a # => [2]
p (2..10).lazy.take(0).sort.to_a # => []
p (2..2).lazy.take(0).sort.to_a # => []

[Bug #18974] Wrong line number in the rescue iseq for the exception matching code

  • iseq の出力と rescue => e している箇所の行番号がズレているというバグ報告
    • コード上は4行目になっているが iseq の出力では5行目になっている
def foo
  begin
    raise 'error'
  rescue => e
    puts e.message
  end
end

puts RubyVM::InstructionSequence.of(method :foo).disasm
__END__
== disasm: #<ISeq:foo@/tmp/vL5efqT/19:1 (1,0)-(7,3)> (catch: TRUE)
== catch table
| catch type: rescue st: 0000 ed: 0005 sp: 0000 cont: 0006
| == disasm: #<ISeq:rescue in foo@/tmp/vL5efqT/19:4 (4,2)-(5,18)> (catch: TRUE)
| local table (size: 1, argc: 0 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
| [ 1] $!@0
| 0000 getlocal_WC_0                          $!@0                      (   5)[Li]
| 0002 putobject                              StandardError
| 0004 checkmatch                             3
| 0006 branchunless                           20
| 0008 getlocal_WC_0                          $!@0                      (   4)
| 0010 setlocal_WC_1                          e@0
| 0012 putself                                                          (   5)
| 0013 getlocal_WC_1                          e@0
| 0015 opt_send_without_block                 <calldata!mid:message, argc:0, ARGS_SIMPLE>
| 0017 opt_send_without_block                 <calldata!mid:puts, argc:1, FCALL|ARGS_SIMPLE>
| 0019 leave
| 0020 getlocal_WC_0                          $!@0
| 0022 throw                                  0
| catch type: retry  st: 0005 ed: 0006 sp: 0000 cont: 0000
|------------------------------------------------------------------------
local table (size: 1, argc: 0 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
[ 1] e@0
0000 putself                                                          (   3)[LiCa]
0001 putstring                              "error"
0003 opt_send_without_block                 <calldata!mid:raise, argc:1, FCALL|ARGS_SIMPLE>
0005 nop                                                              (   1)
0006 leave                                                            (   7)[Re]

[Feature #18408] Allow pattern match to set instance variables

  • パターンマッチでインスタンス変数への束縛を許容したいチケット
  • 現状はパターンマッチでインスタンス変数に対して値を束縛する事ができない
# これはエラーになる
case {name: "John", age: 42}
in name: /jo/ => @name, age: @age
end

puts [@name, @age] #=> ["John", 42]
  • 上記のようにインスタンス変数でも束縛したいことがチケットの内容になる
  • これに関しては以前から議論されていたんですが現時点では『ローカル変数以外はサポートしない』という意思決定がされたようです

2022/08/18 今回の気になった bugs.ruby のチケット

今週はメモ化を行う APIRuby のコア機能に入れる議論がありました。

[eature #18934] Proposal: Introduce method results memoization API in the core

  • メモ化する APIRuby のコア機能に導入しようという提案
    • メモ化とは1回目のメソッドの結果を保存しておき、2回目以降はその結果を使い回す機能の事
class Test
  def foo
    puts "call!"
    5
  end

  # foo メソッドをメモ化する
  memoized :foo
end

o = Test.new

# 1回目の呼び出しはメソッド本体が呼び出される
o.foo # prints "call!", returns 5

# 2回目以降はメソッドの本体は呼び出されず1回目の結果を返す
o.foo # returns 5 immediately
  • 具体的な例として以下のようなコードが上げられている
    • SomeTokenizer#call が非効率なメソッドの場合にボトルネックになる可能性がある
class Sentence
  attr_reader :text

  def initialize(text) = @text = text

  def tokens() = SomeTokenizer.new(text).call

  def size() = tokens.size
  def empty?() = tokens.all?(&:whitespace?)

  def words() = tokens.select(&:word?)
  def service?() = words.empty?
end

many_sentences
  .reject(&:empty?)
  .select { _1.words.include?('Ruby') }
  .map { _1.words.count / _1.tokens.count }
  • メモ化の手段として ||= を使うイディオムもある
def words()= @words ||= tokens.select(&:word?)
  • しかし、イディオムにはいくつか欠点があり例えば service? のように nil / false を返すようなメソッドの場合にはその都度処理が呼ばれてしまう
  • なのでそれを回避する場合は以下のように定義する必要がある
def empty?
  # @empty が定義されている場合のみ tokens.all?(&:whitespace?) を呼び出す
  return @empty if defined?(@empty)

  @empty = tokens.all?(&:whitespace?)
end
  • またインスタンス変数を使うことで #inspect の結果などに保持している値が含まれてしまう
class Sentence
  def initialize(text)
    @text = text
  end

  def empty?
    # @empty が定義されている場合のみ tokens.all?(&:whitespace?) を呼び出す
    return @empty if defined?(@empty)

    @empty = @text.empty?
  end
end

require "yaml"

s = Sentence.new('Ruby is cool')
puts s.to_yaml
# => --- !ruby/object:Sentence
#    text: Ruby is cool

p s.empty?
# => false

# s の中身が汚染されてしまう
puts s.to_yaml
# --- !ruby/object:Sentence
# text: Ruby is cool
# empty: false
  • メモ化するライブラリとして memoistmemo_wise が紹介されている
    • memo_wise の方が新しくて効率がいいらしい
    • これらのライブラリでは nil / false の戻り値にも対応している
# 使用例
class Sentence
  prepend MemoWise
  # ...
  memo_wise def empty?() = tokens.all?(:whitespace?)
end
  • ただし、上記のライブラリを使った場合でもインスタンス変数を使っているのでオブジェクトが汚染されてしまう
p s.empty?
# false
p s
# #<Sentence:0x00007f0f474eb418 @_memo_wise={:empty?=>false}, @text="Ruby is cool">
puts s.to_yaml
# --- !ruby/object:Sentence
# _memo_wise:
#   :empty?: false
# text: Ruby is cool
  • なのでこれを解決するためにインスタンス変数を使わないで C で実装された Module#memoized(*symbols) をコア機能として提供する、というのがこのチケットでやりたいことになる
  • コアにあると便利ではあると思うが C API を使った gem とかつくれないのかな
  • あとはメモ化はクラス全体が immutable でないと安全ではないので意図的にインスタンス変数などを変えた場合に誤った動作をする、みたいな事がコメントで指摘されていますね

2022/08/11 今回の気になった bugs.ruby のチケット

今週は **nil を許容する提案などがありました。

[Feature #18959] Handle gracefully nil kwargs eg. **nil

  • 以下のように『値が存在している時のみ』 Hash に値を追加したいケースがある
{
  some: 'value',
  **({ id: id } if id.present?),   # id の値が存在していれば `{ id: id }` を Has に定義したい
}
  • しかし以下のように **nil は呼び出すことができないので上記のコードは意図する挙動をしない
def qwe(a: 1) end

qwe(**nil) #=> fails with `no implicit conversion of nil into Hash (TypeError)` error

{ a:1, **nil } #=>  fails with `no implicit conversion of nil into Hash (TypeError)` error
  • 以下のようにして回避する事もできるが冗長である
h = { some: 'value' }
h[:id] = id if id.present?
h
# または以下
{
  some: 'value',
  **(id.present? ? { id: id } : {}),
}
  • *nil は動作しており、以下のように書くことができるので **nil も許容してほしいという提案
    • ちなみに *nil[] になる
    • Rails でよく使うらしい
content_tag :div, class: [*('is-hero' if hero), *('is-search-page' if search_page)].presence
  • ちなみに *obj**objobj.to_aobj.to_hash が内部で呼び出される
class X
  def to_a
    ["default"]
  end

  def to_hash
    { default: 0 }
  end
end

x = X.new
pp(*x)    # => "default"
pp(**x)   # => {:default=>0}
  • なので以下のように nil.to_hash を定義して対応する事は可能
# to_hash を定義しておく
def nil.to_hash = {}

def qwe(a: 1) end

qwe(**nil) #=> OK

{ a:1, **nil } #=> OK
h = {"y"=>"!"}
"xyz".sub(/y/, h) #=> "x!z"

h = nil
"xyz".sub(/y/, h) #=> TypeError (no implicit conversion of nil into String)

def nil.to_hash; {}; end
"xyz".sub(/y/, h) #=> "xz" (segfault in 2.6!)

[Feature #18961] Introduce support for pattern matching all elements of an array

  • 以下のように配列の全ての要素が特定のパータンの場合のパターンマッチを書きたいことがある
class Robot; end
class User; end

case [User.new, User.new]
in [User, *] => u if u.all? { _1 in User }
  puts 'array of users!'
end
# => array of users!

case [User.new, User.new, Robot.new]
in [User, *] => u if u.all? { _1 in User }
  puts 'array of users!'
end
# => Error: guard clause does not return true (NoMatchingPatternError)
  • これを以下のような構文で対応させいたいという提案
case [User.new, User.new]
in [User*]
  puts 'array of users!'
end
  • これだけだと u.all? { _1 in User } でよさそうに見えるんですがパターンの一部だけに記述したい、みたいな要求はありそう

[Bug #18960] Module#using raises RuntimeError when called at toplevel from wrapped script

# using.rb
using Module.new
# OK
load "./using.rb"

# NG
load "./using.rb", true
# raises RuntimeError (main.using is permitted only at toplevel)
  • こんなバグが

マージされた機能

[Bug #18946] Time#to_date returns incorrect date

  • 以下のように 1499-12-27TimeDate に変換すると 1499-12-18 になるというバグ報告
require "time"

time = Time.local(1499, 12, 27)
pp time
# => 1499-12-27 00:00:00 +091859

# 変換した日付は -9日されている
pp time.to_date
# => #<Date: 1499-12-18 ((2268919j,0s,0n),+0s,2299161j)>
# 1582-10-15 の場合
require "time"

time = Time.local(1582, 10, 15)
pp time
# => 1582-10-15 00:00:00 +091859

# 変換前と変換後は同じ日付
pp time.to_date
# => #<Date: 1582-10-15 ((2299161j,0s,0n),+0s,2299161j)>
# 1582-10-15 の場合
require "time"

time = Time.local(1582, 10, 14)
pp time
# => 1582-10-14 00:00:00 +091859

# この日付だと変換後はユリウス暦の日付に置き換わっている
pp time.to_date
# => #<Date: 1582-10-04 ((2299160j,0s,0n),+0s,2299161j)>
require "date"

p Time.local(1499, 12, 27).to_datetime
# => #<DateTime: 1499-12-27T00:00:00-07:52 ((2268928j,28378s,0n),-28378s,2299161j)>

p Time.local(1499, 12, 27).to_date
# => #<Date: 1499-12-18 ((2268919j,0s,0n),+0s,2299161j)>
require "date"

p RUBY_VERSION   # => "3.2.0"

p Time.local(1499, 12, 27).to_datetime
# => #<DateTime: 1499-12-18T00:00:00+09:18 ((2268918j,52861s,0n),+33539s,2299161j)>

p Time.local(1499, 12, 27).to_date
# => #<Date: 1499-12-18 ((2268919j,0s,0n),+0s,2299161j)>

2022/08/04 今回の気になった bugs.ruby のチケット

今週は Enumerator.product が新しく追加されました。

[Bug #18953] Array#uniq doesn't evaluate the given block when the size of the array is one

  • 配列の要素が1つの場合に Array#uniq のブロックが呼ばれないんだけどこれは期待する挙動?というバグ報告
# これはエラーにならないが
[1].uniq { aaa }

# これはエラーになる
# error: undefined local variable or method `aaa' for main:Object (NameError)
[1, 2].uniq { aaa }
  • これ自体は期待する挙動ぽいですが、実装依存になるんですかね?
  • また Array#sort_by #max_by #min_by は要素が1つの場合でもブロックが呼び出されます
# error: undefined local variable or method `aaa' for main:Object (NameError)
[1].sort_by { aaa }
  • あと Enumerable#uniq は要素が1つの場合でもエラーになるみたいですね
require "set"

# 2つともエラーになる
Set[1].uniq { aaa }
{ a: 1 }.uniq { aaa }

[Feature #18950] Hash#slice fails to copy default block

  • Hash#slice がデフォルトの proc をコピーして返さないバグ報告
hash_with_default = Hash.new { |h, k| h[k] = {} }

# デフォルト proc が設定されている
pp hash_with_default.default_proc
# => #<Proc:0x00007f7cd0f3c8a8 /tmp/vdmem8a/29:1>

# #slice の戻り値はデフォルト proc が設定されていない
pp hash_with_default.slice(:a).default_proc
# => nil
  • これは期待する挙動で Hash#slice は新しい Hash オブジェクトを返してその時にデフォルトの proc はコピーされないみたいです
  • 以下のように Hash#slice 以外でも同様の挙動
hash_with_default = Hash.new { |h, k| h[k] = {} }
pp hash_with_default.except(:a).default_proc   # => nil
pp hash_with_default.select {}.default_proc    # => nil
pp hash_with_default.invert.default_proc       # => nil

[Bug #18743] Enumerator#next / peek re-use each others stacktraces

enum = [1, 2, 3].each
# Enumerator#peek は状態を変化させないで「次」を返す
pp enum.peek   # => 1
pp enum.peek   # => 1
pp enum.peek   # => 1

# Enumerator#next は状態を変化させて「次」を返す
pp enum.next   # => 1
pp enum.next   # => 2
pp enum.next   # => 3

# 「次」がない場合はエラーになる
# error: `next': iteration reached an end (StopIteration)
pp enum.next   # => 3
  • 次のように peek で失敗したした後に next でエラーになるとバックトレースの行数が正しくないというバグ報告
# enum.rb             # 1
                      # 2
enum = [].each        # 3
enum.peek rescue nil  # 4   <- エラー行はここを指している
enum.next             # 5   <- ここでエラーになるが↑
$ ruby enum.rb
enum.rb:4:in `peek': iteration reached an end (stopiteration)
    from enum.rb:4:in `<main>'
  • 以下のように複数呼び出した場合も意図しない行数になっている
# enum.rb                # 1
                         # 2
enum = [].each           # 3
enum.peek rescue nil     # 4
enum.next rescue nil     # 5
enum.peek rescue nil     # 6
puts "line #{__line__}"  # 7
enum.next                # 8
$ ruby enum.rb
line 7
enum.rb:4:in `peek': iteration reached an end (stopiteration)
    from enum.rb:4:in `<main>'

[feature #18685] Enumerator.product: cartesian product of enumerables

  • 要素ごとの全ての組み合わせの Enumerator を生成する Enumerator.product を追加する提案
product = Enumerator.product(1..3, ["a", "b"])
p product.class #=> Enumerator

product.each do |i, c|
  puts "#{i}-#{c}"
end
__end__
output:
1-a
1-b
2-a
2-b
3-a
3-b
  • この機能は ruby 3.2 にマージされました

2022/07/29 今回の気になった bugs.ruby のチケット

今週は CRuby のインデントが全てスペースに展開されるチケットがありました。

[Bug #18883] parse.y: trailing comma cannot coexist with star

  • 末尾に , がある時に * 付きの多重代入がうまく動作しないというバグ報告
x, y   = 1, 2   # OK => x = 1,   y = 2
x, y,  = 1, 2   # OK => x = 1,   y = 2
*x, y  = 1, 2   # OK => x = [1], y = 2
*x, y, = 1, 2   # syntax error, unexpected '='
  • x, = [1, 2, 3]x, * = [1, 2, 3] と同等になっている
  • したがって *x, y, = 1, 2*x, y, * = 1, 2 と同等となる
  • Ruby では * が2つある場合は代入することができないので報告されているエラー自体は期待する挙動っぽい?
*x, y, * = 1, 2   # => syntax error, unexpected *
x, a    = [1, 2, 3]
x, a,   = [1, 2, 3]
x, a, * = [1, 2, 3]
bin = RubyVM::InstructionSequence.compile("x, a = [1, 2, 3]")
puts bin.disasm
# => == disasm: #<ISeq:<compiled>@<compiled>:1 (1,0)-(1,16)> (catch: FALSE)
#    local table (size: 2, argc: 0 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
#    [ 2] x@0        [ 1] a@1
#    0000 duparray                               [1, 2, 3]                 (   1)[Li]
#    0002 dup
#    0003 expandarray                            2, 0
#    0006 setlocal_WC_0                          x@0
#    0008 setlocal_WC_0                          a@1
#    0010 leave

bin = RubyVM::InstructionSequence.compile("x, a, = [1, 2, 3]")
puts bin.disasm
# => == disasm: #<ISeq:<compiled>@<compiled>:1 (1,0)-(1,16)> (catch: FALSE)
#    local table (size: 2, argc: 0 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
#    [ 2] x@0        [ 1] a@1
#    0000 duparray                               [1, 2, 3]                 (   1)[Li]
#    0002 dup
#    0003 expandarray                            2, 0
#    0006 setlocal_WC_0                          x@0
#    0008 setlocal_WC_0                          a@1
#    0010 leave

bin = RubyVM::InstructionSequence.compile("x, a, * = [1, 2, 3]")
puts bin.disasm
# => == disasm: #<ISeq:<compiled>@<compiled>:1 (1,0)-(1,16)> (catch: FALSE)
#    local table (size: 2, argc: 0 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
#    [ 2] x@0        [ 1] a@1
#    0000 duparray                               [1, 2, 3]                 (   1)[Li]
#    0002 dup
#    0003 expandarray                            2, 0
#    0006 setlocal_WC_0                          x@0
#    0008 setlocal_WC_0                          a@1
#    0010 leave

[Misc #18891] Expand tabs in C code

  • 既存の Ruby の実装の C のコードのインデントを全てスペースに統一するというチケット
    • インデント幅は 4 で幅が8であればタブ文字、それ以外はスペースというスタイルになっていた
    • 新しいコードは基本的にスペースを使うようにはなっていたが古いコードではタブ文字が依然として残っている状態になっていた
  • VSCode だと古いスタイルでコードを書くことが難しいというのが起因ぽいですね
  • また今回の変更を git blame で無視されるように .git-blame-ignore-revs に今回のコミットを追加しているぽいですね
  • これ、長年の問題だったので解消されてめでたい

[Bug #18931] Inconsistent handling of invalid codepoints in String#lstrip and String#rstrip

  • 無効なコードポイントが文字列に含まれている場合の String#lstripString#rstrip で一貫性がないというバグ報告
  • String#lstrip だと以下のような挙動
# error: `lstrip': invalid byte sequence in UTF-8 (ArgumentError)
p " \x80abc".lstrip
# error: `lstrip': invalid byte sequence in UTF-8 (ArgumentError)
p " \x80 abc".lstrip
# error: `lstrip': invalid byte sequence in UTF-8 (ArgumentError)
p "\x80".lstrip
# ok
p "  abc \x80".lstrip
# => "abc \x80"
  • String#rstrip だと以下のような挙動
# ok
p "abc\x80 ".rstrip
# => "abc\x80"
# ok
p "abc\x80".rstrip
# => "abc\x80"
  • また String#rstrip の場合は更に意図しない挙動になっているぽい?
# ok: \x80 が消える
p "abc \x80".rstrip
# => "abc"
# ok: \x80 が消える
p "abc \x80 ".rstrip
# => "abc"
# ok: \x80 が消える
p " \x80 ".rstrip
# => ""
  • \x80 だけの場合だとエラーになる
# error: `rstrip': invalid byte sequence in UTF-8 (ArgumentError)
p "\x80 ".rstrip
# error: `rstrip': invalid byte sequence in UTF-8 (ArgumentError)
p "\x80".rstrip
  • なにもわからない