DEV Community

Tomasz Wegrzanowski
Tomasz Wegrzanowski

Posted on

100 Languages Speedrun: Episode 100: Ruby

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
Enter fullscreen mode Exit fullscreen mode

(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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

This code is as simple as it gets. No off-by-ones. No pointless returns. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Into a method like this with memoization:

  def dictionary
    @dictionary ||= File.readlines("wordle-answers-alphabetical.txt").map(&:chomp)
  end
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
🟩🟩🟩🟩🟩
Enter fullscreen mode Exit fullscreen mode

And bot view:

$ ./wordlebot.rb
arise
πŸŸ₯🟨πŸŸ₯πŸŸ₯🟨
older
πŸŸ₯πŸŸ₯πŸŸ₯🟨🟨
berry
πŸŸ₯🟨🟨🟩πŸŸ₯
exert
🟩🟩🟩🟩🟩
Enter fullscreen mode Exit fullscreen mode

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.

Code for the Ruby episode is available here.

Top comments (2)

Collapse
 
robole profile image
Rob OLeary

I found myself writing some bash/posix scripts recently, and was going to invest some time into learning awk and sed properly. I almost forgotten how great ruby is for scripting, maybe I should just use ruby instead! A timely reminder not to sleep on ruby

Collapse
 
matheusrich profile image
Matheus Richard • Edited

Great series! Congrats for the consistency! I'm looking forward to seeing more of it.