Control Flow | Ruby - Wyatt's Notes
Conditionals
Section titled “Conditionals”if / elsif / else
Section titled “if / elsif / else”The if statement evaluates a condition and executes the corresponding branch. The elsif keyword handles additional conditions, and else provides a fallback:
score = 85
if score >= 90 puts "Grade: A"elsif score >= 80 puts "Grade: B"elsif score >= 70 puts "Grade: C"else puts "Grade: F"end## => "Grade: B"
## if as a modifier (postfix)puts "Pass" if score >= 60puts "Fail" if score < 60
# if as an expression (returns the last evaluated value)grade = if score >= 90 "A" elsif score >= 80 "B" elsif score >= 70 "C" else "F" endputs grade # => "B"
# Nested ifif user if user.active? if user.premium? puts "Premium active user" end endendunless
Section titled “unless”unless is the negation of if. It executes the body when the condition is falsy:
user = nil
unless user puts "No user found"end# => "No user found"
# unless with elseunless score >= 60 puts "Failed"else puts "Passed"end
# unless as modifierputs "No user" unless user
# Avoid double negatives -- prefer if for complex negations# Badunless !user.active? deactivate(user)end
# Goodif user.active? deactivate(user)endTernary Operator
Section titled “Ternary Operator”The ternary operator is a concise inline conditional:
status = score >= 60 ? "Pass" : "Fail"
# Equivalent tostatus = if score >= 60 "Pass" else "Fail" end
# Nested ternaries (avoid when possible)label = score >= 90 ? "A" : score >= 80 ? "B" : score >= 70 ? "C" : "F"
# Use case/when instead for multiple conditionscase / when
Section titled “case / when”The case statement is Ruby”s switch construct. It supports both value matching and condition matching:
# Value matching (=== operator)action = "delete"
case actionwhen "create" puts "Creating resource"when "read" puts "Reading resource"when "update" puts "Updating resource"when "delete" puts "Deleting resource"else puts "Unknown action"end
# Case with expressionscase scorewhen 90..100 then "A"when 80...90 then "B"when 70...80 then "C"when 0...70 then "F"else "Invalid"end
# Case with multiple valuesrole = "admin"
case rolewhen "admin", "superadmin" puts "Full access"when "editor" puts "Edit access"when "viewer" puts "Read-only access"end
# Case with regexcase commandwhen /^create/ puts "Create command"when /^delete/ puts "Delete command"when /^update/ puts "Update command"else puts "Unknown command"end
# Case with procs/lambdas (Ruby 2.7+ pattern matching alternative)even = -> (n) { n.even? }odd = -> (n) { n.odd? }
case 4when even puts "Even number"when odd puts "Odd number"end
# Case as expressioncategory = case score when 90..100 then "excellent" when 80...90 then "good" when 70...80 then "average" else "below average" end
# Case with no argument (acts like a series of if/elsif)number = 42
casewhen number < 0 puts "Negative"when number == 0 puts "Zero"when number > 0 puts "Positive"endPattern Matching (Ruby 2.7+, stable in 3.0+)
Section titled “Pattern Matching (Ruby 2.7+, stable in 3.0+)”# case/in with pattern matchingcase { status: 200, body: "OK" }in { status: 200..299, body: } puts "Success: #{body}"in { status: 400..499 } puts "Client error"in { status: 500..599 } puts "Server error"end
# Array pattern matchingcase [1, 2, 3]in [Integer, Integer] puts "Two integers"in [Integer, Integer, Integer] puts "Three integers"end
# One-line pattern matching with =>{ status: 404 } => { status: }puts status # => 404
# Guard clauses with if in patternscase [1, 2, 3]in [a, b, c] if a == 1 puts "First element is 1"endThe while loop executes as long as the condition is truthy:
count = 5while count > 0 puts "Count: #{count}" count -= 1end# => Count: 5, 4, 3, 2, 1
# while as modifiercount = 5count -= 1 while count > 0
# Infinite loop with breakwhile true line = gets.chomp break if line == "quit" puts "You typed: #{line}"end
# Common pattern: reading inputwhile (line = gets) break if line.strip.empty? process(line)enduntil is the inverse of while — it executes while the condition is falsy:
count = 0until count >= 5 puts count count += 1end# => 0, 1, 2, 3, 4
# until as modifiercount = 0count += 1 until count >= 5The for loop iterates over collections:
# for over arraysfor element in [10, 20, 30] puts elementend
# for over rangesfor i in 1..5 puts iend
# for over hashesfor key, value in { a: 1, b: 2, c: 3 } puts "#{key}: #{value}"end
# Note: for does NOT create a new scope -- the loop variable leaksfor i in 1..3 # i is accessible after the loopendputs i # => 3
# Prefer each over for -- it creates a proper block scope[1, 2, 3].each do |i| # i is local to this blockend# i is not accessible here (nil in Ruby 3.0+)The loop method creates an infinite loop, in standard practice combined with break:
loop do print "Enter command (q to quit): " input = gets.chomp break if input == "q" process_command(input)end
# loop with a countercount = 0loop do count += 1 break if count >= 10end
# loop returns the value passed to breakresult = loop do break 42 if some_conditionend# result => 42Iterators
Section titled “Iterators”Ruby emphasises iteration over manual loops. Iterators are methods that yield values to a block:
The most fundamental iterator — yields each element of a collection:
# Array each[1, 2, 3].each do |element| puts elementend
# Hash each{ a: 1, b: 2 }.each do |key, value| puts "#{key}: #{value}"end
# String each_char"hello".each_char do |char| puts charend
# Range each(1..5).each { |i| puts i }
# each_line for strings"line1\nline2\nline3".each_line do |line| puts line.chompend
# each_with_index["a", "b", "c"].each_with_index do |element, index| puts "#{index}: #{element}"end# => 0: a, 1: b, 2: c5.times do |i| puts "Iteration #{i}"end# => Iteration 0, 1, 2, 3, 4
5.times { puts "Hello" } # prints "Hello" 5 times
# times returns self (the integer)result = 3.times.map { |i| i * 2 } # => [0, 2, 4]upto and downto
Section titled “upto and downto”1.upto(5) { |i| puts i }# => 1, 2, 3, 4, 5
5.downto(1) { |i| puts i }# => 5, 4, 3, 2, 1
# With step1.step(10, 2) { |i| puts i }# => 1, 3, 5, 7, 9
# upto with string"a".upto("e") { |c| puts c }# => a, b, c, d, eeach_with_index and with_index
Section titled “each_with_index and with_index”["apple", "banana", "cherry"].each_with_index do |fruit, index| puts "#{index}. #{fruit}"end
# with_index on any iterator that yields a single value(10..20).each.with_index do |number, index| puts "#{index}: #{number}"end
# with_index with an offset["a", "b", "c"].each.with_index(1) do |letter, index| puts "#{index}. #{letter}"end# => 1. a, 2. b, 3. cHigher-Order Iterators
Section titled “Higher-Order Iterators”# map/collect -- transform each element[1, 2, 3].map { |n| n ** 2 }# => [1, 4, 9]
# select/reject -- filter elements[1, 2, 3, 4, 5].select { |n| n.even? }# => [2, 4]
[1, 2, 3, 4, 5].reject { |n| n.even? }# => [1, 3, 5]
# reduce/inject -- accumulate[1, 2, 3, 4, 5].reduce(0) { |sum, n| sum + n }# => 15
[1, 2, 3, 4, 5].reduce(1, :*)# => 120 (product using symbol shorthand)
# group_by -- group by key["alice", "bob", "charlie"].group_by { |name| name.length }# => {5=>["alice"], 3=>["bob"], 7=>["charlie"]}
# sort_by -- sort by key["banana", "apple", "cherry"].sort_by { |word| word.length }# => ["apple", "banana", "cherry"]
# partition -- split into two arrays[1, 2, 3, 4, 5].partition { |n| n.even? }# => [[2, 4], [1, 3, 5]]
# each_slice -- iterate in groups(1..10).each_slice(3).to_a# => [[1,2,3], [4,5,6], [7,8,9], [10]]
# each_cons -- iterate over consecutive groups[1, 2, 3, 4].each_cons(2).to_a# => [[1,2], [2,3], [3,4]]
# cycle -- repeat endlessly[:a, :b].cycle.take(6)# => [:a, :b, :a, :b, :a, :b]
# zip -- combine multiple arrays[1, 2, 3].zip([4, 5, 6])# => [[1,4], [2,5], [3,6]]
# flat_map/collect_concat -- flatten mapped results[[1, 2], [3, 4]].flat_map { |arr| arr.map { |n| n * 10 } }# => [10, 20, 30, 40]
# all?/any?/none?/one?[1, 2, 3].all? { |n| n > 0 } # => true[1, 2, 3].any? { |n| n > 5 } # => false[1, 2, 3].none? { |n| n > 5 } # => true[1, 2, 3].one? { |n| n == 2 } # => true
# count with block[1, 2, 3, 4, 5].count { |n| n.even? }# => 2
# find/detect -- first matching element[1, 2, 3, 4, 5].find { |n| n > 3 }# => 4
# max_by/min_by%w[apple banana cherry].max_by(&:length)# => "banana"
%w[apple banana cherry].min_by(&:length)# => "apple"Chaining Iterators
Section titled “Chaining Iterators”# Multiple transformations chainedresult = (1..100) .select { |n| n.even? } .map { |n| n ** 2 } .select { |n| n > 100 && n < 1000 } .sort .first(5)# => [144, 196, 256, 324, 400]
# Lazy evaluation for large or infinite collections# Avoids creating intermediate arraysresult = (1..Float::INFINITY) .lazy .select { |n| n.even? } .map { |n| n ** 2 } .select { |n| n < 1000 } .take(5) .to_a# => [4, 16, 36, 64, 100]
# Lazy avoids processing all elementshuge_range = (1..1_000_000) .lazy .select { |n| n % 17 == 0 } .map { |n| n * 2 } .first(3) .to_a# => [34, 68, 102]Loop Control: next, break, redo
Section titled “Loop Control: next, break, redo”Skips to the next iteration of the loop:
# Skip odd numbers(1..10).each do |i| next if i.odd? puts iend# => 2, 4, 6, 8, 10
# next with a value (returns the value instead of the block result)result = [1, 2, 3, 4, 5].map do |n| next nil if n.odd? # returns nil for odd numbers n * 2 # returns doubled value for evenend# => [nil, 4, nil, 8, nil]
# Skip to next with inline form[1, 2, 3, 4, 5].each { |i| next if i < 3; puts i }# => 3, 4, 5Exits the loop entirely:
# Find first element matching a conditionresult = [1, 2, 3, 4, 5, 6, 7].find do |n| break n if n > 4end# result => 5
# Break returns a value from the entire method callfound = loop do x = rand(1..100) break x if x > 90end# found => some number > 90
# break with a value in mapresult = [1, 2, 3, 4, 5].map do |n| break :done if n == 3 n * 2end# result => :done (break exits map entirely)Restarts the current iteration without re-evaluating the condition:
# Retry input until validvalid_input = nil
until valid_input print "Enter a number: " input = gets.chomp
if input.match?(/\A\d+\z/) valid_input = input.to_i else puts "Invalid input. Try again." endend
# redo in loopscount = 0results = []
3.times do value = rand(10) if value < 3 redo # retry this iteration else results << value count += 1 endend# Results will have 3 values all >= 3retry (exception handling)
Section titled “retry (exception handling)”retry restarts the begin block from the beginning. Use with caution to avoid infinite loops:
# Retry with a counterattempts = 0max_attempts = 3
begin attempts += 1 connect_to_databaserescue ConnectionError if attempts < max_attempts sleep(2 ** attempts) # exponential backoff retry else raise "Failed after #{max_attempts} attempts" endend
# retry restarts from begin# Use sparingly -- prefer explicit retry loopsdef fetch_with_retry(url, max_retries: 3) retries = 0 begin response = HTTP.get(url) raise TimeoutError if response.status == 408 response rescue TimeoutError, SocketError retries += 1 if retries <= max_retries sleep(2 ** retries) retry else raise end endendGuard Clauses
Section titled “Guard Clauses”Guard clauses improve readability by handling edge cases early:
# Without guard clauses (arrow anti-pattern)def process_order(order) if order if order.paid? if order.items.any? ship(order) else raise "Empty order" end else raise "Unpaid order" end else raise "No order" endend
# With guard clausesdef process_order(order) raise "No order" unless order raise "Unpaid order" unless order.paid? raise "Empty order" unless order.items.any?
ship(order)end
# Another exampledef calculate_discount(user, order) return 0 unless user
base = order.total > 100 ? 0.1 : 0.0 return base unless user.member?
base + 0.05endException Handling
Section titled “Exception Handling”begin / rescue / ensure / else
Section titled “begin / rescue / ensure / else”Ruby uses begin/rescue blocks for exception handling:
# Basic structurebegin # Code that might raise an exception result = 10 / 0rescue ZeroDivisionError # Handle the specific exception puts "Cannot divide by zero"end
# Full structurebegin file = File.open("data.txt") content = file.read result = process(content)rescue Errno::ENOENT puts "File not found"rescue IOError => e puts "I/O error: #{e.message}"rescue => e # Catch-all (only rescue StandardError subclasses) puts "Unexpected error: #{e.message}" puts e.backtrace.first(5)else # Executes only if no exception was raised puts "Processed successfully: #{result}"ensure # Always executes (like finally in Java) file&.closeendException Hierarchy
Section titled “Exception Hierarchy”Ruby exceptions form a class hierarchy. Exception is the root, but you should in standard practice only rescue StandardError (or its subclasses) in application code:
Exception├── SystemExit # exit(), exit!├── SignalException # signals (SIGINT, SIGTERM)├── NoMemoryError # out of memory├── ScriptError│ ├── LoadError│ ├── NotImplementedError│ └── SyntaxError├── SecurityError├── StandardError # <-- rescue this in app code│ ├── ArgumentError│ ├── IOError│ │ ├── EOFError│ │ └── Errno::* (file system errors)│ ├── IndexError│ ├── KeyError # Ruby 2.5+│ ├── LocalJumpError│ ├── NameError│ ├── RangeError│ ├── RegexpError│ ├── RuntimeError│ ├── StopIteration│ ├── SyntaxError (in StandardError branch too)│ ├── ThreadError│ ├── TypeError│ └── ZeroDivisionError└── SystemStackError# Rescue specific exceptionsbegin JSON.parse(invalid_json)rescue JSON::ParserError => e puts "Invalid JSON: #{e.message}"end
begin File.read("/nonexistent/file.txt")rescue Errno::ENOENT => e puts "File not found: #{e.message}"rescue Errno::EACCES => e puts "Permission denied: #{e.message}"end
begin array[100]rescue IndexError => e puts "Index out of bounds: #{e.message}"endThe raise method creates and raises exceptions:
# Raise with a string messageraise "Something went wrong"
# Raise a specific exception classraise ArgumentError, "Invalid argument"
# Raise with a formatted messageraise ArgumentError, "Expected #{expected}, got #{actual}"
# Raise an existing exception objecterror = RuntimeError.new("Custom error")raise error
# Re-raise the current exception (in a rescue block)begin risky_operationrescue => e # do some logging raise # re-raises the same exception with original backtraceend
# raise with ensurebegin raise "Initial error"rescue => e puts "Caught: #{e.message}" raise RuntimeError, "Wrapped: #{e.message}"endCustom Exceptions
Section titled “Custom Exceptions”Define custom exception classes by inheriting from StandardError:
# Base custom exceptionclass ApplicationError < StandardError; end
# Specific exceptionsclass ValidationError < ApplicationError attr_reader :field, :value
def initialize(message = "Validation failed", field: nil, value: nil) @field = field @value = value super(message) endend
class NotFoundError < ApplicationError attr_reader :resource, :id
def initialize(message = "Resource not found", resource: nil, id: nil) @resource = resource @id = id super(message) endend
class AuthenticationError < ApplicationError; endclass AuthorizationError < ApplicationError; endclass PaymentError < ApplicationError; end
# Using custom exceptionsclass UserService def find_user!(id) user = User.find_by(id: id) raise NotFoundError.new("User not found", resource: "User", id: id) unless user user end
def update_email!(user, new_email) raise ValidationError.new("Invalid email format", field: "email", value: new_email) unless new_email.match?(/\A[^@\s]+@[^@\s]+\z/)
user.update!(email: new_email) end
def authenticate!(username, password) user = User.find_by(username: username) raise NotFoundError, "User "#{username}' not found" unless user raise AuthenticationError, "Invalid password" unless user.authenticate(password) raise AuthorizationError, "Account suspended" unless user.active? user endend
# Rescue custom exceptionsbegin user = UserService.new.authenticate!("alice", "wrong")rescue AuthenticationError => e puts "Auth failed: #{e.message}"rescue NotFoundError => e puts "Not found: #{e.message}"rescue ApplicationError => e puts "App error: #{e.class}: #{e.message}"endException Object Methods
Section titled “Exception Object Methods”begin raise ArgumentError, "Invalid value: nil"rescue => e e.class # => ArgumentError e.message # => "Invalid value: nil" e.backtrace # => Array of call stack strings e.backtrace[0] # => first line of backtrace e.backtrace.first(3) # => first 3 lines e.cause # => the exception that caused this one (Ruby 2.5+) e.inspect # => "#<ArgumentError: Invalid value: nil>" e.to_s # => "Invalid value: nil"
# Exception wrapping begin raise "original error" rescue => original raise StandardError, "wrapped: #{original.message}" endrescue StandardError => e puts e.cause # => nil (no cause tracking unless using raise cause: option)end
# Ruby 3.1+ cause trackingbegin raise "inner"rescue => inner raise "outer"rescue => outer outer.cause # => #<RuntimeError: inner>endretry with Exceptions
Section titled “retry with Exceptions”# Retry with exponential backoffclass ResilientClient def initialize(max_retries: 3, base_delay: 1) @max_retries = max_retries @base_delay = base_delay end
def get(url) retries = 0 begin response = HTTP.get(url) unless response.status == 200 raise RuntimeError, "HTTP #{response.status}" end response rescue SocketError, TimeoutError, RuntimeError => e retries += 1 if retries <= @max_retries delay = @base_delay * (2 ** (retries - 1)) sleep(delay) retry else raise end end endendRescue in Method Definitions
Section titled “Rescue in Method Definitions”Ruby allows a compact rescue syntax directly in method definitions:
# Instead of wrapping entire method in begin/rescuedef fetch_data(url) response = HTTP.get(url) JSON.parse(response.body)rescue JSON::ParserError {}rescue SocketError, TimeoutError => e { error: e.message }end
# Equivalent todef fetch_data(url) begin response = HTTP.get(url) JSON.parse(response.body) rescue JSON::ParserError {} rescue SocketError, TimeoutError => e { error: e.message } endend
# Multiple rescue clausesdef process(input) validate(input) transform(input) save(input)rescue ValidationError => e { status: 422, error: e.message }rescue SaveError => e { status: 500, error: e.message }rescue => e { status: 500, error: "Unexpected: #{e.message}" }endEnsure for Cleanup
Section titled “Ensure for Cleanup”# File handling with ensuredef read_config(path) file = nil begin file = File.open(path, "r") file.read ensure file&.close endend
# Database transaction with ensuredef with_transaction connection = DatabasePool.checkout begin connection.begin_transaction yield(connection) connection.commit rescue => e connection.rollback raise ensure DatabasePool.checkin(connection) endend
# Lock management with ensuredef with_lock(resource) lock = acquire_lock(resource) begin yield ensure release_lock(lock) endend
# Ensure always executesdef example begin raise "error" rescue puts "rescued" ensure puts "ensure runs" # always executes end # Output: "rescued", then "ensure runs"endCommon Control Flow Patterns
Section titled “Common Control Flow Patterns”Early Return
Section titled “Early Return”# Clear, linear flow with early returnsdef process_payment(order, user) return { error: "Order required" } unless order return { error: "User required" } unless user
unless order.items.any? return { error: "Empty order" } end
if order.total > user.credit_limit return { error: "Exceeds credit limit" } end
charge = PaymentGateway.charge(user.payment_method, order.total) return { error: charge.error } unless charge.success?
{ success: true, charge_id: charge.id }endFlattening Nested Conditions
Section titled “Flattening Nested Conditions”# Before: deeply nesteddef handle_request(request) if request.authenticated? if request.authorised? if request.valid? process(request) else respond_with_error(400, "Invalid request") end else respond_with_error(403, "Not authorised") end else respond_with_error(401, "Not authenticated") endend
# After: flattened with guardsdef handle_request(request) return respond_with_error(401, "Not authenticated") unless request.authenticated? return respond_with_error(403, "Not authorised") unless request.authorised? return respond_with_error(400, "Invalid request") unless request.valid?
process(request)endConditional Assignment
Section titled “Conditional Assignment”# ||= for default valuesname = nilname ||= "Anonymous"puts name # => "Anonymous"
# Only assigns if variable is nil or falseconfig = {}config[:timeout] ||= 30config[:timeout] ||= 60 # still 30
# &&= for conditional updatedebug = truedebug &&= false # debug => falsedebug &&= false # debug is still false, no change
# Ternary for computed defaults timeout = ENV.fetch("TIMEOUT", 30).to_i mode = ENV.key?("DEBUG") ? :debug : :productionflowchart TD
A[1_Control Flow] --> B[Key Concepts]
A --> C[Core Principles]
A --> D[Practical Applications]
B --> E[Fundamental definitions]
C --> F[Design patterns]
D --> G[Real-world usage]Intuition
Section titled “Intuition”Ruby’s if/unless are like two sides of a coin. If is the standard choice: do something when a condition is true. Unless is the contrarian: do something when a condition is false. Using unless with a negative condition is like saying do not not do something, which is confusing. The golden rule: use unless only when it makes the code clearer, not just shorter.
Ruby’s blocks are like instructions you hand to a method. You give the method a task, and it decides when and how many times to execute it. This is like handing a recipe to a chef: you describe what to make, but the chef handles the cooking. The method might call the block once, many times, or not at all.
Common Mistakes
Section titled “Common Mistakes”Confusing = with == in conditional expressions. Writing if x = 5 assigns 5 to x and evaluates as truthy, rather than comparing x to 5. This is a silent bug that produces unexpected behaviour. Always use == for comparison in conditionals: if x == 5.
Not using blocks for iteration. Ruby’s iterator methods with blocks (each, map, select) create proper block scoping, while for loops leak the loop variable. Use [1, 2, 3].each { |x| ... } instead of for x in [1, 2, 3] to keep variables scoped correctly and write more idiomatic Ruby.
Forgetting that unless with else is confusing. The unless...else construct reads unnaturally: “unless condition, do this, else do that.” Most Ruby style guides recommend using if with the negated condition instead. unless x is fine, but unless x; ...; else; ...; end should be rewritten as if x; ...; else; ...; end.
Cross-References
Section titled “Cross-References”- Variables and Types - How Ruby’s truthy/falsy values affect conditional evaluation
- Methods and Blocks - How blocks and procs enable functional-style control flow patterns
- Metaprogramming - How method_missing and eval can create custom control structures