Ary Borenszweig
Posted on February 18, 2022
String representation
In Ruby every object responds to to_s
with a good default. There's also inspect
which usually reveals the internal structure of an object.
For example:
class Point
def initialize(x, y)
@x = x
@y = y
end
end
point = Point.new(1, 2)
point.to_s # => #<Point:0x00007fab148d55d8>
point.inspect # => #<Point:0x00007fab148d55d8 @x=1, @y=2>
Okay, maybe to_s
isn't that useful and inspect
is much more useful, but it's nice that they are there.
The nice thing about inspect
is that they also takes potential cycles into account:
class Person
def initialize(name)
@name = name
@sibilings = []
end
def add_sibiling(person)
@sibilings << person
end
end
ary = Person.new("Ary")
gabriel = Person.new("Gabriel")
ary.add_sibiling(gabriel)
gabriel.add_sibiling(ary)
ary.inspect # => #<Person:0x00007fe670851160 @name="Ary", @sibilings=[#<Person:0x00007fe6708510c0 @name="Gabriel", @sibilings=[#<Person:0x00007fe670851160 ...>]>]>
gabriel # => #<Person:0x00007fe6708510c0 @name="Gabriel", @sibilings=[#<Person:0x00007fe670851160 @name="Ary", @sibilings=[#<Person:0x00007fe6708510c0 ...>]>]>
Not only it doesn't crash: it also shows the object ID of objects so you can know that when there's a cycle, what that object is.
Similar code works fine in Crystal too:
class Person
def initialize(@name : String)
@sibilings = [] of Person
end
def add_sibiling(person)
@sibilings << person
end
end
ary = Person.new("Ary")
gabriel = Person.new("Gabriel")
ary.add_sibiling(gabriel)
gabriel.add_sibiling(ary)
ary.inspect # => #<Person:0x107d3aea0 @name="Ary", @sibilings=[#<Person:0x107d3ae60 @name="Gabriel", @sibilings=[#<Person:0x107d3aea0 ...>]>]>
gabriel.inspect # => #<Person:0x107d3ae60 @name="Gabriel", @sibilings=[#<Person:0x107d3aea0 @name="Ary", @sibilings=[#<Person:0x107d3ae60 ...>]>]>
The above output is a bit hard to read... so Ruby and Crystal allow you to pretty print objects, every type of object, right out of the box. Here's Ruby with the above value:
require "pp"
pp ary
Output:
#<Person:0x00007ffad4076fd0
@name="Ary",
@sibilings=
[#<Person:0x00007ffad404b8a8
@name="Gabriel",
@sibilings=[#<Person:0x00007ffad4076fd0 ...>]>]>
Here's Crystal:
pp ary
Output:
#<Person:0x101418ea0
@name="Ary",
@sibilings=
[#<Person:0x101418e60
@name="Gabriel",
@sibilings=[#<Person:0x101418ea0 ...>]>]>
In other languages this ability to easily inspect objects out of the box is not present, or is not great. At least in some languages all objects can be converted to a string, even if the output isn't very useful. But some languages doesn't do that... and I'll mention Haskell now, maybe because I'm using it at work, but I think this is also true in Rust.
In Haskell you can't turn any value into a string by default. The type has to implement the Show
typeclass. In practice this means that if we have the Point
type I mentioned above:
❯ 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> p1
<interactive>:8:1: error:
• No instance for (Show Point) arising from a use of ‘print’
• In a stmt of an interactive GHCi command: print it
You can't see what's p1
. You again have to put deriving (Show)
at the end of the declaration, or define a custom show
function for the Show
typeclass.
This might not sound like a lot to do, but when you are debugging code and you can't inspect objects and then you have to stop what you are doing and what you were thinking about, to go and open a file and add deriving (Show)
in a lot of places just to see what's going on, it's not fun. It's less fun when those types aren't in your control and it's harder to add a Show
for them. At the end of this process you end up thinking "should I leave these deriving (Show)
or should I remove them now that I'm done with them", and it's where my question "why aren't these derived by default" comes to mind.
This is also when Ruby's goal, "developer happiness", comes to my mind. Doing all of the above isn't fun. In Ruby we don't have to do that, Ruby took care of that and we can just have fun solving more interesting problems.
That said, once you add deriving (Show)
, Haskell works fine, and in general show
is very well implemented for "standard library" types:
❯ ghci
GHCi, version 8.10.7: https://www.haskell.org/ghc/ :? for help
Prelude> data Point = Point { x :: Int, y :: Int } deriving (Show)
Prelude> p1 = Point { x = 1, y = 2 }
Prelude> p1
Point {x = 1, y = 2}
Prelude> [p1]
[Point {x = 1, y = 2}]
Prelude> ["hello", "world"]
["hello","world"]
Another language as an example: Go
Let's take a look at another language. I randomly chose Go because it's a relatively modern language, very popular, and there's a playground to try things out.
Here's an example from Go's tour about arrays:
package main
import "fmt"
func main() {
var a [2]string
a[0] = "Hello"
a[1] = "World"
fmt.Println(a[0], a[1])
fmt.Println(a)
primes := [6]int{2, 3, 5, 7, 11, 13}
fmt.Println(primes)
}
This is the output:
Hello World
[Hello World]
[2 3 5 7 11 13]
Let's compare it to the output of Ruby or Crystal. I'm using p
here which invokes inspect
on objects.
a = ["Hello", "World"]
p a[0], a[1]
p a
primes = [2, 3, 5, 7, 11, 13]
p primes
Here's the output:
"Hello"
"World"
["Hello", "World"]
[2, 3, 5, 7, 11, 13]
The first thing to note is that Ruby and Crystal put quotes around strings (well, this isn't the case if you call to_s
on a string, only inspect
.) Then note that array contents use inspect
. This makes it possible to know that the first array has two strings. In Go it's not clear: are there two elements, "Hello" and "World", or is it just one string "Hello World"?
Another nice thing is that you can copy that array output from Ruby and Crystal, paste it into a program and it will work. This isn't generally true, but it works really well for primitive types, arrays and hashes, which are used a lot! In Go this isn't true. There aren't even commas! I don't know why.
Now, I'm sure there's a way to show arrays or strings in Go in a better way. For example in Java you can use Arrays.toString
. But not having that as a default adds friction. It's not the most intuitive thing you would expect to happen.
Another thing is consistency and uniformity. In Go there's the Stringer interface that defines the String()
function to turn objects to a string. So what happens if we call String()
on an array?
package main
import "fmt"
func main() {
a := [2]string{"Hello", "World"}
fmt.Println(a.String())
}
We get a compile error:
./prog.go:7:15: a.String undefined (type [2]string has no field or method String)
So it seems only fmt
knows how to turn arrays into strings, and we have to rely on this package for that, but for other types we probably should use String()
.
It's those things that rarely exist in Ruby that I really appreciate. When something works in one way in Ruby you understand it and think "well, I guess this also works for these other types, or in these contexts" and that's almost always true. When that's not the case in a language, it's the moment you start building a collection of exceptions in your head, and when you start looking for answers in StackOverflow.
Coming up next...
I'll talk about making a language your own.
Posted on February 18, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.