DEV Community

Ary Borenszweig
Ary Borenszweig

Posted on • Edited on

Why I love Ruby: equality

In this series of posts I will list the things that I always loved about Ruby. Some of these work differently in other languages or they don't exist at all. I will try to give some comparisons when possible. But in general my feeling is that Ruby always does the most intuitive thing a programmer would expect, and I love that! And it's what I tried to preserve in Crystal.

Comparing objects

In Ruby you can compare any two objects and Ruby will do the most intuitive thing.

1 == 1           # => true
1 == 1.0         # => true (in math too!)
[1, 2] == [1, 2] # => true
Enter fullscreen mode Exit fullscreen mode

The last one about comparing arrays is important: not every language does this. For instance, if you compare two arrays in Java or C#, you only get true if they are the same array, that is, the same reference to memory. Then if you search "how to compare two arrays in Java/C#" you will get lots of answers in StackOverflow trying to answer this. In Java you can use Arrays.equals, but that doesn't do a deep comparison, so you also have Arrays.deepEquals.

The tricky part about array comparison is that you might end up having an array with an object that in turn has a reference to the same array, leading to a cycle. When trying to compare them, if you don't take cycles into account, the program will stack overflow, or crash. You don't want that in your program!

Well, in Ruby it just works. It will compare two arrays by calling == on each element, and it will also take care of cycles. The following works:

a = []
a << a # => => [[...]]

a == a # => true
Enter fullscreen mode Exit fullscreen mode

All of the above work just fine in Crystal, except that for the recursive array definition. I thought it worked fine in Crystal but it seems it also leads to a stack overflow. But it's something we'll eventually fix.

In Ruby you can also compare Hash, Set, and many other types, where the definition of equality is the obvious one, and the same is true in Crystal.

Equality everywhere

Another nice thing about equality in Ruby is that you get sensible defaults, and you can compare any two objects out of the box. For example, Ruby has a Struct type that's used to pack some values together, kind of like a quick definition of an object with some properties:

Point = Struct.new(:x, :y)

p1 = Point.new(1, 2)
p2 = Point.new(1, 2)
p1 == p2 # => true
Enter fullscreen mode Exit fullscreen mode

In Crystal you can also define structs, and the default comparison just delegates to comparing the fields:

struct Point
  def initialize(@x : Int32, @y : Int32)
  end
end

p1 = Point.new(1, 2)
p2 = Point.new(1, 2)
p1 == p2 # => true
Enter fullscreen mode Exit fullscreen mode

This all sounds very intuitive. Why would a language not do that? Well, I don't know. Take for example Haskell, where these types of structs are really common:

❯ ghci
GHCi, version 8.10.7: https://www.haskell.org/ghc/  :? for help
Prelude> data Point = Point { x :: Int, y :: Int }
Prelude> p1 = Point { x = 1, y = 2 }
Prelude> p2 = Point { x = 1, y = 2 }
Prelude> p1 == p2

<interactive>:4:1: error:
    • No instance for (Eq Point) arising from a use of ‘==’
    • In the expression: p1 == p2
      In an equation for ‘it’: it = p1 == p2
Enter fullscreen mode Exit fullscreen mode

Haskell gives a compile-time error because == is not defined for Point. We need to define one. And we can use a magic deriving to let the compiler do the obvious thing:

❯ ghci
GHCi, version 8.10.7: https://www.haskell.org/ghc/  :? for help
Prelude> data Point = Point { x :: Int, y :: Int } deriving (Eq)
Prelude> p1 = Point { x = 1, y = 2 }
Prelude> p2 = Point { x = 1, y = 2 }
Prelude> p1 == p2
True
Enter fullscreen mode Exit fullscreen mode

Now, this last point is controversial (maybe this entire blog post is controversial!) Someone might say:

But what if you didn't want this comparison by default? It's dangerous! Then you would have a bug in your code. It's better to always think about these cases and make sure the equality you want is really that one.

That's fine. My answer to that is that you want to have tests to verify that you code works in the way you want it. Compile-time safety is good, but it doesn't prove behavior.

Coming up next...

In the next part I'll talk about Ruby's to_s method.

Top comments (11)

Collapse
 
betaziliani profile image
Beta Ziliani

