Project

General

Profile

Feature #16122

Struct::Value: simple immutable value object

Added by zverok (Victor Shepelev) about 2 months ago. Updated about 1 month ago.

Status:
Feedback
Priority:
Normal
Assignee:
-
Target version:
-
[ruby-core:94508]

Description

Value Object is a useful concept, introduced by Martin Fowler (his post, Wikipedia Entry) with the following properties (simplifying the idea):

  • representing some relatively simple data;
  • immutable;
  • compared by type & value;
  • nicely represented.

Value objects are super-useful especially for defining APIs, their input/return values. Recently, there were some movement towards using more immutability-friendly approach in Ruby programming, leading to creating several discussions/libraries with value objects. For example, Tom Dalling's gem, Good Ruby Value object convention (disclaimer: the latter is maintained by yours truly).

I propose to introduce native value objects to Ruby as a core class.

Why not a gem?

  • I believe that concept is that simple, that nobody will even try to use a gem for representing it with, unless the framework/library used already provides one.
  • Potentially, a lot of standard library (and probably even core) APIs could benefit from the concept.

Why Struct is not enough

Core Struct class is "somewhat alike" value-object, and frequently used instead of one: it is compared by value and consists of simple attributes. On the other hand, Struct is:

  • mutable;
  • collection-alike (defines to_a and is Enumerable);
  • dictionary-alike (has [] and .values methods).

The above traits somehow erodes the semantics, making code less clear, especially when duck-typing is used.

For example, this code snippet shows why to_a is problematic:

Result = Struct.new(:success, :content)

# Now, imagine that other code assumes `data` could be either Result, or [Result, Result, Result]
# So, ...

data = Result.new(true, 'it is awesome')

Array(data) # => expected [Result(true, 'it is awesome')], got [true, 'it is awesome']

# or...
def foo(arg1, arg2 = nil)
p arg1, arg2
end

foo(*data) # => expected [Result(true, 'it is awesome'), nil], got [true, 'it is awesome']

Having [] and each defined on something that is thought as "just value" can also lead to subtle bugs, when some method checks "if the received argument is collection-alike", and value object's author doesn't thought of it as a collection.

Concrete proposal

  • Class name: Struct::Value: lot of Rubyists are used to have Struct as a quick "something-like-value" drop-in, so alternative, more strict implementation, being part of Struct API, will be quite discoverable; alternative: just Value
  • Class API is copying Structs one (most of the time -- even reuses the implementation), with the following exceptions (note: the immutability is **not* the only difference)*:
    • Not Enumerable;
    • Immutable;
    • Doesn't think of itself as "almost hash" (doesn't have to_a, values and [] methods);
    • Can have empty members list (fun fact: Struct.new('Foo') creating member-less Struct::Foo, is allowed, but Struct.new() is not) to allow usage patterns like:
class MyService
  Success = Struct::Value.new(:results)
  NotFound = Struct::Value.new
end

NotFound here, unlike, say, Object.new.freeze (another pattern for creating "empty typed value object"), has nice inspect #<value NotFound>, and created consistently with the Success, making the code more readable. And if it will evolve to have some attributes, the code change would be easy.

Patch is provided

Sample rendered RDoc documentation


Files

struct_value.patch (18.6 KB) struct_value.patch zverok (Victor Shepelev), 08/23/2019 05:40 PM

History

Updated by Eregon (Benoit Daloze) about 2 months ago

This sounds interesting to me.
What would a simple implementation of Struct::Value.new look like in Ruby code?
I'm not quite sure what the available API is since it's all described as Struct - some methods.

Updated by zverok (Victor Shepelev) about 2 months ago

Eregon (Benoit Daloze), here is rendered version of class' docs: https://zverok.github.io/ruby-rdoc/Struct-Value.html

Basically, it is what is said on the tin: like Struct, just leaner.

Updated by zverok (Victor Shepelev) about 2 months ago

What would a simple implementation of Struct::Value.new look like in Ruby code?

Oh, I've probably answered the wrong question... But I am not quite sure I understand yours.

Theoretically, it is just something like this (ignoring the fact that Structs implementation has optimized storage and other tricks, and any input validation):

