This is the final regular episode in the series, but a few bonus episodes with tier list and series retrospective are coming.
Ruby is the best programming language created so far. The history of programming language design this century is mainly features from Ruby slowly diffusing into every other programming language, newly designed or existing. Hopefully someday a language better than Ruby will be created, but nothing's really attempted that yet.
Many of the most exciting new languages like Crystal, Julia, and Elixir are heavily inspired by Ruby, and everything else keeps getting updated with Ruby-like features. Big features like f-strings in Python or ES6 JavaScript class system, or small features like String.prototype.matchAll
recently added to JavaScript, Ruby continues being the main source of inspiration for the whole world of programming languages design.
Let's explore just some of the many wonderful ways Ruby can be used!
FizzBuzz
We can do the most plain version (it also works as Crystal FizzBuzz):
#!/usr/bin/env ruby
(1..100).each do |n|
if n % 15 == 0
puts "FizzBuzz"
elsif n % 3 == 0
puts "Fizz"
elsif n % 5 == 0
puts "Buzz"
else
puts n
end
end
(1..100)
is a beautiful range from 1
to 100
, no off-by-one issues here.
But hey, if you somehow want a range to almost-N, Ruby also provides 1...100
style ranges (which is 1
to 100-1
). These are useful much less often, as their main use would be iterating arrays by index and all common iteration patterns have much nicer methods.
We can also use any object as a matcher, like a function, or a range, or a regular expression, or a custom matcher object:
#!/usr/bin/env ruby
def divisible_by(n)
proc{|m| m % n == 0}
end
puts (1..100).map{|n|
case n
when divisible_by(15)
"FizzBuzz"
when divisible_by(5)
"Buzz"
when divisible_by(3)
"Fizz"
else
n
end
}
Or we could use pattern matching:
#!/usr/bin/env ruby
puts (1..100).map{|n|
case [n%5, n%3]
in [0, 0]
"FizzBuzz"
in [0, _]
"Buzz"
in [_, 0]
"Fizz"
else
n
end
}
Pattern matching is fairly common feature in functional programming languages, but they generally only allow you to do structural matching on exposed union types (like EmptyLisp
or Cons(head, tail)
), and that's not really very applicable to the object-oriented world. Ruby 3.x tries to do some innovating with OOP-compatible pattern matching.
If you're wondering what's the difference between do ... end
blocks and { ... }
blocks, it's their parsing priority. Ruby also has separate and
/or
and &&
/||
, again with different parsing priority. This is a somewhat controversial choice - it can increase clarity by reducing parentheses, but some people don't like that redundancy.
Fibonacci
Let's start with the obvious one:
#!/usr/bin/env ruby
def fib(n)
if n <= 2
1
else
fib(n-1) + fib(n-2)
end
end
(1..30).each do |n|
puts "fib(#{n}) = #{fib(n)}"
end
This code is as simple as it gets. No off-by-ones. No pointless return
s. No type declarations. And most important of all - string interpolation.
As far as I know Ruby was the first language to introduce full string interpolation. You can open #{ }
anywhere in the string, put any expression you want there, and it will be converted to a String
with .to_s
and placed there. A lot of languages before Ruby allowed you to interpolate variables (generally in language names where variables started with a $
), and Perl 5 can sort of be tricked into supporting this in some limited cases, but Ruby took it to the new level, and after decades of resistance string interpolation is everywhere. And as I discovered in this series, it seems that every language picked a slightly different syntax for it.
Right, but back to the Fibonacci sequence.
#!/usr/bin/env ruby
require "memoist"
extend Memoist
memoize def fib(n)
if n <= 2
1
else
fib(n-1) + fib(n-2)
end
end
(200..210).each do |n|
puts "fib(#{n}) = #{fib(n)}"
end
Ruby is a fairly simple language. You can just get really far with great syntax, everything being an object, everything being an expression, and blocks, a lot of blocks. There's so many features Ruby never added because it could just use blocks for that.
One such missing feature are @
decorators. Back in the first episode I showed how in Python we could use functools.cache
to add memoization to a function. Let's implement memoized Fibonacci in Ruby.
There's no equivalent of functools
in standard library, but we coud use a popular memoist
gem. extend Memoist
adds a memoization tables and some extras like flush_cache
to whatever we're in (usually an object, but we can do this here, because the whole program is also an object!).
Then we call memoize
passing in name of the method we want to memoize. That memoize def fib(n)
is not any new syntax def fib(n) ...
is just an expression like everything else - one that just so happens to return its name (:fib
Symbol), so we first define a method, then call memoize :fib
. This might seem like a trivial thing, but Ruby syntax is full of such nice interactions which result in some beautiful Domain Specific Languages.
However, the way most Ruby users would do this isn't by getting memoist
, but by ||=
something:
#!/usr/bin/env ruby
def fib(n)
@fib ||= {}
@fib[n] ||= begin
if n <= 2
1
else
fib(n-1) + fib(n-2)
end
end
end
(200..210).each do |n|
puts "fib(#{n}) = #{fib(n)}"
end
This is a very common pattern, especially if function doesn't take any variables and it's a one line expression, then it turns a method like this without memoization:
def dictionary
File.readlines("wordle-answers-alphabetical.txt").map(&:chomp)
end
Into a method like this with memoization:
def dictionary
@dictionary ||= File.readlines("wordle-answers-alphabetical.txt").map(&:chomp)
end
For one-line code like this nil
and false
aren't memoized, but that very rarely comes up in practice.
Interestingly Hash
itself, one of core Ruby classes, can be used for some remarkably concise memoization:
#!/usr/bin/env ruby
fib = Hash.new{|_, n| fib[n] = fib[n-1] + fib[n-2]}
fib[1] = 1
fib[2] = 1
(200..210).each do |n|
puts "fib(#{n}) = #{fib[n]}"
end
You can pass a block to Hash.new
and that block will be called every time someone asks for an element that's not in the Hash
. The block can either just compute it, or compute-and-assign it. =
returns the value too. Thanks to blocks and everything being an expression, we get some concise and beautiful code.
The important thing about Ruby metaprogramming is that Ruby makes not just using metaprogramming methods very approachable, but also creating them. This is in stark distinction to many other systems, like various Lisps, where macros are nice to use, but nontrivila macro writing is a dark art.
Let's just implement our own memoize
method, that can memoize whatever we want to!
#!/usr/bin/env ruby
def memoize(name)
m = method(name)
define_method(name) do |*args|
@memo ||= {}
@memo[name] ||= {}
@memo[name][args] ||= m.call(*args)
end
end
memoize def fib(n)
if n <= 2
1
else
fib(n-1) + fib(n-2)
end
end
(200..210).each do |n|
puts "fib(#{n}) = #{fib(n)}"
end
Yeah, it's that easy. It's just 5 lines:
- we grab current implementation of the method we want to memoize with
method(name)
- then we
define_method
a new one passing a block - we initialize
@memo
and@memo[name]
where we'll store memoized values on current object - if
@memo[name][args]
is not set, we call the original method, otherwise we just return the stored value
And finally the result:
$ ./fib5.rb
fib(200) = 280571172992510140037611932413038677189525
fib(201) = 453973694165307953197296969697410619233826
fib(202) = 734544867157818093234908902110449296423351
fib(203) = 1188518561323126046432205871807859915657177
fib(204) = 1923063428480944139667114773918309212080528
fib(205) = 3111581989804070186099320645726169127737705
fib(206) = 5034645418285014325766435419644478339818233
fib(207) = 8146227408089084511865756065370647467555938
fib(208) = 13180872826374098837632191485015125807374171
fib(209) = 21327100234463183349497947550385773274930109
fib(210) = 34507973060837282187130139035400899082304280
Ruby supports big integers out of the box without any special annotations. I think it might have been the first major language to do so. Python sure didn't originally, and at first only introduced them with an extra L
suffix, and Perl/JavaScript/etc. overflowed big integers into floats. Now this feature is fairly common, but far from universal.
One-Liners
Ruby is very concise. It's wild that a language that looks so good is competing head to head against Perl 5 and APL in code golfing competitions.
I'd definitely know something about it, as a reigning London Ruby User Group Ruby Code Golf Champion.
Ruby is one of very few languages which can reasonably be used for shell one-liners. Perl and Raku are about the only others.
For example if you want to extract all the numbers from a file and add them up, super easy:
$ cat budget.txt
Food $200
Data $150
Rent $800
Candles $3600
Utility $150
$ ruby -e 'puts STDIN.read.scan(/\d+/).map(&:to_i).sum' <budget.txt
4900
Or maybe you want to transform some text, like let's say number list items:
$ ruby -ple '$_ = "#{$.}. #{$_}"' < budget.txt
1. Food $200
2. Data $150
3. Rent $800
4. Candles $3600
5. Utility $150
Or want some Cat Facts and don't have jq
installed?
$ curl -s 'https://cat-fact.herokuapp.com/facts' | ruby -rjson -e 'JSON.parse(STDIN.read).each{|x| puts x["text"]}'
Wikipedia has a recording of a cat meowing, because why not?
When cats grimace, they are usually "taste-scenting." They have an extra organ that, with some breathing control, allows the cats to taste-sense the air.
Cats make more than 100 different sounds whereas dogs make around 10.
Most cats are lactose intolerant, and milk can cause painful stomach cramps and diarrhea. It's best to forego the milk and just give your cat the standard: clean, cool drinking water.
Owning a cat can reduce the risk of stroke and heart attack by a third.
Wordle
Let's end this episode with a Wordle:
#!/usr/bin/env ruby
class Wordle
def dictionary
@dictionary ||= File.readlines("wordle-answers-alphabetical.txt").map(&:chomp)
end
def word
@word ||= dictionary.sample
end
def report(guess)
5.times.map{|i|
if guess[i] == word[i]
"🟩"
elsif word.include?(guess[i])
"🟨"
else
"🟥"
end
}.join
end
def play
loop do
print "Guess: "
guess = gets.chomp
puts report(guess)
break if guess == word
end
end
end
Wordle.new.play
Oh sorry, did I say Wordle? I meant a bot that plays Wordle for us:
#!/usr/bin/env ruby
require "memoist"
class WordleBot
extend Memoist
memoize def dictionary
@dictionary ||= File.readlines("wordle-answers-alphabetical.txt").map(&:chomp)
end
memoize def report(guess, word)
5.times.map{|i|
if guess[i] == word[i]
"🟩"
elsif word.include?(guess[i])
"🟨"
else
"🟥"
end
}.join
end
# Try to pick a guess that minimizes worst case outcome
def score(guess)
@candidates.group_by{|word| report(guess, word)}.values.map(&:size).max
end
def best_guess
dictionary.min_by{|guess| score(guess)}
end
# This is quite slow, especially the first word, as we do O(N^2) checks
# So to save some time, result of first one is pre-calculated here
def play
@candidates = dictionary
guess = "arise"
loop do
puts guess
result = gets.chomp
break if result == "🟩🟩🟩🟩🟩"
@candidates.select!{|word| report(guess, word) == result}
guess = best_guess
end
end
end
WordleBot.new.play
To keep things simple, they don't talk to each other, it's all based on copy&paste.
Here's the game view:
$ ./wordle.rb
Guess: arise
🟥🟨🟥🟥🟨
Guess: older
🟥🟥🟥🟨🟨
Guess: berry
🟥🟨🟨🟩🟥
Guess: exert
🟩🟩🟩🟩🟩
And bot view:
$ ./wordlebot.rb
arise
🟥🟨🟥🟥🟨
older
🟥🟥🟥🟨🟨
berry
🟥🟨🟨🟩🟥
exert
🟩🟩🟩🟩🟩
Should you use Ruby?
Definitely yes!
Ruby is the best language for so many domains. It's amazing for one-liners, it's amazing for medium-sized programs, and it's actually even better for larger programs, as Ruby lets you easily create some DSLs to express domain specific logic, then code in that. Ruby (and Rails) is how individuals and small teams were able to compete with far bigger companies so successfully.
Ruby is by no means perfect, it's just very far ahead of every other language. By coding in Ruby you'll experience today what people coding in other languages will wait years or even decades for. It's less common than it used to, but so many programmers still sufdfer in languages without unlimited precission integers, without string interpolation, without .scan(Regexp)
, and without blocks! So many still waste precious hours of their lifes on mindless "missing semicolon" errors! Fortunately languages of today are a lot more Ruby-like than languages were ten years ago, and this trend looks certain to continue.
Will someone ever create a better programming language? I sure hope so. Programming languages keep experimenting with new features, and many languages I covered have some great ideas. But so far nobody brought all those great ideas together into a single work of art like Matz did it with Ruby.
And if you're designing a new programming language, just do what the smartest people have done, and start by copying as much of Ruby as you can (or at least something else good, like let's say Python), and then add your own features on top of that. Far too often programming language designers approach this problem from a blank slate point of view, but really, why not start with state of the art? Crystal is the best example of this approach, but a lot of other languages like Elixir, Julia, and quite a few lesser known ones boldly copied what works to have a headstart.
Code
All code examples for the series will be in this repository.
Top comments (3)
Hello, fellow Rubyist here!♦️
Thanks for a great article!👏🏻
You might find my article on «Features of a dream programming language» interesting. It received some encouraging feedback from Ruby's creator Matz, who thought it was very inspirational. 😀
I found myself writing some bash/posix scripts recently, and was going to invest some time into learning
awk
andsed
properly. I almost forgotten how great ruby is for scripting, maybe I should just use ruby instead! A timely reminder not to sleep on rubyGreat series! Congrats for the consistency! I'm looking forward to seeing more of it.