Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?

immutable = deeply frozen

Immutable classes

Immutable core classes

  • TrueClass
  • FalseClass
  • NilClass
  • Integer
  • Float
  • Rational
  • Complex
  • Encoding
  • Symbol
  • frozen Strings, both from # frozen_string_literal: true and from -str.
  • Regexp (only literals on CRuby, all on TruffleRuby)
  • Range (only Range instances, not subclass instances)
  • Process::Status

Should be immutable but not yet on CRuby:

  • Thread::Backtrace::Location
  • MatchData (the referenced string is a frozen copy)

Immutable stdlib classes

  • BigDecimal
  • probably more classes, harder to get a list

Methods:

  • .allocate => TypeError (allocator undefined for MyClass), or .allocate is undefined on that class (Complex, Rational, MatchData)
  • .new => typically undefined and there are other methods or there is a literal notation to create instances
  • #initialize => BasicObject#initialize, noop, takes 0 arguments
  • #initialize_copy => Kernel#initialize_copy, which for frozen objects raises FrozenError (can't modify frozen MyClass: value)
  • #clone => Kernel#clone returns the receiver because it knows it's an immutable object
  • #dup => Kernel#dup returns the receiver because it knows it's an immutable object
  • #freeze => Kernel#freeze, noop since already frozen
  • #frozen => Kernel#frozen?, true since creation

Often no .new but instead literal notation or Kernel#MyClass(args) method.

Note that there is Numeric#clone and Numeric#dup, but they are not enough (Kernel.instance_method(:clone).bind_call(1) => 1)

because it knows it's an immutable object is currently an hardcoded list, but ideally non-core types should also be able to be recognized as immutable. That check doesn't work for BigDecimal for instance: Kernel.instance_method(:clone).bind_call(BigDecimal(0)) # => allocator undefined for BigDecimal (TypeError) vs BigDecimal(0).clone # => itself.

Advantages:

  • No need to worry about .allocate-d but not #initialize-d objects => not need to check in every method if the object is #initialize-d
  • internal state/fields can be truly final/const.
  • simpler and faster 1-step allocation since there is no dynamic call to #initialize (instead of .new calls alloc_func and #initialize)
  • Known immutable by construction, no need for extra checks, no need to iterate instance variables since no instance variables
  • Potentially lower footprint due to no instance variables
  • Can be shared between Ractors freely and with no cost
  • Can be shared between different Ruby execution contexts in the same process and even in persisted JIT'd code
  • Easier to reason about both for implementers and users since there is no state
  • Can be freely cached as it will never change

Classes with .allocate undefined or allocator undefined, and noop initialize

The immutable classes above +

  • TracePoint, Binding, Proc, Method, UnboundMethod
  • Ractor, Ractor::MovedObject
  • Thread::Backtrace::Location, MatchData (both should be immutable instead).

