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

今週はブロックの引数で Hash が分割して受け取れなくなったチケットがありました。

[Misc #18609] keyword decomposition in enumerable (question/guidance)

  • Ruby 3.0 から次のように Hash をキーワード引数で受け取れなくなったというチケット
drafts = [
  {name: 'draft4', mod: :Draft04, image: 'draft4.png'},
  {name: 'draft6', mod: :Draft06, image: 'draft6.jpg'},
]

# Ruby 2.x 系だと Hash をキーワード引数で受け取る事ができた
# Ruby 3.x 系だとキーワード引数の挙動が変わったのでこう書くことができなくなった
drafts.each do |name: , mod: , image: |
end
  • これは Ruby 3.0 で Hash が暗黙的にキーワード引数に変換されなくなった弊害ですね
def test(a:, b:)
end

# Ruby 2.7 => OK: Hash がそのままキーワード引数に変換されていた
# Ruby 3.0 => NG: Hash は暗黙的にキーワード引数に変換されなくなった
test({ a: 1, b: 2 })
  • 上記のように Hash をキーワード引数に渡したい場合は ** を付けることで渡せます
def test(a:, b:)
end

# Ruby 2.7 => OK: Hash をキーワード引数に渡せる
# Ruby 3.0 => OK: Hash をキーワード引数に渡せる
test(**{ a: 1, b: 2 })
module Enumerable
  def each_kw
    each{|v| yield(**v)}
  end
end

drafts.each_kw do |name: , mod: , image: | 
 # ...
end
  • これ、普通に本体にほしい気がする

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

今週はラテン文字String#downcase したときのバグ報告がありました。

[Feature #18603] Allow syntax like obj.method(arg)=value

  • 次のようのような構文を許容する提案
obj.method(arg) = value
  • これは以下と同じ意味になる
obj.__send__(:method=, arg, value)
obj.dig(0, :key, 1) = 20
  • あるとなにかに利用できそうだけどメソッド呼び出しは () が省略できるのでなかなかややこしそう
    • obj.method arg = value みたいなコードは現状でもかけるので
  • あと def obj.method(arg) = value と構文が似てるので混乱する、とコメントされていますね

[Bug #18590] String#downcase and CAPITAL LETTER I WITH DOT ABOVE

'İ'.downcase
# => "i̇"
  • しかし String#downcase の結果に結合文字 "̇" が含まれてしまっているというバグ報告
'İ'.downcase.chars
# => ["i", "̇"]
0130;LATIN CAPITAL LETTER I WITH DOT ABOVE;Lu;0;L;0049 0307;;;;N;LATIN CAPITAL LETTER I DOT;;;0069;
0130; F; 0069 0307; # LATIN CAPITAL LETTER I WITH DOT ABOVE
0130; T; 0069; # LATIN CAPITAL LETTER I WITH DOT ABOVE
'İ'.downcase.chars
# => ["i", "̇"]

'İ'.downcase(:turkic).chars
# => ["i"]
  • String#downcase のデフォルトの挙動としては フルケースホールディング になるのが意図しているので今回の報告は期待する挙動だったみたい
  • ただし、rdoc で参照しているドキュメントが正しくなかったのでそれに関しては別途修正されている

[Feature #18551] Make Range#reverse_each to raise an exception if endless

  • 終端無限 Range に対して Range#reverse_each を呼び出した時に例外を発生させる提案
  • 現状は無限ループになる
    • これは内部で #to_a を呼び出して配列に変換しようとしているから
# 無限ループになる
(1..).reverse_each { }
  • 先端無限に対して #each を呼び出す場合は例外になるのでそれに合わせたいって感じみたい
# error: `each': can't iterate from NilClass (TypeError)
(..1).each { }
  • 懸念点として Range#reverse_each を新たに実装する必要があるがその場合のパフォーマンスや仕様の複雑さがコメントされている
  • 定期的に無限 Range に対するこの手の話題が上がってる気がする
  • 個人的には全体的に一貫性の挙動にはなってほしい

[Bug #18577] Range#include? returns wrong result for beginless range with exclusive string end

  • (...'z') の場合は終端の値を含めるべきではないが #include? #member? #===true になり終端を含めた結果を返す
    • ただし #cover?false を返す
    • これは先端が無限の場合にのみ発生してる
# 数値は終端を含まない
(...10).include?(10)  # => false
(...10).member?(10)   # => false
(...10) ===(10)       # => false
(...10).cover?(10)    # => false

# 文字列は終端を含めている
(...'z').include?('z')  # => true
(...'z').member?('z')   # => true
(...'z') ===('z')       # => true
(...'z').cover?('z')    # => false

# 先端が無限でない場合は発生しない
('a'...'z').include?('z')  # => false
('a'...'z').member?('z')   # => false
('a'...'z') ===('z')       # => false
('a'...'z').cover?('z')    # => false
  • これは Ruby 3.2 で修正される
# Ruby 3.2 だと全部 false を返す
(...'z').include?('z')  # => false
(...'z').member?('z')   # => false
(...'z') ===('z')       # => false
(...'z').cover?('z')    # => false

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

今週はアクセシビリティ関連のバグ報告がありました。

[Bug #18600 ] Aliased method visibility issue on Ruby 3.1

  • 次のように親のメソッドを public にしてからalias_method すると private に戻ってしまっているというバグ報告
module M
  private def private_meth; end
end

class X
  include M

  public :private_meth
  alias_method :public_meth, :private_meth
  alias_method :private_meth, :public_meth
end

x = X.new

p x.respond_to?(:private_meth)
# Ruby 3.0.0 => true
# Ruby 3.1.0 => false

x.private_meth
# Ruby 3.0.0 => OK
# Ruby 3.1.0 => NG: private method `private_meth' called for #<X:0x00007fd46bca92b0> (NoMethodError)

[Feature #18594] Add a #to_h method on URI::Generic

  • 以下のように uri に対して Hash に変換するメソッドを追加する提案
require "uri"

uri = ::URI.parse("https://example.com")

pp uri.component
# => [:scheme, :userinfo, :host, :port, :path, :query, :fragment]
pp uri.select(*uri.component)
# => ["https", nil, "example.com", 443, "", nil, nil]

# こういうような Hash を返すメソッドを追加する提案
pp [uri.component, uri.select(*uri.component)].transpose.to_h
# => {:scheme=>"https", :userinfo=>nil, :host=>"example.com", :port=>443, :path=>"", :query=>nil, :fragment=>nil}

[Feature #16295] Chainable aliases for String#-@ and String#+@

# これは -("foo".size) と同じ意味になる
-"foo".size
# => -3

# 本来はこういう挙動を期待する
(-"foo").size
# => 3
  • なので次のようなコードはエラーになってしまう
# error: undefined method `+@' for false:FalseClass (NoMethodError)
+[1, 2, 3].to_s.frozen?
  • このチケットでは以下のようにチェーンできるようにすることを議論している
# -@ や +@ をメソッド呼び出しのようにする
"foo".-.size
ary.to_s.+.frozen?

# もしくは -@ や +@ と同等の名前付きのメソッドを別に用意する
"foo".dedup.sizたのむかねもなかったe
ary.to_s.mutable.frozen?
"foo".-@.size
# => 3

[1, 2, 3].to_s.+@.frozen?
# false
  • 色々と議論されているんですが以下のチケットに派生している

[Feature #18595] Alias String#-@ as String#dedup

  • [Feature #16295] からの派生
  • String#-@エイリアスとして String#dedup を追加する提案
  • String#+@ に関しては String#dup で代替できるのでこのチケットでは -@ について議論されている
  • 現状は String#freeze-@ の代替として利用できるんじゃない?とコメントされていて議論が進んでいる

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

今週はパターンマッチの find 検索を正式に導入するチケットがありました。

[PR reline] Proposal for quick shell execution

  • irb. から始まるコマンドを入力した時に shell コマンドが実行されるようにする提案
  • pry だとこの機能が実装されているらしい
.cat .ruby-version
2.7.5
  • わたしは pry の機能を知らなかったんですが REPL で shell コマンドを実行したいときって結構あるんですかね
  • Ruby だと
`.cat .ruby-version`

[Bug #13885] Random.urandom と securerandom について

  • Random.urandomsecurerandom の仕様のチケット
  • チケット自体の内容よりも日本語で議論されていて普段どうやって議論されているのかがわかりやすいので気になる人は見てみるとよいかも

[Feature #18585] Promote find pattern to official feature

  • Ruby 3.0 で入ったパターンマッチの find 検索を正式に導入するチケット
  • Ruby 3.0 で入った時はまだ実験的な機能だったので使用すると警告が出てくる
ary = [1, 2, 3]

# warning: Find pattern is experimental, and the behavior may change in future versions of Ruby!
if ary in [*, {a: 0, b: 1 | 2} => i, *]
end
  • Ruby 3.1 で消えるかと思ってたんですけどまだ残ってたみたいですね

[Feature #12962] Feature Proposal: Extend 'protected' to support module friendship

  • protected で宣言した時に後から別のクラスからでも呼び出せるようにする機能の提案
    • C++ にあるような friend 機能
class A
  protected def foo
    "secrets"
  end
end

class D
  def call_foo
    A.new.foo
  end
end

# A のフレンドとして D を登録
A.friend D

# D から A の protected を呼び出すことができるようになる
D.new.call_foo # => "secrets"
  • 他には以下のようにモジュールに対して使用したりとか
module MyLib
  module Internals
  end

  class A
    include Internals
    # Internals を friend することでこれを Internals を include しているクラスから
    # protected なメソッドを呼び出すことができるようになる
    friend Internals

    protected def foo
      "implementation"
    end
  end

  class B
    include Internals
    friend Internals

    protected def bar
      A.new.foo
    end
  end
end

class UserCode
  # include MyLib::Internals してないので protected なメソッドは呼べない
  def call_things
    [MyLib::A.new.foo, MyLib::B.new.bar]
  end
end

class FriendlyUserCode
  # include MyLib::Internals しているので protected なメソッドは呼べる
  include MyLib::Internals

  def call_things
    [MyLib::A.new.foo, MyLib::B.new.bar]
  end
end

UserCode.new.call_things # !> NoMethodError: protected method `foo'..
FriendlyUserCode.new.call_things # => ["implementation", "implementation"]
  • モチベーションとしては機能としてはプライベートな API だけど他の API でも使いたい事があるので Ruby 的な意味での private ではなくて public になっている事がある
    • private にて send で呼び出すこともできるが煩わしい
  • このように機能としてはプライベートだが Ruby として public になっているとユーザが混乱するので明示的に protectedfriend する仕組みがほしいらしい
  • Rubyアクセシビリティを制御するのってむずかしいので friend でちゃんと意識して書くようになるのかはちょっと気になる
  • 個人的には Refinements でメソッドを定義しておいて必要な時に using すればいいんじゃないかと思っている
class A
  # プライベートな API は Refinements で定義しておく
  module Internals
    refine A do
      def foo
        "secrets"
      end
    end
  end
end

class D
  # 使用する箇所で明示的に using する
  using A::Internals

  def call_foo
    A.new.foo
  end
end

D.new.call_foo # => "secrets"

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

今週はエンコーディングASCII_8BIT という名前を BINARY に変える提案がありました。

[Feature #18576] Rename ASCII-8BIT encoding to BINARY

  • エンコーディングASCII-8BITBINARY という名前に変更する提案
  • 以下のようなエラーメッセージで ASCII-8BIT と表示されても分かりづらいらしい
    • Ruby だと ASCII-8BITno encodingbinary という意味合いで使われているが Ruby をよく知らないと意味がわかりづらい
>> "fée" + "\xFF".b
(irb):3:in `+': incompatible character encodings: UTF-8 and ASCII-8BIT (Encoding::CompatibilityError)
  • 現状は BINARYASCII_8BITエイリアスとして存在しているんですがそれを逆にする感じなんですかね
pp Encoding::ASCII_8BIT  # => #<Encoding:ASCII-8BIT>
pp Encoding::BINARY      # => #<Encoding:ASCII-8BIT>

[Bug #18575] [BUG] unsupported: T_NONE

  • 巨大な CSV ファイルを処理している時にクラッシュするというバグ報告
  • 以下のコードで再現するらしい
require 'csv'
parsed = CSV.parse("Foo,bAr,baZ\nfoo,bar,baz", headers: true)

while true
  parsed.map do |row|
    obj = row.to_h
    obj.transform_keys! { |k| k.strip.downcase }
  end
end

[Feature #18573] Object#pack1

  • Array#pack を使用する時に配列の要素が1つのケースがよくある
    • 標準ライブラリなどでもあるらしい
[codepoint].pack('U')
[digest].pack('m0')
[mail_body].pack('M')
[ip_address].pack('N')
  • このような際にわざわざ Array にするのが手間なので Object#pack1 というメソッドを追加する提案
codepoint.pack('U')
digest.pack('m0')
mail_body.pack('M')
ip_address.pack('N')
  • Object に生やすのではなくて String.pack1(format, arg) を追加するのはどうか、みたいなコメントとかもあります
  • ちなみに Array#pack は指定されたテンプレートに沿って文字列に変換するメソッド
# コードポイントから UTF-8 の文字列に変換する
pp [12354].pack("U*")
# => "あ"

[Bug #18572] Performance regression when invoking refined methods

  • Ruby 2.7 と比較して Refinement で定義したメソッドの呼び出しが遅くなっているというバグ報告
    • using していなくても遅くなっている
  • 以下、検証結果
require "benchmark_driver"

source = <<~RUBY
class Foo
  def original
  end
  def refined
  end
end
module FooRefinements
  refine Foo do
    def refined
      raise "never called"
    end
  end
end
FOO = Foo.new
RUBY

Benchmark.driver do |x|
  x.prelude %Q{
    #{source}
  }
  x.report "no-op original", %{ FOO.original }
  x.report "no-op refined", %{ FOO.refined }
end
...
Comparison:
      no-op original:  54831732.8 i/s
       no-op refined:  28231384.4 i/s - 1.94x  slower
...
Comparison:
       no-op refined:  57847396.8 i/s
      no-op original:  56289619.5 i/s - 1.03x  slower

[Bug #18569] RubyVM::InstructionSequence#disasm returns nil for composed functions

  • Proc#>> の結果を RubyVM::InstructionSequence#disasm に渡すと nil が返ってくるというバグ報告
# Proc オブジェクト渡すとそのブロックの命令が返ってくる
first_proc = proc { |x| x + 2 }
puts RubyVM::InstructionSequence.disasm(first_proc)
# =>
# == disasm: #<ISeq:block in <main>@disasm.rb:3 (3,18)-(3,31)> (catch: FALSE)
# local table (size: 1, argc: 1 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
# [ 1] x@0<Arg>
# 0000 getlocal_WC_0                          x@0                       (   3)[LiBc]
# 0002 putobject                              2
# 0004 opt_plus                               <calldata!mid:+, argc:1, ARGS_SIMPLE>[CcCr]
# 0006 leave                                  [Br]
# -------------------------------------

# Proc#>> の結果を .disasm に渡すと nil が返ってくる
pp RubyVM::InstructionSequence.disasm(first_proc >> first_proc)
# => nil
  • これは Proc#>> の結果が命令シーケンスを持っていないからが理由とのこと
  • 命令シーケンスが必要な場合は自分で Proc オブジェクトを生成する必要がある
first_proc = proc { |x| x + 2 }
# これだと OK
puts RubyVM::InstructionSequence.disasm(proc { |x| first_proc.call(x) + 2 })
# =>
# == disasm: #<ISeq:block in <main>@disasm.rb:3 (3,18)-(3,31)> (catch: FALSE)
# local table (size: 1, argc: 1 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
# [ 1] x@0<Arg>
# 0000 getlocal_WC_1                          first_proc@0              (   2)[LiBc]
# 0002 getlocal_WC_0                          x@0
# 0004 opt_send_without_block                 <calldata!mid:call, argc:1, ARGS_SIMPLE>
# 0006 putobject                              2
# 0008 opt_plus                               <calldata!mid:+, argc:1, ARGS_SIMPLE>[CcCr]
# 0010 leave                                  [Br]

[Bug #18578] Hash#shift を繰り返していると ruby が無応答になる。

  • 以下のように Hash#shift を繰り返し呼び出すと Ruby の実行が止まってしまうことがるというバグ報告
H = {}
100.times{|n|
    while H.size < n
        k = Random.rand 0..1<<30
        H[k] = 1 # たぶんここで止まる。  
    end
    warn "size: #{H.size} before shifting."
    0 while H.shift
    warn "empty?: #{H.empty?}"
}
warn :exit
  • このバグは Ruby 2.5 から発生している
  • Hash が空になった後に Hash#shift を呼び出すと意図しない挙動になっていたぽい
  • このバグは既に修正済み

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

今週は String 周りでメソッドを追加するチケットの話などがありました。

[Feature #18564] Add Exception#detailed_message

class MyClass < StandardError
  def message = "my error!"
  def detailed_message(highlight: false, **opt)
    super + "\nThis is\nan additional\nmessage"
  end
end

raise MyClass
$ ./ruby test.rb
test.rb:8:in `<main>': my error! (MyClass)
This is
an additional
message
  • Exception#detailed_message(highlight: false) は内部で Exception#message を呼び出し highlight: true だとエスケープシーケンスでハイライトされる
e = RuntimeError.new("my error!")
p e.detailed_message                  #=> "my error! (RuntimeError)"
p e.detailed_message(highlight: true) #=> "\e[1mmy error! (\e[1;4mRuntimeError\e[m\e[1m)\e[m"

[Feature #13110] Byte-based operations for String

  • String にバイトベースの操作をするメソッドを追加するチケット
s = "あああいいいあああ"
p s.byteindex(/ああ/, 4) #=> 18
x, y = Regexp.last_match.byteoffset(0) #=> [18, 24]
s.bytesplice(x...y, "おおお")
p s #=> "あああいいいおおおあ"
lexington:ruby$ cat bench.rb
require "benchmark"

s = File.read("README.ja.md") * 10

Benchmark.bmbm do |x|
  x.report("index") do
    pos = 0
    n = 0
    loop {
      break unless s.index(/\p{Han}/, pos)
      n += 1
      _, pos = Regexp.last_match.offset(0)
    }
  end
  x.report("byteindex") do
    pos = 0
    n = 0
    loop {
      break unless s.byteindex(/\p{Han}/, pos)
      n += 1
      _, pos = Regexp.last_match.byteoffset(0)
    }
  end
end
lexington:ruby$ ./ruby bench.rb
Rehearsal ---------------------------------------------
index       1.060000   0.010000   1.070000 (  1.116932)
byteindex   0.000000   0.010000   0.010000 (  0.004501)
------------------------------------ total: 1.080000sec

                user     system      total        real
index       1.050000   0.000000   1.050000 (  1.080099)
byteindex   0.000000   0.000000   0.000000 (  0.003814)

[Feature #18563] Add "graphemes" and "each_grapheme aliases

  • String#each_grapheme_cluster メソッドのエイリアスとして #graphemes#each_grapheme を追加するチケット
  • 他の言語でも graphemes という名前になっている事が多いらしい

    • JavaScript/TypeScript grapheme-splitter library: splitGraphemes
    • PHP: grapheme_extract
    • Zig ziglyph library: GraphemeIterator
    • Golang uniseg library: NewGraphemes
    • Matlab: splitGraphemes
    • Python grapheme library: grapheme
    • Elixir: graphemes
    • Crystal uni_text_seg library: graphemes
    • Nim nim-graphemes library: graphemes
    • Rust unicode-segmentation library: graphemes
  • 以下、matz のコメント

https://bugs.ruby-lang.org/issues/13780#note-10

grapheme sounds like an element in the grapheme cluster. How about each_grapheme_cluster? If everyone gets used to the grapheme as an alias of grapheme cluster, we'd love to add an alias each_grapheme.

Matz.

[Bug #11064] #singleton_methods for objects with special singleton_class returns an empty array

  • 以下のように nil に特異メソッドを追加すると nil.singleton_methods には追加されてないように見えるというバグ報告
# nil に特異メソッドを追加する
def nil.bla
  42
end

# 以下は動作してるが
nil.bla #=> 42
nil.singleton_method(:bla) #=> #<Method: NilClass#bla>

# 以下は動作していない
nil.singleton_methods #=> []
  • これは nil#singleton_classNilClass を返しているため
    • nil には特異クラスは存在しておらず NilClass がその役割を果たしている
# NilClass を返す
p nil.singleton_class
# => NilClass

# なので nil の特異メソッドは NilClass のインスタンスメソッドとして定義される
def nil.bla
end
p NilClass.instance_methods.include? :bla
# => true

Ruby 3.1 でオブジェクトが生成された箇所が表示できるようになった

Ruby 3.1 の小ネタです。
Ruby 3.1 で objspace/trace というライブラリが追加されました。
このライブラリを require すると p で出力するときに『オブジェクトが生成された箇所』が一緒に表示されるようになります。

# このライブラリを require するとオブジェクトが生成された場所がトレースされるようになる
# require した後に生成されたオブジェクトが対象となる
require "objspace/trace"

obj = Object.new

# p する時に obj が生成された場所が表示される
p obj
# => #<Object:0x00007fce8b2ea778> @ /path/to/test.rb:5

# こういうのも表示される
str = 42.to_s
p str
# => "42" @ /path/to/test.rb:11


# クラス内で生成されたオブジェクトも取得できる
class X
  attr_accessor :value

  def initialize
    @value = "hoge"
  end

  def hoge
    self.value = "bar"
  end
end

x = X.new

p x.value
# => "hoge" @ /path/to/test.rb:22

x.hoge
p x.value
# => "hoge" @ /path/to/test.rb:26

こんな感じで各オブジェクトが生成された場所が生成されます。
また objspace/trace を使っている場合は objspace/trace is enabled が表示されます。

注意点

objspace/trace はあくまでも『オブジェクトが生成された場所』が表示されるのであって『変数が定義された場所ではない』という注意点があります。
なので、例えば次のように定数を変数に代入している場合は『定数が定義された位置』が表示されるようになります。

require "objspace/trace"

C = Object.new

obj = C

# これは obj 変数が定義された場所ではなくて C が定義された場所が出力される
p obj
# => #<Object:0x00007f6e1111e438> @ /path/to/test.rb:3

他には数値や nil などの値も表示されなかったり

require "objspace/trace"

obj = 1 + 2

p obj

obj2 = "42".to_i
p obj2

obj3 = nil
p obj3

# frozen_string_literal: true している場合は文字列リテラルの生成位置も表示されません。

require "objspace/trace"

str = "homu"

# これは表示される
p str
# => "homu" @ /path/to/test.rb:3
require "objspace/trace"

str = "homu"

# これは表示される
p str
# => "homu" @ /path/to/test.rb:3
# frozen_string_literal: true

require "objspace/trace"

str = "homu"

# これは表示されない
p str
# => "homu"

# リテラル以外で生成された文字列は表示される
str2 = str + str
p str2
# => "homuhomu" @ /path/to/test.rb:12

おまけ

メタプロ的に定義されている場合でも問題なく取得できた。

require "objspace/trace"

# 動的に生成している場合でも位置情報は取得できる
eval(<<~EOS, binding, __FILE__, __LINE__ + 1)
@value = "hoge"
def  hoge
  @value
end
EOS

p hoge
# => "hoge" @ /path/to/test.rb:5

# 動的に変数を定義した場合も取得できる
bind = binding
bind.local_variable_set(:value, "mami")

p bind.local_variable_get(:value)
# => "mami" @ /path/to/test.rb:14