Ary Borenszweig
Posted on February 20, 2022
Designing the perfect API
In the last part I talked about how in Ruby method calls are used a lot and in many different ways. If users are going to write calls all the time, well, they better be nice to work with!
It turns out, Ruby calls are extremely flexible and powerful. Let's take a look at a few examples:
# No arguments, splits by whitespace
"a b c".split # => ["a", "b", "c"]
# With a string
"a,b,c".split(",") # => ["a", "b", "c"]
# With a string and a limit
"a,b,c".split(",", 2) # => ["a", "b,c"]
# With a block
"a,b,c".split(",") do |piece|
puts piece # prints "a", "b" and "c" in order
end
# With a block and a limit
"a,b,c".split(",", 2) do |piece|
puts piece # prints "a", then "b,c"
end
# With a regular expression
"a,,,b,,c".split(/,+/) # => ["a", "b", "c"]
# With a regular expression and a limit
"a,,,b,,c".split(/,+/, 2) # => ["a", "b,,c"]
"a\nb\n".each_line do |line|
p line # prints "a\n", then "b\n"
end
"a\nb\n".each_line(chomp: true) do |line|
p line # prints "a", then "b"
end
File.join("usr", "mail") # => "usr/mail"
File.join("usr", "mail", "gumby") # => "usr/mail/gumby"
These are just three methods, namely String#split
, String#each_line
and File.join
, but we can already see a lot of what methods support:
- They can behave differently depending on the number of arguments given. In this case, calling
split
without any arguments splits by whitespace. Then one can pass an extra integer to specify the maximum number of pieces to return. - They can behave differently depending on the type of argument. We can give
split
aString
, but also aRegexp
. - They can also behave differently if a block is given. In the case of
split
, if a block is given then each piece is yielded to the block. - Some arguments can be specified with a name, like in the case of
chomp
, which drops a trailing newline from a string, if there's any. - Some methods can accept a variable number of arguments:
File.join
will join all arguments given, no need to put them first in an array
From a user's perspective, if they want to split a string, all they need to do is call split
. Maybe they want to use a string for that. Maybe a regular expression. Maybe with an optional limit. Or maybe they don't need to capture things in an array and it's fine if each piece is yielded to a block. It's fine! In all these cases the operation is a split
.
I really like this, because:
- The operation is really a
split
. Why have possibly multiple names for the same operation? - Given how flexible this is, it means that if you'd like to design an API, you can choose exactly how users are going to call it. If you prefer arguments to be named, you can do it. If you want optional arguments, you can do that too. You can design the API you had in mind! The language rarely constrains you here.
What about other languages?
So now I'm going to compare this with other languages. If I like this in Ruby it must mean that not every language works like that. I'm just noting that in this series of blog posts I compare Ruby with others just to say "I really like how Ruby handled this" and not "I really dislike how this other language did it."
Java has method overloads so you can define methods that operate on different number of arguments and different types. Then Java doesn't have default arguments, but you can kind of work around it by defining two overloads and having one method call the other one with a default value. Then Kotlin, which compiles to Java bytecode (among other things) supports default arguments and named arguments. Nice!
C# started being very similar to Java but slowly evolved in a great direction: they now support default argument values and even named arguments. Pretty cool!
Go has no overloads, no default argument values and no named arguments. If we want to split a string we have:
-
Split
: splits by a string -
SplitN
: splits by a string returning at most N results.
Then if they wanted these to work with a regex they would need to add SplitRegex
and SplitRegexN
. If they want to add more options to split they will have to combine all these names and options into the function name. I know, I know, it's not a big deal. But I like it better in Ruby. In Ruby if I need to add a limit, I just add it. In Go I need to add it too, but then I need to go back and change the function name to add N
. And of course I might forget what all these functions are named... in Ruby it's always split
!
Rust is also a recent, modern language that has no default arguments and no named arguments.
Then we have Haskell and Elm, which are a completely different story. In Haskell and Elm you can call a function with less arguments than those required, and that's fine. Yes, you read that right. What happens is that the result is another function that expects the missing arguments. I think this is called partial application. Partial application is very powerful and useful! But because it's how the language works by default, this happens:
- If you forget to pass an argument you don't get an error message at the call site. You will get it later when you try to use that result value, where you wanted something but you got a function. This makes understanding what went wrong really hard.
- You can't have default arguments: if you don't pass an argument, should the default value be used, or is it partial application?
- You can't have overloads based on the number of arguments for a similar reason than the last point
In practice having less flexibility in these cases also means having to think about how to name your functions. I know of Haskell functions where '
or ''
appended to the name to support different number of arguments or different types. This in my opinion leads to code that's harder to understand or easily memorize.
In languages without default arguments people would come up with ways to simulate them, which means there's some boilerplate and also a chance that everyone has their own way of doing it, leading to less code uniformity.
One more thing about Ruby methods...
There's more! In Ruby method names don't have restrictions. For example, you can't have a function called void
in Java, it's a keyword. You can't have a function called type
in Go: it's a keyword! I recently found out that in Elm you can't have a record field be named type
, even though, unless I'm mistaken, there would be a way to allow that without a problem.
So this is Ruby:
class Foo
def def
1
end
end
Foo.new.def # => 1
You can use names like begin
, end
, class
... it's all good! Calling these requires a dot, so there's no ambiguity. Actually... you can also call methods that don't have a receiver (something before the dot,) but then you can do self.def
in those cases and it will work.
Crystal
Because we really like how flexibly Ruby is, we wanted the same thing in Crystal. And so, Crystal supports default arguments, named arguments, variable number of arguments, overloading based on different types, and overloading based on whether a method receives a block or not. You can even define methods that require arguments to be passed by name, and overload based on different names:
# Arguments that come after a `*` must be passed by name
def foo(*, x)
"I got an x: #{x}"
end
def foo(*, y)
"I got a y: #{y}"
end
foo x: 1 # => "I got an x: 1"
foo y: 2 # => "I got a y: 2"
In fact, the very first code snippet in this post compiles and runs fine in Crystal, and it behaves almost exactly like in Ruby (in Crystal the chomp
argument is true
by default, while in Ruby it's false
.)
In practice, you get all the flexibility that Ruby has, which lets you define delightful APIs.
Coming up next
It's time we talk about how great Ruby's standard library is!
Posted on February 20, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.