I think the following should be added to that list:

  • Module, Class (simplifies a lot in Ruby implementations and removes many "is initialized" checks, also known superclass from the start).
  • Enumerator::ArithmeticSequence, Thread (they currently have a custom #initialize even though no allocator)
  • Enumerator (currently defined allocator and #initialize) for Enumerator.new {}, but could define Enumerator.new to avoid the need.
  • Random
  • Probably more

Advantages:

  • No need to worry about .allocate-d but not #initialize-d objects => not need to check in every method if the object is #initialize-d
  • internal state/fields can be truly final/const, except for ivars.
  • simpler and faster 1-step allocation since there is no dynamic call to #initialize (instead of .new calls alloc_func and #initialize)

These are currently all not #frozen? as they exhibit mutable behavior through method calls.

Some could be shallow-frozen, i.e., they would refer to mutable objects, but the instance itself always refer to the given objects, and they would have no mutable fields and not support ivars. They would be marked as #frozen? on creation. That could make sense for Proc, Method, UnboundMethod (those have no mutating methods).

Classes with .allocate undefined

$ ruby --disable-gems -e 'ObjectSpace.each_object(Class) { |c| p c if !c.respond_to?(:allocate) }'
ruby 3.0.2p107 (2021-07-07 revision 0db68f0233) [x86_64-linux]
Complex
Rational
MatchData

Classes with .allocate => TypeError (allocator undefined for MyClass)

$ ruby --disable-gems -e 'ObjectSpace.each_object(Class) { |c| begin; c.allocate; rescue TypeError => e; p [c,e]; end if c.respond_to?(:allocate) && !c.singleton_class? }' | sort
ruby 3.0.2p107 (2021-07-07 revision 0db68f0233) [x86_64-linux]
[Binding, #<TypeError: allocator undefined for Binding>]
[Encoding, #<TypeError: allocator undefined for Encoding>]
[Enumerator::ArithmeticSequence, #<TypeError: allocator undefined for Enumerator::ArithmeticSequence>]
[FalseClass, #<TypeError: allocator undefined for FalseClass>]
[Float, #<TypeError: allocator undefined for Float>]
[Integer, #<TypeError: allocator undefined for Integer>]
[Method, #<TypeError: allocator undefined for Method>]
[NilClass, #<TypeError: allocator undefined for NilClass>]
[Process::Waiter, #<TypeError: allocator undefined for Process::Waiter>]
[Proc, #<TypeError: allocator undefined for Proc>]
[Ractor::MovedObject, #<TypeError: allocator undefined for Ractor::MovedObject>]
[Ractor, #<TypeError: allocator undefined for Ractor>]
[Random::Base, #<TypeError: allocator undefined for Random::Base>]
[RubyVM::AbstractSyntaxTree::Node, #<TypeError: allocator undefined for RubyVM::AbstractSyntaxTree::Node>]
[RubyVM::InstructionSequence, #<TypeError: allocator undefined for RubyVM::InstructionSequence>]
[RubyVM, #<TypeError: allocator undefined for RubyVM>]
[Struct, #<TypeError: allocator undefined for Struct>]
[Symbol, #<TypeError: allocator undefined for Symbol>]
[Thread::Backtrace::Location, #<TypeError: allocator undefined for Thread::Backtrace::Location>]
[Thread, #<TypeError: allocator undefined for Thread>]
[TracePoint, #<TypeError: allocator undefined for TracePoint>]
[TrueClass, #<TypeError: allocator undefined for TrueClass>]
[UnboundMethod, #<TypeError: allocator undefined for UnboundMethod>]

Full list of classes with no allocator

[RubyVM::AbstractSyntaxTree::Node, TracePoint, Complex, Rational, Process::Waiter, RubyVM::InstructionSequence, Thread::Backtrace::Location, Thread, RubyVM, Ractor::MovedObject, Ractor, Enumerator::ArithmeticSequence, Binding, UnboundMethod, Method, Proc, Random::Base, MatchData, Struct, Float, Integer, Symbol, Encoding, FalseClass, TrueClass, NilClass]

#initialize:
BasicObject#initialize: [RubyVM::AbstractSyntaxTree::Node, TracePoint, Complex, Rational, RubyVM::InstructionSequence, Thread::Backtrace::Location, RubyVM, Ractor::MovedObject, Ractor, Binding, UnboundMethod, Method, Proc, MatchData, Float, Integer, Symbol, Encoding, FalseClass, TrueClass, NilClass]
Custom #initialize: [Process::Waiter, Thread, Enumerator::ArithmeticSequence, Random::Base, Struct]
values = [
true,
false,
nil,
1,
1.0,
1r,
1i,
Encoding::US_ASCII,
:symbol,
-"interned_string",
/regexp/,
(1..2),
Process.wait2(spawn("true"))[1],
/regexp/.match("regexp"),
caller_locations(0).first,
# 1.method(:+),
# Integer.instance_method(:+),
# [1, 2].each,
# (1..7).step(2),
]
values, not_frozen = values.partition(&:frozen?)
puts "not frozen: #{not_frozen}"
classes = values.map(&:class).uniq
puts
puts '.allocate'
undefined_allocate = classes.select { |c| !c.respond_to?(:allocate) }
puts "undefined .allocate: #{undefined_allocate}"
no_allocator = (classes - undefined_allocate).select { |c|
begin
c.allocate
rescue TypeError => e
e.message.include?('allocator undefined')
end
}
puts "allocate raises TypeError: #{no_allocator}"
puts "rest: #{classes - undefined_allocate - no_allocator}"
puts
puts ".new"
undefined_new = classes.select { |c| !c.respond_to?(:new) }
base_owner, other_owners = (classes - undefined_new).partition { |c| c.method(:new).owner == Class }
puts "Class#new: #{base_owner}"
puts "Custom .new: #{other_owners}"
puts "undefined .new: #{undefined_new}"
def analyze_method_owners(classes, method, base)
puts
puts "##{method}"
base_owner, other_owners = classes.partition { |c| c.instance_method(method).owner == base }
puts "#{base}##{method}: #{base_owner}"
puts "Custom ##{method}: #{other_owners}"
end
analyze_method_owners(classes, :initialize, BasicObject)
analyze_method_owners(classes, :initialize_copy, Kernel)
analyze_method_owners(classes, :initialize_dup, Kernel)
analyze_method_owners(classes, :initialize_clone, Kernel)
analyze_method_owners(classes, :clone, Kernel)
analyze_method_owners(classes, :dup, Kernel)
analyze_method_owners(classes, :freeze, Kernel)
analyze_method_owners(classes, :frozen?, Kernel)
###
puts
puts
no_allocator = ObjectSpace.each_object(Class).select { |c|
if !c.singleton_class?
if !c.respond_to?(:allocate)
true
else
begin
c.allocate
rescue TypeError => e
e.message.include?('allocator undefined')
else
false
end
end
end
}
p no_allocator
analyze_method_owners(no_allocator, :initialize, BasicObject)
$ ruby --disable-gems immutable_classes.rb
not frozen: [#<MatchData "regexp">, "immutable_classes.rb:16:in `<main>'"]
.allocate
undefined .allocate: [Rational, Complex]
allocate raises TypeError: [TrueClass, FalseClass, NilClass, Integer, Float, Encoding, Symbol, String, Regexp, Range, Process::Status]
rest: []
.new
Class#new: [String, Regexp, Range]
Custom .new: []
undefined .new: [TrueClass, FalseClass, NilClass, Integer, Float, Rational, Complex, Encoding, Symbol, Process::Status]
#initialize
BasicObject#initialize: [TrueClass, FalseClass, NilClass, Integer, Float, Rational, Complex, Encoding, Symbol, Process::Status]
Custom #initialize: [String, Regexp, Range]
#initialize_copy
Kernel#initialize_copy: [TrueClass, FalseClass, NilClass, Integer, Float, Rational, Complex, Encoding, Symbol, Process::Status]
Custom #initialize_copy: [String, Regexp, Range]
#initialize_dup
Kernel#initialize_dup: [TrueClass, FalseClass, NilClass, Integer, Float, Rational, Complex, Encoding, Symbol, String, Regexp, Range, Process::Status]
Custom #initialize_dup: []
#initialize_clone
Kernel#initialize_clone: [TrueClass, FalseClass, NilClass, Integer, Float, Rational, Complex, Encoding, Symbol, String, Regexp, Range, Process::Status]
Custom #initialize_clone: []
#clone
Kernel#clone: [TrueClass, FalseClass, NilClass, Encoding, Symbol, String, Regexp, Range, Process::Status]
Custom #clone: [Integer, Float, Rational, Complex]
#dup
Kernel#dup: [TrueClass, FalseClass, NilClass, Encoding, Symbol, String, Regexp, Range, Process::Status]
Custom #dup: [Integer, Float, Rational, Complex]
#freeze
Kernel#freeze: [TrueClass, FalseClass, NilClass, Integer, Float, Rational, Complex, Encoding, Symbol, Regexp, Range, Process::Status]
Custom #freeze: [String]
#frozen?
Kernel#frozen?: [TrueClass, FalseClass, NilClass, Integer, Float, Rational, Complex, Encoding, Symbol, String, Regexp, Range, Process::Status]
Custom #frozen?: []
[RubyVM::AbstractSyntaxTree::Node, TracePoint, Complex, Rational, Process::Waiter, RubyVM::InstructionSequence, Thread::Backtrace::Location, Thread, RubyVM, Ractor::MovedObject, Ractor, Enumerator::ArithmeticSequence, Binding, UnboundMethod, Method, Proc, Random::Base, MatchData, Struct, Float, Integer, Symbol, Encoding, FalseClass, TrueClass, NilClass]
#initialize
BasicObject#initialize: [RubyVM::AbstractSyntaxTree::Node, TracePoint, Complex, Rational, RubyVM::InstructionSequence, Thread::Backtrace::Location, RubyVM, Ractor::MovedObject, Ractor, Binding, UnboundMethod, Method, Proc, MatchData, Float, Integer, Symbol, Encoding, FalseClass, TrueClass, NilClass]
Custom #initialize: [Process::Waiter, Thread, Enumerator::ArithmeticSequence, Random::Base, Struct]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment