Project

General

Profile

Feature #12092

Allow Object#clone to yield cloned object before freezing

Added by jeremyevans0 (Jeremy Evans) about 1 year ago. Updated 7 months ago.

Status:
Rejected
Priority:
Normal
Assignee:
-
Target version:
-
[ruby-core:73898]

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.

0001-Allow-clone-to-yield-cloned-object-before-freezing.patch View (2.51 KB) jeremyevans0 (Jeremy Evans), 02/19/2016 08:33 PM

0001-Allow-clone-to-take-a-second-argument-passed-to-init.patch View - Alternative approach #2 (2.37 KB) jeremyevans0 (Jeremy Evans), 03/14/2016 06:05 PM

History

#1 [ruby-core:74302] Updated by jeremyevans0 (Jeremy Evans) about 1 year ago

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.

#3 [ruby-core:74369] Updated by nobu (Nobuyoshi Nakada) about 1 year 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?

#4 [ruby-core:74386] Updated by jeremyevans0 (Jeremy Evans) about 1 year 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)

#5 [ruby-core:74392] Updated by duerst (Martin Dürst) about 1 year 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

#6 [ruby-core:74394] Updated by jeremyevans0 (Jeremy Evans) about 1 year 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.

#7 Updated by naruse (Yui NARUSE) 11 months ago

  • Assignee deleted (ruby-core)

#8 [ruby-core:77548] Updated by nobu (Nobuyoshi Nakada) 7 months ago

  • Status changed from Feedback to Rejected

Also available in: Atom PDF