class Struct::Value
  def self.new(*args, keyword_init: false)
    name, *members = args.first.is_a?(String) ? args : [nil, *args]
    Class.new(self) do
      @members = members

      def self.new(*args)
        allocate.tap { |o| o.__send__(:initialize, *args) }
      end

      members.each { |m| define_method(m) { instance_variable_get("@#{m}") }}
    end.tap { |cls| const_set(name, cls) if name }
  end
# ....

So, (if that's what you've asking) it produces object of different class, Struct::Value, unrelated to Struct, but sharing most of the implementation.

Updated by matz (Yukihiro Matsumoto) about 2 months ago

  • Status changed from Open to Feedback

The typical solution is Struct.new(...).freeze. This doesn't require any enhancement. The other option is Struct.new(..., immutable: false). It looks simpler than the proposed Struct::Value.

Matz.

Updated by zverok (Victor Shepelev) about 2 months ago

matz (Yukihiro Matsumoto) Sorry for not sharing more detailed reasoning which led to the current proposal (I explained the "final reasons" in its text, but it is too terse).

So, it went as following:

1. First, I really wanted just Struct.new(..., immutable: false) (and even experimented for some time with a private monkey-patch, doing just that)

2. But in fact, to be a proper convenient "value object", it is also bad for container to mimic Enumerable, and especially bad to implement to_a. Simple example:

  Result = Struct.new(:success, :content)

  # Now, imagine that other code assumes `data` could be either Result, or [Result, Result, Result]
  # So, ...

  data = Result.new(true, 'it is awesome')

  Array(data) # => expected [Result(true, 'it is awesome')], got [true, 'it is awesome']

  # or...
  def foo(arg1, arg2 = nil)
    p arg1, arg2
  end

  foo(*data) # => expected [Result(true, 'it is awesome'), nil], got [true, 'it is awesome']

3. And generally, some random value object "duck typing" itself as a collection seems not really appropriate.

4. The same, I believe, is related to supporting [:foo] and ['foo'] accessors: convenient for "general content object" that Struct is, but for "just value" it could seem an unnecessary expansion of the interface.

