Rekindle the Romance: Take Ruby Out for Another First Date

You Don’t Know Ruby (Anymore!)

Ihcène Medjber
9 min readJun 6, 2024

--

Caution: If you are a senior Ruby developer, this article may hurt your feelings.

Ruby, like all modern programming languages, evolves. The community is constantly introducing new capabilities that not everyone is aware of. Developers that don’t follow and adopt with new features, are probably writing Ruby 1.9.2 code in the era of Ruby 3.3!

It seems that despite Ruby’s maturity and longevity, there’s a tendency among users to stick to old practices, showing resistance to embracing newer features. This reluctance is unfortunate because it’s the introduction of new features that maintains Ruby’s relevance and ensures its place among modern programming languages.

I’ll outline significant recent features and improvements in Ruby versions, including some older but lesser-known ones that, in my opinion, haven’t gained enough traction within the community.

RBS (RuBy Signature)

Ensuring type safety in Ruby code required extensive testing and documentation, leading to potential runtime errors and decreased productivity.

RBS (Ruby Signature) is a new language introduced alongside Ruby 3.1 for describing the types and interfaces of Ruby code. It allows for static type checking and IDE autocompletion, improving code quality and developer productivity.

While Sorbet and similar community-driven alternatives exist, RBS stands out as it’s endorsed by Matz himself. Despite being packaged as a separate gem, it’s considered the official method for embracing the trend of type checking in the programming language landscape.

# Point.rbs
class Point
attr_reader x: Integer
attr_reader y: Integer

def initialize: (x: Integer, y: Integer) -> void
end

# Point.rb
class Point
attr_reader :x, :y

def initialize(x, y)
@x = x
@y = y
end
end

Steep, a static type checker for Ruby, can be integrated into CI/CD pipelines to ensure type safety and catch errors early in the development process.

In a typical GitHub Actions CI/CD pipeline, Steep can be configured to run as part of the testing phase. It analyzes Ruby codebase against RBS files, checking for type errors and providing feedback to developers.

# .github/workflows/ci.yml
name: CI

on: [push, pull_request]

jobs:
build:
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v2

- name: Setup Ruby
uses: actions/setup-ruby@v1
with:
ruby-version: '3.3'

- name: Install dependencies
run: bundle install

- name: Run Steep
run: bundle exec steep check

Integrating Steep into CI/CD pipelines helps maintain code quality by enforcing type safety and preventing runtime errors. It promotes best practices and improves the reliability of Ruby applications.

RBS adoption may require a significant investment in learning and tooling setup. It can also introduce overhead in maintaining type annotations alongside the codebase.

Pattern matching

Pattern matching was one of the most anticipated features for Ruby 2.7. Matz, the creator of Ruby, mentioned that he wanted to include pattern matching to make Ruby more expressive and to align it with modern programming language trends. The community extensively discussed it on Ruby’s mailing lists and issue trackers. The feature went through several iterations and refinements before its final inclusion.

Prior to pattern matching, destructuring and matching complex data structures required verbose and repetitive code using multiple if or case statements. This often resulted in less readable and maintainable code.

Pattern matching, introduced in Ruby 2.7 and enhanced in Ruby 3.0, allows for more concise and expressive destructuring and matching of data. This makes the code cleaner and more maintainable.

Example:

def company_location_contact_id(company_location_id)
query = <<~GRAPHQL
query($company_location_id: ID!) {
companyLocation(id: $company_location_id) {
...
}
}
GRAPHQL

response =
@client.query(
query:,
variables: { company_location_id: "gid://shopify/CompanyLocation/#{company_location_id}" }
).body

case response.deep_symbolize_keys
in errors: [{ message: error_message }]
Rollbar.error("#{error_message} for", company_location_id:)
in data: { companyLocation: nil }
Rollbar.error("Company location not found", company_location_id:)
in data: { companyLocation: { roleAssignments: { edges: [{ node: { companyContact: { id: contact_id } } }] } } }
contact_id
end
end

Without pattern matching, the code would typically involve nested conditionals or error-prone manual parsing of the response data. Here’s how the same functionality might be implemented without pattern matching:

def company_location_contact_id(company_location_id)
query = <<~GRAPHQL
query($company_location_id: ID!) {
companyLocation(id: $company_location_id) {
...
}
}
GRAPHQL

response = @client.query(
query: query,
variables: { company_location_id: "gid://shopify/CompanyLocation/#{company_location_id}" }
).body

data = response.deep_symbolize_keys

