Feature #12092
closedAllow Object#clone to yield cloned object before freezing
Description
This allows creating modified clones of frozen objects that have
singleton classes:
a = [1,2,3]
def a.fl; first + last; end
a.freeze
a.fl # => 4
clone = a.clone{|c| c << 10}
clone.last # => 10
clone.fl # => 11
clone.frozen? # => true
Previously, this was not possible at all. If an object was
frozen, the clone was frozen before the cloned object could
be modified. It was possible to modify the clone using
initialize_clone
or initialize_copy
, but you couldn't change how
to modify the clone on a per-call basis. You couldn't use dup
to return an unfrozen copy, modify it, and then freeze it, because
dup
doesn't copy singleton classes.
This allows ruby to be used in a functional style with immutable
data structures, while still keeping the advantages of singleton
classes.
Files
Updated by jeremyevans0 (Jeremy Evans) over 8 years ago
- File 0001-Allow-clone-to-take-a-second-argument-passed-to-init.patch 0001-Allow-clone-to-take-a-second-argument-passed-to-init.patch added
Since this will be discussed tomorrow at the developers meeting, here's a more detailed example of how this can be used, along with pros and cons of alternative approaches.
Let's say you have a class where each instance has an options hash, which you would like to be immutable (both the instance and the underlying options hash). You would like to created modified clones of this object, where the clones are also immutable but copy the singleton classes from the original object. With the patch attached to this feature request, you would have to write code like:
A = Struct.new(:opts)
a = A.new({}.freeze).extend(SomeModule).freeze
hash = {:c=>1}
a.clone{|b| b.opts = b.opts.merge(hash).freeze}
The attached patch was designed to be the minimally invasive change that supports the need to created modified copies of objects that are frozen and have singleton classes. However, it's not necessarily the best approach.
Alternative approach #1: Have #clone
pass a block given to #initialize_clone
.
Example:
A = Struct.new(:opts) do
def initialize_clone(clone)
clone.opts = clone.opts.dup
yield clone
clone.opts.freeze
super
end
end
a = A.new({}.freeze).extend(SomeModule).freeze
hash = {:c=>1}
# Not possible in attached patch as #clone yields after #initialize_clone,
# and clone.opts would already be frozen in that case
a.clone{|b| b.opts.merge!(hash)}
Pros:
- Doesn't change current behavior when passing #clone a block. Blocks passed to
#clone
will not be yielded to, unless the object's#initialize_clone
has been overriden to yield to the block. - Allows users to determine when to yield, as they may want to yield before doing some work in
#initialize_clone
.
Cons:
- Requires overriding
#initialize_clone
for each class that you want to be able to modify during#clone
. - Possible additional runtime overhead unless proc activation can be avoided.
- Requires more changes to the existing code.
Alternative approach #2: Allow #clone
to accept an argument to pass to #initialize_clone
Example:
A = Struct.new(:opts) do
def initialize_clone(clone, opts)
clone.opts = self.opts.merge(opts).freeze
super
end
end
a = A.new({}.freeze).extend(SomeModule).freeze
hash = {:c=>1}
# Much simpler API for this and probably most use cases
a.clone(hash)
Pros:
- Faster as it doesn't require creating a block at all.
- Simpler for most use cases
Cons:
- Requires overriding
#initialize_clone
for each class that you want to be able to modify during#clone
. - Requires more changes to the existing code, but I have a working patch for it.
I think alternative approach #2 is probably the best way to support this. I'm attaching a patch for it as well.
Updated by ferdinandrosario@gmail.com (ferdinand rosario) over 8 years ago
- Assignee set to core
Updated by nobu (Nobuyoshi Nakada) over 8 years ago
- Description updated (diff)
- Status changed from Open to Feedback
Why does it need to be a singleton method but can't a method from an included module?
Updated by jeremyevans0 (Jeremy Evans) over 8 years ago
Nobuyoshi Nakada wrote:
Why does it need to be a singleton method but can't a method from an included module?
I think this should work with arbitrary objects, and all objects in ruby that can have singleton classes support singleton methods. If you just want to deal with modules, you can currently do:
a1 = a.dup
(a.singleton_class.ancestors[1..-1] - a.class.ancestors).each do |m|
a1.extend m
end
a1.opts = a.opts.merge(hash).freeze
a1.freeze
However, there is no way to handle singleton methods AFAIK:
a.singleton_methods.each do |meth|
um = a.method(meth).unbind
# Raises TypeError
um.bind(a1)
end
In addition, doing dup/freeze instead of clone performs worse even if you are just copying modules. Here's a comparison using alternative approach #2 listed above. Code:
A = Struct.new(:opts) do
def initialize_clone(orig, opts={})
self.opts = orig.opts.merge(opts).freeze
super(orig)
end
def clone2(opts={})
clone = dup
(singleton_class.ancestors[1..-1] - self.class.ancestors).each do |m|
clone.extend m
end
clone.opts = self.opts.merge(opts).freeze
clone.freeze
end
end
module B; def b; 2 end end
module C; def c; 3 end end
a = A.new({})
a.extend B
a.extend C
def a.a; 1; end
a.freeze
h = {:a=>1}
require 'benchmark'
Benchmark.bm(15) do |x|
x.report('clone'){100000.times{a.clone(h)}}
x.report('dup/freeze'){100000.times{a.clone2(h)}}
end
Results:
| user| system| total| real
---------------+---------:+---------:+---------:+-----------:
clone | 2.210000| 0.000000| 2.210000|( 2.209889)
dup/freeze | 5.490000| 0.000000| 5.490000|( 5.488063)
Updated by duerst (Martin Dürst) over 8 years ago
Hello Jeremy,
On 2016/03/17 01:14, merch-redmine@jeremyevans.net wrote:
Nobuyoshi Nakada wrote:
Why does it need to be a singleton method but can't a method from an included module?
I think this should work with arbitrary objects, and all objects in ruby that can have singleton classes support singleton methods.
At the developer's meeting yesterday, we were wondering whether your
request is mostly based on a completeness/consistency argument (which
the above sentence looks like) or whether you have some actual use case
(the performance arguments you give seem to indicate you have a use case
that involves a lot of actual operations).
So giving more examples of use cases (not "Let's say...", but actual
usage) would help a lot to move this issue forward.
Regards, Martin.e
Updated by jeremyevans0 (Jeremy Evans) over 8 years ago
Martin Dürst wrote:
Hello Jeremy,
On 2016/03/17 01:14, merch-redmine@jeremyevans.net wrote:
Nobuyoshi Nakada wrote:
Why does it need to be a singleton method but can't a method from an included module?
I think this should work with arbitrary objects, and all objects in ruby that can have singleton classes support singleton methods.
At the developer's meeting yesterday, we were wondering whether your
request is mostly based on a completeness/consistency argument (which
the above sentence looks like) or whether you have some actual use case
(the performance arguments you give seem to indicate you have a use case
that involves a lot of actual operations).So giving more examples of use cases (not "Let's say...", but actual
usage) would help a lot to move this issue forward.
I would eventually like to use to use this with Sequel datasets. In Sequel, dataset extensions and model plugins result in dataset instances that are extended with multiple modules, and users can always add methods directly to datasets. Sequel's dataset API is built around #clone, which Sequel::Dataset overrides and calls super:
def clone(opts = nil)
c = super()
if opts
c.instance_variable_set(:@opts, Hash[@opts].merge!(opts))
c.instance_variable_set(:@columns, nil) if @columns && !opts.each_key{|o| break if COLUMN_CHANGE_OPTS.include?(o)}
else
c.instance_variable_set(:@opts, Hash[@opts])
end
c
end
Because this cannot currently work with frozen objects, I have to override #freeze:
def freeze
@frozen = true
self
end
And then I have to manually check whether the instance is frozen in every method that mutates the instance:
def identifier_output_method=(v)
raise_if_frozen!
@identifier_output_method = v
end
def raise_if_frozen!
if frozen?
raise RuntimeError, "can't modify frozen #{visible_class_name}"
end
end
However, this still allows users to manually mutate the object, and it's possible I may miss places where raise_if_frozen!
should be called.
Being able to actually freeze the datasets would fix these issues, give stronger consistency guarantees, and remove possible thread safety issues.
Having some way to mutate the object during #clone should be helpful for any ruby library that uses a functional approach with immutable objects that have singleton classes.
Hopefully this gives a more clear picture about why I want this. However, I don't want this to be about Sequel, which is why I didn't bring it up previously. I think the idea should be evaluated on its own merits, instead of based on how it would help a single library.
Updated by nobu (Nobuyoshi Nakada) about 8 years ago
- Status changed from Feedback to Rejected
Updated by nobu (Nobuyoshi Nakada) over 6 years ago
- Related to Feature #14778: Make Object#dup accept a block added