5. Finally, empty-member Value is allowed, while empty-member Struct somehow does not (I don't know if it is by design or just a bug, as I am mentioning above, Struct.new('Name') IS allowed, but Struct.new is NOT).

So, considering all the points above, it could be either multiple settings: immutable: true, enumerable: false, hash_accessors: false (the (5) probably could be just fixed for Struct, too) -- which is not that convenient if you are defining 3-5 types in a row, and requires some cognitive efforts both from writer (errm, what options did I used last time to set it as a "good" value object?..) and reader (ugh, what's this struct with so many settings?..).

So I eventually decided to propose going another way.

Updated by Dan0042 (Daniel DeLorme) about 2 months ago

If I understand correctly, the idea is to have X=Struct::Value.new(:x,:y,:z) which is strictly equivalent to

class X
  def initialize(x=nil, y=nil, z=nil)
    @x,@y,@z = x,y,z
  end
  attr_reader :x, :y, :z
  #and other methods based on x,y,z attributes:
  #def ==(other)
  #def eql?(other)
  #def hash 
end

Or was there some nuance I didn't catch?

Updated by zverok (Victor Shepelev) about 2 months ago

Dan0042 (Daniel DeLorme) you are (probably) missing #inspect, #==, #eql?, #hash, #to_h and a bunch of other methods that are pretty trivial, but also important for the "value object".

Updated by mame (Yusuke Endoh) about 2 months ago

I couldn't understand what is "value object", and I found: https://martinfowler.com/bliki/ValueObject.html
Please do not assume that everybody knows such an essay ;-)
No one pointed out the article during the developer's meeting, so we cannot understand what you want.

I have some comments:

  • Why don't you start it with a gem? It may be useful for your case, but I'm not sure if it is useful for many people so that it deserves a built-in feature. And the design of Struct::Value is not clear to me (e.g., non-Enumerable is trade off; is it really useful for many cases?).If your gem become so popular, we can import it as a built-in feature.
  • The behavior of Struct::Value is too different from Struct. Another class name (like ValueClass or NamedTuple or what not) looks more suitable.
  • What you (first) want is called "structural equality" in other languages (OCaml, F#, C#, TypeScript, Kotlin, as far as I know). Also it resembles "namedtuple" in Python. You may want to study them.

BTW, I understand the motivation of the proposal. I want "structural equality" in Ruby. Personally, I often write:

class Point3D
  include StructuralEquality
  def initialize(x, y, z)
    @x, @y, @z = x, y, z
  end
end

foo1 = Point3D.new(1, 2, 3)
foo2 = Point3D.new(1, 2, 3)
p foo1 == foo2 #=> true
h = { foo1 => "ok" }
p h[foo2] #=> "ok"

(The definition of StructuralEquality is here: https://github.com/mame/ruby-type-profiler/blob/436a10787fc74db47a8b2e9db995aa6ef7c16311/lib/type-profiler/utils.rb#L8-L31 )

But, I'm unsure if it deserves a built-in feature.

Updated by zverok (Victor Shepelev) about 2 months ago

mame (Yusuke Endoh) I understand your concerns. I'll update the description today or tomorrow to include all the terminology and detailed rationale behind the proposal.

Updated by zverok (Victor Shepelev) about 2 months ago

  • Description updated (diff)

mame (Yusuke Endoh), matz (Yukihiro Matsumoto), I updated the description, tried to include a proper rationale for every design decision made.

Updated by naruse (Yui NARUSE) about 2 months ago

I believe that concept is that simple, that nobody will even try to use a gem for representing it with, unless the framework/library used already provides one.

I'm using immutable_struct.gem.

Updated by zverok (Victor Shepelev) about 2 months ago

naruse (Yui NARUSE) Of course, there are several good gems with more-or-less similar functionality. But, from the hard experience, large codebases tend to look with great caution on the "small utility" gems to avoid dependency bloat and tend to depend only on large non-trivial functionality. But if it is a part of the language core, it is beneficial for everyone.

Updated by Dan0042 (Daniel DeLorme) about 2 months ago

Question: you say "Doesn't think of itself as almost hash" but at the same time you say it should have to_h. Isn't that a contradiction? What exactly are you looking for?

Naming suggestion: BasicStruct (in parallel to Object and BasicObject)

Updated by zverok (Victor Shepelev) about 2 months ago

Dan0042 (Daniel DeLorme)

Question: you say "Doesn't think of itself as almost hash" but at the same time you say it should have to_h. Isn't that a contradiction?

Nope. An object that has to_h is not "something hash-like", it is just "something that can be represented as a Hash" (for example, to save it to JSON). The same way that all Ruby objects respond to to_s but that doesn't make them "something like String".

But "mimicking" some of the Hash API (with [] and values and values_at) makes the object responsibility less focused.

Updated by Dan0042 (Daniel DeLorme) about 2 months ago

Ok I see what you meant. BTW Struct#values_at follows the Array rather than Hash API, because Struct also thinks of itself as a tuple :-/

 Struct.new(:x).new(42).values_at(0)  #=> [42]
 Struct.new(:x).new(42).values_at(:x) #=> TypeError

Updated by palkan (Vladimir Dementyev) about 1 month ago

zverok (Victor Shepelev) wrote:

Why not a gem?

  • I believe that concept is that simple, that nobody will even try to use a gem for representing it with, unless the framework/library used already provides one.

If a concept is popular and there is a well-designed gem that implements it then people use it. For example, a lot of people use dry-initializer, which is also dead-simple and provides the functionality that could easily be implemented from scratch (and even could be useful as a part of the standard library).

If there is still no such a gem then there is no enough demand for the feature itself.

So, why pushing it to the core?

Updated by zverok (Victor Shepelev) about 1 month ago

palkan (Vladimir Dementyev) I have a strong feeling of "value object notion should be a part of the language, not an externally implemented optional thingy", but it is not easy to rationalize it.

Maybe the thing is that "value object" is a notion most useful at API borders (and it is not just utility usability, but conceptual one, "our API accepts this and that type of value objects and return this and that type of them"). And I believe "this is a concept of the language" makes a huge difference in using, documenting and explaining your APIs, compared to "well, we use that external gem, developed by some random dude, to bloat our depenencies, because it is tinsy bit more convenient."

In other words, I am proposing to introduce the concept, not implementation.

Also available in: Atom PDF