if data.key?(:errors) && data[:errors].is_a?(Array) && data[:errors].first.key?(:message)
error_message = data[:errors].first[:message]
Rollbar.error("#{error_message} for", company_location_id: company_location_id)
elsif data.key?(:data) && data[:data].is_a?(Hash) && data[:data][:companyLocation].nil?
Rollbar.error("Company location not found", company_location_id: company_location_id)
elsif data.key?(:data) && data[:data].is_a?(Hash) &&
data[:data][:companyLocation].is_a?(Hash) &&
data[:data][:companyLocation][:roleAssignments].is_a?(Hash) &&
data[:data][:companyLocation][:roleAssignments][:edges].is_a?(Array) &&
data[:data][:companyLocation][:roleAssignments][:edges].first.is_a?(Hash) &&
data[:data][:companyLocation][:roleAssignments][:edges].first[:node].is_a?(Hash) &&
data[:data][:companyLocation][:roleAssignments][:edges].first[:node][:companyContact].is_a?(Hash) &&
data[:data][:companyLocation][:roleAssignments][:edges].first[:node][:companyContact].key?(:id)
data[:data][:companyLocation][:roleAssignments][:edges].first[:node][:companyContact][:id]
end
end

As you can see, without pattern matching, the code becomes significantly more verbose and error-prone. Each condition needs to be carefully checked and nested, leading to potential readability issues and increased risk of bugs. Pattern matching simplifies this process by providing a concise and readable way to destructure and match complex data structures.

However, pattern matching can add complexity if overused or used inappropriately. It may also require developers to learn new syntax and patterns, which could slow down adoption initially.

One-Line Pattern Matching:

Destructuring hashes or arrays often required multiple lines of code, making simple assignments cumbersome and verbose.

One-line pattern matching, introduced in Ruby 2.7, allows destructuring to be performed in a single line, making the code more concise and readable.

data = { user: { name: "Alice", details: { age: 25, city: "Paris" } } }
data => { user: { name:, details: { age:, city: } } }
puts "Name: #{name}, Age: #{age}, City: #{city}"

The one-line syntax may be less readable for complex patterns, and developers need to be familiar with the new syntax to fully leverage its benefits.

Rightward Assignment:

Similar to the previous feature, the `=>` operator can be used without deconstruction and pattern matching, just in a way to assign variables in a different order that can feel more natural with the direction flow of the code.

Rightward assignment, introduced in Ruby 3.4 (preview), simplifies the syntax by allowing the assignment to be performed directly in a more readable and concise manner.