The thing in Ruby and Crystal is that you can overload the default, if it doesn't work for your data structure. This isn't true for Haskell. But I agree that you can have the opposite: instead of requiring deriving (Eq, Show), take that for granted and let the developer opt out.

Collapse
 
marcoservetto profile image
Marco Servetto

You seams to be very confident that you can define a meaningful equal for an listish class that can contains meaningful equals for structy stuff that also has meaningful equals that works in the case of cycles.
Are you sure? can you show me code/pseudocode that would work?
The iconic example is the Person with a list of friends that can be Persons
It can be done with a special equals that takes a list of visited or something like that... can you do it with a normal binary equal method? how?

Collapse
 
asterite profile image
Ary Borenszweig

Hi! I can show code that check for recursiveness for to_s. For == it should be similar, but considering a pair of objects.

def to_s
  if self_in_global_table?
    "[..]"
  else
    put_object_in_a_global_table
    "[" + join(",") + "]"
    remove_object_from_global_table
  end
end
Enter fullscreen mode Exit fullscreen mode

That is, before printing ourselves we remember that we passed through this object. If later we pass again, we don't recurse and just output "[..]".

For equality it would be similar, but putting the two objects in a global table.

You can try to check how this is done in Ruby, thought it's C code so it will be harder to understand: github.com/ruby/ruby/blob/c53bdb8f... (note that it calls a function recursive_equal)

Collapse
 
marcoservetto profile image
Marco Servetto

Hi, This is not really satisfactory:
-what about parallelism?
-what if you want to point to the circular object by giving it a name instead of just printing "repeated stuff here"
For the equality you will need that.
For equality of circular objects you even need problems like DFS minimization:
For example assume you have a Node=Struct.new(int,node)
is it the case that
start=Node(0,start)
is equal to
start=Node(0,Node(0,start))
? in certain interpreations of recursive equality it is the same, in other it is not.

Thread Thread
 
betaziliani profile image
Beta Ziliani

Good points, although I don't think parallelism is relevant here. Of course if you mutate things while inspecting them you get odd results. This is the same for equality or any other operation.

About circular objects, the answer is: structs are passed as values, so they can't be circular by restriction, and a class (reference) would consider the two examples above to be different. If you want something smarter, you have to code it yourself. The motto here could be
``do simply what works most of the time and let the complex be easily incorporated too''

Thread Thread
 
marcoservetto profile image
Marco Servetto

? are you talking about ruby here? Structs are definitively passed by reference.

Person = Struct.new(:name,:age,:friends) {
  def inspect() to_s end
  def to_s() "Person[#{name},#{age},#{friends}]" end
  }
def methIn(person)
  person.age=50
  end
bob     = Person["Bob",14,[]]
methIn(bob)
puts bob #prints Person[Bob,50,[]]
Enter fullscreen mode Exit fullscreen mode

If they were passed by value then methIn would only be altering its local copy.
I do not think that anything at all is passed by value in ruby.

Thread Thread
 
betaziliani profile image
Beta Ziliani

Sorry! I meant in Crystal.

Thread Thread
 
marcoservetto profile image
Marco Servetto

ok then... I'm interested in the gritty details of circular equality in ruby.
I also wrote on reddit (reddit.com/r/ruby/comments/syemrs/...) searching for a better answer, and I'm getting some, but I'm still confused.

Thread Thread
 
asterite profile image
Ary Borenszweig

The way it works is:

  • you keep a global table of pair of objects being checked for equality
  • before you compare two objects, you check if they are in this global table. If they are, then the answer is "true" (you don't recurse)
  • otherwise, you put them in the table, then you do the comparison, and finally you remove them from the table

What happens if these objects are compared in other threads at the same time? Well, the global table keeps these pairs per thread!

At least that's how it will work in Crystal (supporting recursive to_s is done with a similar algorithm.)

If that default equality isn't a good default, you can always override == in Ruby and Crystal.

Thread Thread
 
asterite profile image
Ary Borenszweig

You can also take a look at the way we are probably going to do it in Crystal (similar to Ruby, but you don't need to understand C): github.com/crystal-lang/crystal/is...

Collapse
 
straightshoota profile image
Johannes Müller

In Crystal you can also define structs, and the default comparison just delegates to comparing the fields:

That is not entirely true. Struct#==also requires type identity, including generic parameters. Which reminded me of this issue: github.com/crystal-lang/crystal/is...