Ruby 3.4
- Released at: Dec 25, 2024 (NEWS.md file)
- Status (as of Jan 06, 2025): 3.4.1 is recently released (immediately after 3.4.0)
- This document first published: Dec 25, 2023
- Last change to this document: Jan 06, 2025
🇺🇦 🇺🇦 Before you start reading the changelog: A full-scale Russian invasion into my home country continues, for the second year in a row. The only reason I am alive and able to work on the changelog is Armed Forces of Ukraine, and international support with weaponry, funds and information. I got into the Army in March, and spent the summer on the frontlines. Now I have moved to another army position which frees some time to work for the Ruby community. Here is my recent post about making the changelog (and links to some Ukrainian fundraisers).🇺🇦 🇺🇦
Note: As already explained in Introduction, this site is dedicated to changes in the language, not the implementation, therefore the list below lacks mentions of lots of important internal changes. The changes aren’t covered extensively not because they are not important, just because this site’s goals are different. See the official release notes that cover those significant internal changes. This year, though, changes are quite noticeable from the language user’s perspective, so some of them are briefly mentioned in a separate section.
Highlights
it
anonymous block argument**nil
unpacking- String literals would be frozen in Ruby 3.5
Range#step
iteration for any class via+
- Backtrace improvements
- Ractor improvements
- Serious implementation updates
Language changes
Standalone it
in blocks became anonymous argument
After a long discussion (and introduction of warning in Ruby 3.3), it
was added as a name for anonymous parameter in a block.
- Discussion: Feature #18980
- Documentation: Proc:
it
- Code:
[1, 2, 3].map { it**2 } #=> [1, 4, 9] # numbered params still work: [1, 2, 3].map { _1**2 } #=> [1, 4, 9] # the two aren't allowed to be mixed together: [1, 2, 3].map { _1 + it } # SyntaxError # ^~ `it` is not allowed when a numbered parameter is already used # it is a "soft" keyword, it is allowed to use as a name for variable or a method, # even inside a block: [1, 2, 3].each { x = it # referes to block argument it = x**2 # assigns local variable p it # now refers to a local variable } # prints # 1 # 4 # 9 # But if `it` is available as a local variable in the same scope, # it takes precedence: it = 5 [1, 2, 3].map { it**2 } #=> [25, 25, 25] # If `it` is defined as a method, anonymous parameter takes precedence: def it = 6 [1, 2, 3].map { it**2 } #=> [1, 4, 9] # unlike _1, `it` is allowed in nested blocks: [[1, 2], [3, 4]].each { it.each { p it } } # prints: # 1 # 2 # 3 # 4 # inner `it` refers to variable in the inner block # Wouldn't work this way with numbered parameters: [[1, 2], [3, 4]].each { _1.each { p _1 } } # prints # [1, 2] # [1, 2] # [3, 4] # [3, 4] # because inner _1 is the same as the outer one # `it` is represented in .parameters this way: proc { it**2 }.parameters #=> [[:opt, nil]] lambda { it**2 }.parameters #=> [[:req]]
- Notes:
- A lot of effort was put into backward compatibility of the new syntax:
it
still works as a local variable and method name, so there is very small chance for old code being broken. See feature documentation for more details and examples. - There is still a bit some rough edges around
it
inspectability, discussed here (binding.local_variables
) and here (Proc#parameters
, as shown above), as well as parsing logic in some edge cases (discussed here):proc { it + 1 }.call(3) #=> 4 proc { it +1 }.call(3) # undefined method 'it' for main (NoMethodError) # Because without space in `+1` it is parsed as `it(+1)` # Even though it is resolved correctly with real local variables: it = 3 it +1 #=> 4
- A lot of effort was put into backward compatibility of the new syntax:
**nil
unpacks into empty keyword arguments
- Reason: There is a frequently useful argument where positional arguments are provided conditionally by
method(*(some_value if some_condition))
. Before this change, it was not possible to do the same with keyword arguments, so the additional convenience was introduced. - Discussion: Bug #20064
- Documentation: Calling Methods: Unpacking Keyword Arguments
- Code:
def handle_options(**kwargs) = p kwargs handle_options(**nil) # Ruby 3.3: no implicit conversion of nil into Hash (TypeError) # Ruby 3.4: prints {} # Practical usage: we have conditional options passing: # Ruby 3.3: that's the shortest we can do: handle_options(**(some_condition? ? extra_options : {})) # Ruby 3.4: `nil` is unpacked as no keyword arguments handle_options(**(extra_options if some_condition?))
- Notes: There is an convention that
**value
invokesvalue.to_hash
(explicit hash conversion method) while trying to unpack the value. This is not how it was implemented in this case:nil
doesn’t implement#to_hash
, and**nil
doesn’t produce empty hash, just treated by Ruby as passing no keyword arguments. This allows to avoid unnecessary empty hash allocation.
Keyword and block arguments are prohibited in #[]=
- Reason:
- A possibility to use keyword arguments while calling
[]=
(e.g.,my_matrix[5, axis: :y] = value
) leads to wrong expectations: Ruby have always parsed it as a positional hash, because the last argument to[]=
should be the value set, and it is positional. Prohibiting the misleading syntax allows to avoid false expectations. - For blocks, there was no valid syntax to call
[]=
with a literal block, but passing&value
was allowed, leading to confusing precedence and, in general, providing only implementation complications to no gain.
- A possibility to use keyword arguments while calling
- Discussion: Bug #19918 (block argument), Bug #20218 (keyword argument)
- Documentation: —
- Code:
class MyMatrix # ...some implementation def []=(*args, **kwargs) p(args:, kwargs:) # ...some implementation end end matrix = MyMatrix.new matrix[5, axis: :y] = 8 # Ruby 3.3: prints # {:args=>[5, {:axis=>:y}, 8], :kwargs=>{}} # Unlike somebody's expectation, it does NOT put `axis:` into keyword arguments # # Ruby 3.4: keyword arg given in index assignment (SyntaxError) # If you meant to have it as a positional hash argument, it should be wrapped: matrix[5, {axis: :y}] = 8 # {args: [5, {axis: :y}, 8], kwargs: {}} # With blocks, one could previously do something like: class MyCollection # ...some implementation def []=(index, value, &block) p "setting at #{index} to #{block.call(value)}" # ...or some real implementation end end # There is no valid syntax to provide this block: col = MyCollection.new col[1] { _1.floor } = 2.5 # unexpected write target (SyntaxError) # But this was possible: col[1, &:floor] = 2.5 # Ruby 3.3: prints "setting at 1 to 2" # Ruby 3.4: unexpected block arg given in index assignment; blocks are not allowed in index assignment expressions (SyntaxError)
- Notes:
#[]=
method still can be defined to accept keyword arguments (it doesn’t raise an error). Such methods even can be called by usingsend
, though there is no real utility in doing so:matrix.public_send(:[]=, 5, 8, axis: :y) # {args: [5, 8], kwargs: {axis: :y}}
- During the discussion, it was noted that syntax like
matrix[5, axis: :y] = 8
might be useful, and there is a possible alternative: Instead of prohibiting “keyword-looking” arguments, Ruby could have a convention that the[]=
method is allowed to have this signature:def []=(*args, value, **kwargs) # ... end matrix[5, axis: :y] = 8 # ^ ^^^^^^^^ ^ # | kwargs | # args value
Such “inversion” (when keyword arguments are moved after the actually positional
value
) was considered too complicated to implement and document so far, but it might be rethought in the future, if requested by language users. - Block and keyword arguments are still allowed in
#[]
, as they create no confusion:class Matrix def [](*a, **kwa, &b) p [a, kwa, b] end end m = Matrix.new m[5, axis: :y] { p "fetching the value" } #=> [[5], {axis: :y}, #<Proc:0x00...>] # ^ keywords block # positional
Warnings about unused blocks
There is now a warning (opt-in, enabled by a special warning category :strict_unused_block
) when the block is passed to a method that doesn’t expect it.
- Reason: As any method can implicitly accept block parameter, it might create hard to detect bugs, when the block is passed to a method that stopped accepting it after an API update, or to one that never accepted it in the first place (like using
Time.freeze { some tests }
instead of specialized test libraryTimecop.freeze { some tests }
). The warning allows to indicate such cases. - Discussion: Feature #15554
- Documentation: —
- Code:
Warning[:strict_unused_block] = true def yields yield end def accepts(&block) end def conditional yield if Time.now.tuesday? end def checks p block_given? end def ignores end yields { } # no warning accepts { } # no warning, even if the block is just put in the variable conditional { } # no warning, even if it doesn't use the block on some of the code paths checks { } # test.rb:19: warning: the block passed to 'Object#checks' defined at test.rb:10 may be ignored ignores { } # test.rb:20: warning: the block passed to 'Object#ignores' defined at test.rb:14 may be ignored
- Notes:
- Note there is no warning for
accept
method above: it isn’t necessary even to use the block, accepting explicitly is enough. This is because the feature’s primary intention is to avoid accidental passing blocks to methods whose author doesn’t even considered this possibility; while accepting the block explicitly and not calling it (at least in some code paths) means that the method’s author considered it. There is still a warning though if the method checks whether the block is given yet never uses it (seechecks
method). - There was alternative/complementary idea discussed: to allow declare “this method doesn’t accept blocks” explicitly with
&nil
(akin to**nil
for “doesn’t accept keyword arguments”). So far, it was not accepted: it might be useful to specify on API changes (when some method that previously accepted blocks stopped doing so), but would hardly be useful in general, as it is hard to guess which methods should be marked, and too ugly to mark all methods that doesn’t accept blocks.
- Note there is no warning for
Warning to prepare for freezing string literals in Ruby 3.5
String created as string literals in Ruby 3.4 emit deprecation warnings when modified; this will probably become an error in Ruby 3.5. In developer discussions, such strings—not frozen, but emitting a warning—are frequently called “chilled” strings, though it is not an official term. Symbol#to_s
also returns a “chilled” string.
- Reason:
- Discussion: Feature #20205, Feature #20350 (“chilled”
Symbol#to_s
) - Documentation: —
- Code:
Warning[:deprecated] = true # or -W:deprecated when running ruby buf = "" buf << "test" # warning: literal string will be frozen in the future buf = +"" # this makes a string "not chilled" buf << "test" # no warning # any operation on chilled literals produce not chilled one: ("" + "") << "test" # no warning # string interpolation also produce non-chilled strings: "#{1}" << "test" # no warning # Besides literals, chilled string also returned from Symbol#to_s s = :foo.to_s s << "_sym" # warning: string returned by :foo.to_s will be frozen in the future
- Notes:
- The warning might be suppressed (even when the “deprecated” warnings category is enabled with an command-line option
--disable-frozen-string-literal
. - The full warning (not shown in examples for clarity) is this: “warning: literal string will be frozen in the future (run with
--debug-frozen-string-literal
for more information)”. When the suggested command-line option is used, there is an additional warning output, allowing to indicate where the literal is coming from, like this:Warning[:deprecated] = true # or -W:deprecated when running ruby buf = "" # line 3 buf << "test" # line 4 # test.rb:4: warning: literal string will be frozen in the future # test.rb:3: info: the string was created here
- There is no straightforward way to “chill” the string manually (though
string.to_sym.to_s
can be used to achieve this) or check if the string is chilled. - The discussion is still ongoing about the overall influence of the freezing of the string literals on the ecosystem. So it is not set in stone yet that it will happen in 3.5.
- The warning might be suppressed (even when the “deprecated” warnings category is enabled with an command-line option
Reserved module ::Ruby
The top-level constant Ruby
is reserved for future use by the language and emits warning when redefined.
- Reason: It was noted that there are many constants with name like
RUBY_SOMETHING
, and “auxiliary” classes likeThread::Backtrace::Location
(that aren’t really related to thread, but provide the call stack locations as structured objects), and there are no common grouping module for them in an implementation-independent way (there is ::RubyVM module, but it is specifically documented as a place to define implementation-specific constants and modules). - Discussion: Feature #20884
- Documentation: —
- Code:
Ruby # uninitialized constant Ruby (NameError) -- it is not yet defined Warning[:deprecated] = true # or -W:deprecated when running ruby Ruby = 1 # warning: ::Ruby is reserved for Ruby 3.5
- Notes: There is still no clear vision of what exactly would be put in the module (several proposals were discussed and rejected), so it is not necessary that the module would be introduced in the Ruby 3.5.
Removals
Refinement#refined_class
which was introduced in 3.2 and deprecated in favor of#target
in 3.3, is removed.
Core classes and modules
Object#singleton_method
: includes methods from prepended/included modules
- Reason: Just fixing an old inconsistency.
- Discussion: Bug #20620
- Documentation:
Object#singleton_method
- Code:
- Notes:
module Extensions def foo; end end o = Object.new def o.bar; end o.extend Extensions p o.singleton_methods # both methods are listed #=> [:bar, :foo] p o.singleton_method(:bar) # method defined on the object directly is returned #=> #<Method: #<Object:0x00...>.bar()> p o.singleton_method(:foo) # included method was not available before # Ruby 3.3: undefined singleton method `foo' for `#<Object:0x00007f5bcc79da68>' (NameError) # Ruby 3.4: #<Method: Object(Extensions)#foo()> # A more practical example: # an idiom for a module that can be included somewhere or used on itself module Utils def format(...); end extend self # makes all methods available as Utils.format end Utils.singleton_methods #=> [:format] Utils.singleton_method(:format) # Ruby 3.3: undefined singleton method `format' for `Utils' (NameError) # Ruby 3.4: #<Method: #<Class:Utils>(Utils)#format(...)>
Float parsing changes
Methods Float(str)
and str.to_f
now allow floats without digits after the decimal point, e.g., "1."
or "1.E-3"
.
- Reason: Initially, it was proposed to change which float formats Ruby supports in general (akin to many other languages that support
1.
as a literal). It was considered impossible because the dot after number might mean a method call; but it was decided that enhancing float-to-string parsing is acceptable. - Discussion: Feature #20705
- Documentation:
Kernel#Float
,String#to_f
- Code:
Float('1.') # Ruby 3.3: in `Float': invalid value for Float(): "1." (ArgumentError) # Ruby 3.4: => 1.0 Float('1.E2') # Ruby 3.3: ArgumentError # Ruby 3.4: => 100.0 '1.'.to_f # 3.3&3.4: 1.0 -- Ruby 3.3 just ignored "." '1.E2'.to_f # Ruby 3.3: 1.0 -- Ruby 3.3 ignores ".E2" # Ruby 3.4: 100.0
- Notes: The change might be subtly breaking in some code, if the input data contains text like
"1.e2"
which previously wasn’t parsed as an exponential notation of a float (as shown above, the result of parsing for such strings have changed). But the probability to encounter such incompatibility is low.
String#append_as_bytes
An efficient way to modify the string by appending the argument, treating it as bytes.
- Reason: The feature is useful while working on binary protocols: either constructing the binary message from bytes and strings in various encoding; or constructing a string receiving it byte-by-byte from some stream/binary format.
- Discussion: Feature #20594
- Documentation:
String#append_as_bytes
- Code:
buf = "msg@>".b content = "Україна" # Without append_as_bytes: buf.encoding #=> #<Encoding:BINARY (ASCII-8BIT)> buf << content buf.encoding #=> #<Encoding:UTF-8> -- implicitly converted the buffer # When there are unconvertable bytes (markers in binary protocol), # this wouldn't work at all: "msg@>".b << 255 << content # incompatible character encodings: BINARY (ASCII-8BIT) and UTF-8 (Encoding::CompatibilityError) # With append_as_bytes buf = "msg@>".b.append_as_bytes(255).append_as_bytes(content) #=> "msg@>\xFF\xD0\xA3\xD0\xBA\xD1\x80\xD0\xB0\xD1\x97\xD0\xBD\xD0\xB0" buf.encoding #=> #<Encoding:BINARY (ASCII-8BIT)> # Behavior details: # append_as_bytes never changes the encoding even if the content is # not valid in a given encoding. # Say, byte-by-byte building of string from some stream: buf = +"У" # UTF-8 string buf.append_as_bytes("\xD0") #=> "У\xD0" -- not valid UTF-8 content buf.valid_encoding? #=> false buf.append_as_bytes("\xBA") #=> "Ук" buf.valid_encoding? #=> true again # With integer argument, uses only lower byte of the integer as a byte to append buf.append_as_bytes(32) "Ук " buf.append_as_bytes(289) # two bytes: 0x01_21, only the lower (0x21) is appended "Ук !" # No argument conversions is performed for other types than String or Integer buf.append_as_bytes(:test) # wrong argument type Symbol (expected String or Integer) (TypeError) buf.append_as_bytes(1r) # wrong argument type Rational (expected String or Integer) (TypeError)
MatchData#bytebegin
and #byteend
have been added
Singular begin/end values of match group offsets to complement #byteoffset
introduced in Ruby 3.2.
- Discussion: Feature #20576
- Documentation:
MatchData#bytebegin
,MatchData#byteend
- Code:
str = 'Слава Україні' match = str.match(/Слава\s+(?<name>.+)/) match.begin(1) #=> 6 match.bytebegin(1) #=> 11 match.end(1) #=> 13 match.byteend(1) #=> 25 match.bytebegin(:name) #=> 11 match.byteend(:name) #=> 25
- Notes: The method names are spelled this way (and, not, say
byte_begin
) following the traditions of methods likeString#bytesplice
,MatchData#byteoffset
, and such.
Time#xmlschema
( #iso8601
) in core
The methods were moved from time
standard library to the core of the Time
class and rewritten in C.
- Reason: Those methods are extremely frequently used in serialization, and therefore considering them core, and rewriting in C, allows to better optimize and improve developer experience.
- Discussion: Feature #20707
- Documentation:
Time#xmlschema
,Time#iso8601
- Code:
# no `require "time"` necessary Time.now.xmlschema #=> "2024-12-15T20:19:36+02:00" Time.now.xmlschema(3) #=> "2024-12-15T20:19:36.113+02:00" -- argument is number of digits Time.now.iso8601 #=> "2024-12-15T20:19:36+02:00" -- just an alias
- Notes:
- Unfortunately the documentation still refers to
time
standard library, even though the method is now defined in C; this is just a documentation rendering problem. Time.xmlschema
(symmetrical parsing method) is confusingly still defined intime
standard library, in Ruby.
- Unfortunately the documentation still refers to
Range
#size
raises TypeError
if the range is not iterable
I.e., if the range’s begin
doesn’t respond to #succ
—which means it can’t be iterated with #each
and related methods, and therefore doesn’t have a sense as a “sequence of values.”
- Reason: The clarification of the behavior was a consequence of fixing a bug: before the change, calculating the size (by subtracting ends) have happened for any numerics, including floats. It produced confusing results. Instead of just returning
nil
, a new behavior was introduced. - Discussion: Misc #18984
- Documentation:
Range#size
- Code:
(1..5).size # 3.3 & 3.4: => 5 ('a'..'z').size # a sequence, but can't calculate size efficiently # 3.3 & 3.4: => nil (Time.now..Time.now + 1).size # not a sequence # Ruby 3.3: => nil # Ruby 3.4: in 'Range#size': can't iterate from Time (TypeError) # The bug that initialized the discussion: (0.5..1.3).size # Ruby 3.3: => 1 -- doesn't make much sense, it is neither the number of steps in range, # nor an arithmetic distance between 1.3 and 0.5 # Ruby 3.4: 'Range#size': can't iterate from Float (TypeError)
- Notes:
- Note that
Range#size
still returnsnil
for ranges that can be iterated, but their size can’t be calculated efficiently, like string ones.Range#size
is calculated only for integer ranges (by subtractingbegin
fromend
). To calculate count of items in any iterable range (by traversing the range), useRange#count
:('a'..'z').count #=> 26 (Time.now..Time.now + 1).count # in 'Range#each': can't iterate from Time (TypeError) # ...which is now consistent with #size
- One slightly confusing consequence is behavior change for semi-open Integer ranges. The discussion of whether it is intentional is here:
(3..).size #=> Infinity (..3).size # Ruby 3.3: Infinity # Ruby 3.4: can't iterate from NilClass (TypeError) -- maybe somewhat worse
- Note that
#step
: iterating by using +
operator for all types
For a long time, there was a behavioral inconsistency in ranges: with numbers, the step
argument meant “what to add on each step”, while for all other types, it was expected to be integer, and meant “how many steps to iterate using #succ
method.” Now steps are consistently iterated by using begin + step
, which allows to go through ranges of various types like types, units, mathematical vectors and so on.
- Discussion: Feature #18368
- Documentation:
Range#step
- Code:
# Always worked with numbers: (1..2).step(0.2).to_a #=> [1.0, 1.2, 1.4, 1.6, 1.8, 2.0] # But with other types it expected `step` argument to be integer: ('a'..'e').step(2).to_a #=> ["a", "c", "e"] # Now it also works with +, allowing, say, this: require 'time' (Time.parse('2024-12-01')..Time.parse('2024-12-03')).step(6*60*60).to_a # Or, with ActiveSupport: (Time.parse('2024-12-01')..Time.parse('2024-12-03')).step(6.hours).to_a # Ruby 3.3: in `step': can't iterate from Time (TypeError) # Ruby 3.4: => [2024-12-01 00:00:00 +0200, 2024-12-01 06:00:00 +0200, 2024-12-01 12:00:00 +0200, 2024-12-01 18:00:00 +0200, 2024-12-02 00:00:00 +0200] # For backward compatibility, String supports both types of iteration: ('a'..'e').step(2).to_a # Ruby 3.3&3.4: => ["a", "c", "e"] -- old behavior preserved ('='..'=====').step('=').to_a # Ruby 3.3: in `step': no implicit conversion of String into Integer (TypeError) # Ruby 3.4: => ["=", "==", "===", "====", "====="]
Array#fetch_values
A method that behaves like a mix between Array#values_at
(allows to get several values at once) and Array#fetch
(raises when the index is absent, or uses the provided default value), akin to Hash#fetch_values
.
- Discussion: Feature #20702
- Documentation:
Array#fetch_values
- Code:
ary = %w[a b c] ary.fetch_values(1, 0, 1, 2) #=> ["b", "a", "b", "c"] -- values are corresponding to indexes ary.fetch_values(1, 9, 5) # in 'Array#fetch': index 5 outside of array bounds: -3...3 (IndexError) ary.fetch_values(1, 9, 5) { "default for #{it}" } #=> ["b", "default for 5", "default for 9"]
- Notes: Unlike Array#values_at, the method only allows integer indexes (not ranges). The discussion of whether it should be adjusted is ongoing:
ary = %w[a b c d] ary.values_at(1, 2..5) #=> ["b", "c", "d", nil, nil] -- two last values don't exist # But so far #fetch_values doesn't allow to replace them with defaults # in the same protocol: ary.fetch_values(1, 2..5) { '<default>' } # 'Array#fetch': no implicit conversion of Range into Integer (TypeError)
Hash
.new
accepts an optional capacity:
argument
Like String.new(capacity:)
and Array.new(capacity)
, allows to pre-allocate hash if it is expected to be of some large size (and its order of magnitude is known).
- Discussion: Feature #19236
- Documentation:
Hash.new
- Code:
h = Hash.new(capacity: 1000) #=> {} -- just an empty hash, but internally preallocated for 1000 keys
- Notes: It is worth noticing that introduction of a keyword arguments in
Hash.new
signature changed the behavior with passing another hash (as a default value for an absent key):# Ruby 3.3: settings = Hash.new(value: '<default>') # treated as a positional Hash argument settings[:some_key] #=> {:value=>"default"} # It was already a warning though: Warning[:deprecated] = true # or -W:deprecated when running ruby settings = Hash.new(value: '<default>') # warning: Calling Hash.new with keyword arguments is deprecated and will be removed in Ruby 3.4; use Hash.new({ key: value }) instead # Ruby 3.4: settings = Hash.new(value: '<default>') # unknown keyword: :value (ArgumentError) # The correct way to pass the default value which is hash: settings = Hash.new({value: '<default>'}) # The reason for the change is easy to see if used together with `capacity:`: settings = Hash.new({value: '<default>'}, capacity: 1000) # ^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^ # default: a hash keyword arguments
#inspect
rendering have been changed
At long last, the #inspect
of Hash is adjusted to correspond to the modern syntax of hashes with symbol: value
. On the way, other (non-symbol) keys rendering was also slightly adjusted, adding the spaces around the =>
.
- Discussion: Bug #20433
- Documentation:
Hash#inspect
- Code:
p({x: 1, 'foo-bar': 2, "baz" => 3}) # Ruby 3.3: {:x=>1, :"foo-bar"=>2, "baz"=>3} # Ruby 3.4: {x: 1, "foo-bar": 2, "baz" => 3}
- Notes: While the change might be considered decorative (and therefore not significant, even if improving the DX), it should be considered by some codebases, because it might break some tests relying on particular Hash representation.
Backtrace-related changes
Exception #backtrace_locations
can be set programmatically
When raising exception with raise
or modifying it with Exception#set_backtrace
, it is now possible to pass an array of Thread::Backtrace::Location
objects to set both #backtrace
(string values) and #backtrace_locations
(structured values).
- Reason: Previously, the only option to customize exception’s backtrace (to exclude some library’s internals, or adjusting some DSL’s logical error place to where it is invoked, not where it is implemented), was to pass the array of strings to
Kernel#raise
orException#set_backtrace
. In this case, onlyException#backtrace
(which is also represented by array of strings) would’ve been set, whileException#backtrace_locations
(structured location information) was impossible to set programmatically. - Discussion: Feature #13557
- Documentation: Exceptions: Backtraces,
Exception#set_backtrace
,Kernel#raise
,Thread#raise
,Fiber#raise
(the latter two methods are also affected, but their docs just refer toKernel#raise
) - Code:
def raising_method # raise with the current stack with the last location ignored: raise RuntimeError, "Modified error", caller_locations(1) end def wrapper_method raising_method end begin wrapper_method rescue => ex # Both backtrace and backtrace_locations are adjusted, hiding the #raising_method p ex.backtrace #=> ["test.rb:6:in 'Object#wrapper_method'", "test.rb:10:in '<main>'"] p ex.backtrace_locations #=> ["test.rb:6:in 'Object#wrapper_method'", "test.rb:10:in '<main>'"] # backtrace_locations is structured object p ex.backtrace_locations.first.then { [it.path, it.lineno, it.label] } #=> ["test.rb", 6, "Object#wrapper_method"] ex.set_backtrace(ex.backtrace_locations[1..]) raise end # Fails with an error that just says the last location: # test.rb:10:in '<main>': Modified error (RuntimeError)
- Notes:
- I wouldn’t be documenting all the nuances of
#backtrace_locations
setting, they are described in the official documentation exhaustively. - Note that
Thread::Backtrace::Location
objects can’t be constructed from scratch, and only can be obtained fromKernel#caller_locations
orException#backtrace_locations
of an existing error. In the case when you need to provide a completely “fake” backtrace which can’t be obtained from the current stack (which in most cases is not a very good idea), you only can pass an array of strings to raise, but this sets only#backtrace
:begin raise RuntimeError, "Sneaky", %w[dsl.rb:1] rescue => ex p ex.backtrace #=> ["dsl.rb:1"] p ex.backtrace_locations #=> nil end
- I wouldn’t be documenting all the nuances of
Backtrace formatting adjustments
1) Backtick in the backtrace formatting was replaced with singular quote sign. 2) Module/class name was added to method names in backtace locations.
- Reason: Using backtick in the terminal rendering has a long tradition, but in the age of Markdown-like markups, it brings a lot of pain when copying error messages and call stacks into various input fields, so it was finally changed. Adding of the module owning the method is just a nice idea that doesn’t require further explanations.
- Discussion: Feature #16495 (backtick replacement), Feature #19117 (class name)
- Affected classes and methods:
Exception#backtrace
,Kernel#caller
(return arrays of strings),Thread::Backtrace::Location
class (returned byException#backtrace_locations
andKernel#caller_locations
) - Code:
class Calculator def divide(num, denom) num / denom end end c = Calculator.new c.divide(1, 0) # Ruby 3.3: backtick before the method name, no module names # test.rb:3:in `/': divided by 0 (ZeroDivisionError) # from test.rb:3:in `divide' # from test.rb:8:in `<main>' # Ruby 3.4: method name wrapped in singular quotes, module names present # test.rb:3:in 'Integer#/': divided by 0 (ZeroDivisionError) # from test.rb:3:in 'Calculator#divide' # from test.rb:8:in '<main>' # The module name is of that defining the method, # not the one that the method belonged to at the time of calling: module Multiplication def multiply(a, b) = a * b end class BetterCalculator < Calculator include Multiplication end c = BetterCalculator.new c.divide(1, 0) # test.rb:3:in 'Integer#/': divided by 0 (ZeroDivisionError) # from test.rb:3:in 'Calculator#divide' -- not BetterCalculator#divide c.multiply(1, '2') # test.rb:8:in 'Integer#*': String can't be coerced into Integer (TypeError) # from test.rb:8:in 'Multiplication#multiply' -- not BetterCaclulator#multiply # Unnamed classes/modules aren't mentioned in backtrace: c = Class.new do def foo = 1 / 0 end o = c.new p o.method(:foo) #=> #<Method: #<Class:0x00007f6a2c698e88>#foo()...> o.foo # test.rb:2:in 'Integer#/': divided by 0 (ZeroDivisionError) # from test.rb:2:in 'foo' -- #<Class:0x0...> is not included # from test.rb:7:in '<main>' # #set_temporary_name doesn't affect backtrace either c.set_temporary_name('<Internal>') p o.method(:foo) #=> #<Method: <Internal>#foo()...> -- affects method representation o.foo # test.rb:2:in 'Integer#/': divided by 0 (ZeroDivisionError) # from test.rb:2:in 'foo' -- still isn't included in backtrace # from test.rb:11:in '<main>'
- Notes:
- The changes might break some tests that expect particular error messages, but it was considered a reasonable risk.
- Remember that all the methods defined on the top level are becoming private instance method of an
Object
class, and most of core “top-level” methods are actually methods ofKernel
module, this makes some backtraces more explainable:def my_method rand('test') end my_method # test.rb:2:in 'Kernel#rand': no implicit conversion of String into Integer (TypeError) # from test.rb:2:in 'Object#my_method' # from test.rb:5:in '<main>'
Extra rescue/ensure frames are removed from the backtrace
Before Ruby 3.4, there was an additional backtrace “frame” corresponding to the exception-handling block. It is removed for clarity.
- Discussion: Feature #20275
- Documentation: —
- Code:
begin 1 / 0 rescue raise "Something bad happened" end # Ruby 3.3: # test.rb:4:in `rescue in <main>': Something bad happened (RuntimeError) # from test.rb:1:in `<main>' -- line where `begin` is # Ruby 3.4: # test.rb:4:in `rescue in <main>': Something bad happened (RuntimeError)
Warning.categories
Returns the list of all warning categories that can be set by Warning[]=
or running Ruby with -W:<category name>
.
- Discussion: Feature #20293
- Documentation:
Warning.categories
- Code:
Warning.categories # => [:deprecated, :experimental, :performance, :strict_unused_block]
GC.config
A new method that returns two values so far, and allows to set one of them: :rgengc_allow_full_mark
—whether major GC cycle is disabled.
- Reason: The goal of the discussion that led to the
GC.config
introduction, was focused on an ability to disable “major” garbage collection, which might reclaim more memory, yet also might introduce significant slow-down. - Discussion: Feature #20443
- Documentation:
GC.config
- Code:
GC.config #=> {rgengc_allow_full_mark: true, implementation: "default"} GC.config(rgengc_allow_full_mark: false) #=> {rgengc_allow_full_mark: false} GC.config #=> {rgengc_allow_full_mark: false, implementation: "default"} # Unknown keys are ignored: GC.config(threads: 1) #=> {rgengc_allow_full_mark: false} # Attempt to set implementation: raises: GC.config(implementation: 'alternative') # in 'GC.config': Attempting to set read-only key "Implementation" (ArgumentError)
- Notes:
- The second key present in the config is related to the current garbage collector implementation, see Implemention changes section. It raises on attempt to be set.
- While discussing possible API for disabling the major GC, many options was considered (like
GC.disable_major
orGC.disable(major: true)
), but eventually generic and extensible pair of getter and setter.config
/.config(value)
was agreed upon. - See this Jemma Isroff’s article for a deep explanation about major/minor GC.
Fiber::Scheduler#blocking_operation_wait
Implements a generic API to offload CPU-bound blocking operation into a separate thread by Fiber scheduler.
- Discussion: Feature #20876
- Documentation:
Fiber::Scheduler#blocking_operation_wait
- Notes:
- See code examples in 3.0 changelog for general demo of using Fiber Scheduler. As no simple implementation is available, it is complicated to show an example of new hooks in play.
- Just to remind: Ruby does not include the default implementation of Fiber Scheduler, but the maintainer of the feature, Samuel Williams, provides one in his gem Async which is Ruby 3.4-compatible already.
Ractor
require
works in Ractors
require
now works in non-main Ractors. It delegates requiring to the main ractor and blocks while waiting for it.
- Reason: not being able to invoke
require
from Ractor was one of a major usability problem, as it meant that no library can be safely used from them: many librariesrequire
additional code during some calls. Even Ruby core does this: say,Kernel#pp
internally requirespp
library on the first call. - Discussion: Feature #20876
- Documentation: — (no new public methods added,
require
just works as usual in Ractors) - Code:
r = Ractor.new { require 'json' } r.take # Ruby 3.3: `require': can not access non-shareable objects in constant Kernel::RUBYGEMS_ACTIVATION_MONITOR by non-main ractor. (Ractor::IsolationError) # Ruby 3.4: successfully required r = Ractor.new { pp ["test"] } # Ruby 3.3: Ractor::IsolationError -- `pp` call implicitly does `require "pp"` on the first call # Ruby 3.4: prints ["test"]
- Notes: The technical implementation of the availability of
require
can be seen in ractor.rb: on the creation of the first Ractor in the program, an anonymous module prepended toKernel
, that wraps an existingrequire
definition into one that considers Ractors. It can be inspected in user code like this:p Kernel.instance_method(:require) #=> #<UnboundMethod: Kernel#require(path) <internal:.../rubygems/core_ext/kernel_require.rb>:36> Ractor.new { } p Kernel.instance_method(:require) # the implementation is updated #=> #<UnboundMethod: <RactorRequire>#require(feature) <internal:ractor>:908>
.main?
“Main” Ractor (where the initial program runs) is an important concept, as it is the one which has more possibilities than non-main ones, like setting instance variables of classes/modules. Now this concept is exposed to the user code.
- Discussion: Feature #20876
- Documentation:
Ractor.main?
- Code:
p Ractor.main? # prints true Ractor.new { p Ractor.main? }.take # prints false # Potential usage: class Configuration class << self attr_accessor :enabled end end def naive_flip Configuration.enabled = !Configuration.enabled end def ractor_aware_flip if Ractor.main? Configuration.enabled = !Configuration.enabled else warn "Can't be flipped from non-main Ractor" # ...or delegate it to a main ractor end end naive_flip # works Ractor.new { naive_flip }.take # 'Object#naive_flip': can not set instance variables of classes/modules by non-main Ractors (Ractor::IsolationError) Ractor.new { ractor_aware_flip }.take # warning: Can't be flipped from non-main Ractor # But no exception
.[]
and .[]=
A pair of methods provides a Ractor-local storage, akin to Fiber.[]
and .[]=
.
- Discussion: Feature #20715
- Documentation:
Ractor.[]
,Ractor.[]=
- Code:
p Ractor[:value] #=> nil Ractor[:value] = 10 p Ractor[:value] #=> 10 Ractor.new { p Ractor[:value] #=> nil -- the value store is per-ractor Ractor[:value] = 15 p Ractor[:value] #=> 15 }.take p Ractor[:value] #=> 10 # Strings and symbols are supported, being treated as the same key: Ractor['value'] #=> 10 # Any value that responds to #to_str explicit conversion method is supported: o = Object.new def o.to_str = 'value' Ractor[o] #=> 10 # Other values aren't supported: Ractor[1] # in 'Ractor.[]': 1 is not a symbol nor a string (TypeError)
- Notes: Like Fiber’s methods, and unlike
Thread#[]
and#[]=
, you only can access to the current Ractor’s storage.
.store_if_absent
In addition to the above method that were introduced for Ractor-local storage, the method store_if_absent(name) { block }
was introduced to initialize the value in an atomic thread-safe way.
- Reason: As Ractors are a concurrency primitive, there is a high chance of using them together with other concurrency primitives, like threads. In this case,
[]
and[]=
aren’t enough to safely initialize value, asRactor[key] ||= some_value
is actually two operations (fetch existing value, see if it isnil
, store a new value), which can be problematic when several threads do this concurrently. - Discussion: Feature #20702
- Documentation:
Ractor.store_if_absent
- Code:
def calculate puts "calculating in #{Ractor.current}" rand(10) end r1 = Ractor.new { 3.times.map { Ractor.store_if_absent(:value) { calculate } } } r2 = Ractor.new { 5.times.map { Ractor.store_if_absent(:value) { calculate } } } p r1.take p r2.take # This prints (numbers might differ obviously, but always the same in one ractor): # # calculating in #<Ractor:#2 test.rb:6 running> # calculating in #<Ractor:#3 test.rb:10 running> # [2, 2, 2] # [6, 6, 6, 6, 6]
- Notes:
RubyVM::AbstractSyntaxTree::Node#locations
For some nodes of AbstractSyntaxTree, several locations are returned, corresponding to various parts of the node.
- Discussion: Feature #20624
- Documentation:
RubyVM::AbstractSyntaxTree::Node#locations
,RubyVM::AbstractSyntaxTree::Location
- Code:
root = RubyVM::AbstractSyntaxTree.parse("a || b") root # top-level node is "scope" #=> (SCOPE@1:0-1:6 tbl: [] args: nil body: (OR@1:0-1:6 (VCALL@1:0-1:1 :a) (VCALL@1:5-1:6 :b))) or_op = root.children[2] # its second child is OR node # => (OR@1:0-1:6 (VCALL@1:0-1:1 :a) (VCALL@1:5-1:6 :b)) or_op.locations # => [#<RubyVM::AbstractSyntaxTree::Location:@1:0-1:6>, #<RubyVM::AbstractSyntaxTree::Location:@1:2-1:4>] # # a || b # ^^ -- second location (positions 2-4) # ^^^^^^ -- first location (positions 0-6, the whole node) # For other nodes, only one location, corresponding to the whole node, is returned: RubyVM::AbstractSyntaxTree.parse("a + b").children[2].locations # => [#<RubyVM::AbstractSyntaxTree::Location:@1:0-1:5>]
- Notes: TBH, the choice of which nodes provide additional locations, and to which parts of them, seem kinda arbitrary for me (changelog’s author). In the example above, it might be argued that in
a + b
case+
has its separate node (unlikea || b
), but this node is still represented by just a Symbol and doesn’t provide location information anyway. Which nodes are handled separately can be seen in the C code. On the other hand, with Prism becoming the default parser,RubyVM::AbstractSyntaxTree
kinda loses its importance: usingPrism.parse('code')
provides much more detailed and well-represented information.
Standard library
Since Ruby 3.1 release, most of the standard library is extracted to either default or bundled gems; their development happens in separate repositories, and changelogs are either maintained there, or absent altogether. Either way, their changes aren’t mentioned in the combined Ruby changelog, and I’ll not be trying to follow all of them.
stdgems.org project has a nice explanations of default and bundled gems concepts, as well as a list of currently gemified libraries and links to their docs.
“For the rest of us” this means libraries development extracted into separate GitHub repositories, and they are just packaged with main Ruby before release. It means you can do issue/PR to any of them independently, without going through more tough development process of the core Ruby.
A few changes to mention, though:
Socket::ResolutionError
is added and raised on unsuccessful name resolution instead of genericSocketError
. Discussion: Feature #20018.Timeout.timeout
fails on negative values. Discussion: Bug #20795Tempfile.create
: allows to pass keyword argumentanonymous: true
, which opens an immediately removed temporary file: this file still can be used by the code that needs an IO object, but there is no need to remove it after usage. Discussion: Feature #20497
Version updates
See the corresponding section in the official NEWS-file. This year, it provides full information, including links to change descriptions, which I see no reason to just copy here.
Standard library content changes
- repl_type_completor is added as a bundled gem.
Default gems that became bundled
This means that if your dependencies are managed by Bundler and your code depend on one of them, they should now be added to a Gemfile
.
CRuby Implementation changes
- Prism, which was introduced as alternative parser in Ruby 3.3, became the default one. The classic parser still can be used via
--parser=parse.y
command-line argument. Discussion: Feature #20564. - Garbage collector became modular, and alternative GC implementations can be loaded. Discussions: Feature #20470, Feature #20351.
- Redefining some core methods that are specially optimized by the interpreter and JIT like
String#freeze
orInteger#+
now emits a warning of:performance
category:Warning[:performance] = true # or -W:performance when running ruby class Integer def +(other) # some alternative implementation, maybe supporting custom classes end end # test.rb:4: warning: Redefining 'Integer#+' disables interpreter and JIT optimizations
Discussion: Feature #20429
Integer#**
andRational#**
used to returnFloat::INFINITY
orFloat::NAN
when the numerator of the return value is large, but now returns an Integer. If the return value is extremely large, it raises an exception. Discussion: Feature #20811