read_data() => user_data => { user: { name:, details: { age:, city: } }
save!(user_data)
puts "Name: #{name}, City: #{city}"

This syntax might be unfamiliar to some developers, and potentially confusing. It can also make the code less readable if overused in complex expressions.

Refinements

Modifying core classes globally could lead to unexpected side effects and conflicts, especially in large codebases or shared libraries.

Refinements, introduced in Ruby 2.0, provide a way to make scoped modifications to core classes. This allows changes to be limited to specific contexts, reducing the risk of side effects.

module ArrayExtensions
refine Array do
def to_hash
Hash[*self.flatten]
end
end
end

class Converter
using ArrayExtensions

def self.convert(array)
array.to_hash
end
end

puts Converter.convert([[:key1, "value1"], [:key2, "value2"]])

This is a relatively old (but gold) feature that I’ve seen used in too few code bases. It has some drawbacks but when we are aware of them, it becomes a powerful tool.

One significant trap of using refinements is the potential for unexpected behavior when the refinement is not active in the context where the code runs. Since refinements only affect code within a specific lexical scope, code that relies on refinements may behave differently depending on where it is executed. This can lead to inconsistencies and bugs that are difficult to diagnose, especially if the code is reused or executed in different contexts.

Just be aware of this.

Enumerator::Lazy

Chaining operations on large collections could be inefficient, as intermediate arrays were created, consuming memory and processing time.

Lazy enumerators, introduced in Ruby 2.0, allow for efficient chaining of operations without creating intermediate arrays. This results in better performance and lower memory usage for large collections.

lazy_numbers = (1..Float::INFINITY).lazy
result = lazy_numbers.select { |n| n % 2 == 0 }
.map { |n| n * n }
.take(10)
.to_a

puts result.inspect

Drawbacks: Lazy enumerators can make debugging more challenging since the operations are deferred until the end of the chain. Additionally, not all enumerator methods are available in lazy enumerators.

Enumerator::Chain

Chaining multiple enumerables together required concatenation and was less expressive, often leading to less readable code.

Enumerator::Chain, introduced in Ruby 2.6, provides a clean and expressive way to chain enumerators together, improving readability and maintainability.

evens = (2..10).step(2)
odds = (1..9).step(2)
combined = evens.each.chain(odds.each)

puts combined.to_a.inspect

Drawbacks: While it simplifies chaining, it can introduce another concept for developers to learn. It may also obscure the source of data if overused in complex chaining scenarios.

Module#prepend

Using include to mix in modules could make it difficult to override methods in a controlled manner, often resulting in complex and less maintainable code.

Module#prepend, introduced in Ruby 2.0, allows a module to be inserted into the method lookup chain before the class itself, making method overrides more predictable and controlled.

module Logging
def process
puts "Logging before processing"
super
puts "Logging after processing"
end
end

class DataProcessor
def process
puts "Processing data"
end
end

class CustomProcessor < DataProcessor
prepend Logging
end

processor = CustomProcessor.new
processor.process

Prepend can complicate the method lookup chain, making it harder to understand and debug the code. It also requires developers to understand the differences between include and prepend.

End-less Method Definition

Defining simple methods required multiple lines of code, even when the method body was a single expression. There was a way to write oneliner method definition using “;” but that was ugly.

def power(base, exponent); base**exponent ; end

Endless method definitions, introduced in Ruby 3.0, provide a concise syntax for methods that return a single expression, reducing boilerplate and improving readability.

class Calculator
def add(a, b) = a + b
def multiply(a, b) = a * b
def power(base, exponent) = base**exponent
end

calc = Calculator.new
puts calc.add(2, 3) # Output: 5
puts calc.multiply(4, 5) # Output: 20
puts calc.power(2, 3) # Output: 8

The new syntax can be less familiar to some developers, potentially leading to confusion. It also might reduce readability when overused for more complex methods.

`it` parameter

Using block variables explicitly could be verbose, especially for simple operations like Enumerable methods such as map, and filter that expect only one parameter, leading to repetitive and less concise code.

The it parameter, introduced in Ruby 3.0, simplifies block syntax by providing a default block parameter, making code more concise and readable for simple operations.

["apple", "banana", "cherry"].map { it.upcase.reverse }

In Ruby 2.7, numbered parameters (_1, _2, and so on) were introduced as an experimental feature aiming to simplify block syntax by automatically referencing arguments. However, concerns were raised within the community regarding readability and potential confusion, similar to the discussions surrounding the default it parameter. Rubocop defaulted configuration to prevent using numbered parameters other than _1, which is the exact same as using it , with a nicer name.

Drawbacks: The implicit nature of it can make the code less readable for more complex operations. It also introduces a new convention that developers must learn.

String#casecmp?

Case-insensitive string comparison required more verbose code, often involving multiple method calls or RegExp, reducing readability.

String#casecmp?, introduced in Ruby 2.4, provides a straightforward method for case-insensitive comparison, making the code more concise and expressive.

strings = ["Hello", "world", "HELLO"]
matches = strings.select { |s| s.casecmp?("hello") }
puts matches.inspect # => ["Hello", "HELLO"]

Object#yield_self and then

Chaining operations on an object could be less readable without intermediate variables, leading to more verbose and less fluid code. The code reading direction can also feel unnatural when calls are nested.

Object#yield_self, introduced in Ruby 2.5, and its alias then, introduced in Ruby 3.0, allow for cleaner chaining of operations, improving code readability and fluidity.

result = "hello"
.yield_self { |str| str.upcase }
.then { |str| str + " WORLD" }

puts result # Output: "HELLO WORLD"

Drawbacks: These methods can be easily misused and overused, leading to less readable code if the chain becomes too long or complex.

Endless Ranges:

Defining ranges with no upper or lower limit required the use of Float::INFINITY or other workarounds, which could be cumbersome and less intuitive.

Endless ranges, introduced in Ruby 2.6, allow ranges to be defined with no limits on one side, making code more concise and expressive, especially for infinite sequences or open-ended ranges.

alphabet = ('a'..'z')
numbers = (1..)
positive_even_numbers = (2..).step(2)

It can be a powerful shortcut with ActiveRecord:

@posts = 
Post.where(some_value: ..min_value).order(:id).paginate(page: params[:page])
# Same as where('some_value < ?', min_value)

Endless ranges can lead to unexpected behavior if not used carefully, particularly in cases where the range is iterated over indefinitely. They can also be less readable if used excessively or inappropriately.

Conclusion

Ruby has evolved significantly, with many powerful features that improve code readability, efficiency, and expressiveness. By leveraging these new capabilities, you can write modern, idiomatic Ruby code. If you’re still writing Ruby 1.9-style code, it’s time to explore and embrace these new features to stay up-to-date with the latest Ruby standards.

--

--

Ihcène Medjber

An experienced software developer and entrepreneur with a strong background in building innovative solutions for various industries in Ruby on Rails and React.