Functions #
In Ruby, the accurate term isn’t “function” but “method” — because every callable piece of code is attached to an object, whether it’s a class instance, the class itself, or the top-level main object. But beyond terminology, methods in Ruby have several characteristics that distinguish them from functions in other languages: there’s always a return value (the last expression is returned implicitly), parentheses are optional when calling, and the parameter system is extremely flexible — from default values and keyword arguments to splat and double splat for hashes. Understanding all of this is the foundation for writing Ruby that’s expressive and easy for other developers to use.
Defining and Calling Methods #
Methods are defined with the def keyword and end with end. Method names follow the snake_case convention:
def greet(name)
"Hello, #{name}!"
end
puts greet("Dewi") # => Hello, Dewi!
Parentheses when calling a method are optional in Ruby — but there’s a convention to follow:
# Parentheses MAY be omitted when calling
puts greet "Dewi" # valid
puts greet("Dewi") # also valid
# CONVENTION: use parentheses when there are arguments, omit when there aren't
puts greet("Dewi") # ← clearer that there are arguments
list_products # ← no arguments, parentheses unnecessary
# ANTI-PATTERN: omitting parentheses when there are arguments — ambiguous
result = calculate width, height # hard to distinguish from variables
result = calculate(width, height) # clearly a method call
Implicit Return Values #
In Ruby, methods always return a value — namely the value of the last expression executed. The return keyword isn’t needed except for early exits:
# ANTI-PATTERN: explicit return at the end of a method — unnecessary
def square(n)
return n ** 2 # ← return here isn't needed
end
# CORRECT: implicit return — the last expression is returned automatically
def square(n)
n ** 2
end
# return IS useful for early exit
def divide(a, b)
return "Error: cannot divide by zero" if b == 0
a.fdiv(b)
end
puts divide(10, 2) # => 5.0
puts divide(10, 0) # => "Error: cannot divide by zero"
Because methods are expressions that return values, they can be used directly in any context:
def max(a, b)
a > b ? a : b
end
puts max(3, 7) # => 7
puts "The largest value: #{max(10, 5)}" # => The largest value: 10
result = [max(1,2), max(3,4), max(5,6)] # => [2, 4, 6]
Parameters and Arguments #
Ruby provides a very rich parameter system — far more flexible than most languages.
Positional Parameters #
Positional parameters are the most basic — values are passed by order:
def create_profile(name, age, city)
"#{name}, #{age} years old, from #{city}"
end
puts create_profile("Rina", 28, "Bandung")
# => Rina, 28 years old, from Bandung
Default Parameters #
Parameters can have default values used when an argument isn’t given:
def send_message(to, content, format: :text, priority: :normal)
"[#{priority.upcase}] To: #{to} | Format: #{format} | #{content}"
end
puts send_message("[email protected]", "Server down!")
# => [NORMAL] To: [email protected] | Format: text | Server down!
puts send_message("[email protected]", "Critical!", priority: :high, format: :html)
# => [HIGH] To: [email protected] | Format: html | Critical!
Default parameters can be any expression — including values computed at call time:
def log(message, time = Time.now, level = :info)
"[#{level.upcase}] #{time.strftime('%H:%M:%S')} — #{message}"
end
puts log("Server started")
puts log("Critical error", Time.now, :error)
Parameters with default values must come after parameters without defaults. Ruby technically allows a default in the middle, but it’s confusing — put all required parameters first, optional parameters last.
Keyword Arguments #
Keyword arguments make method calls more expressive because parameter names are stated explicitly. Order doesn’t matter, and there’s no ambiguity:
def create_connection(host:, port:, database:, timeout: 30, ssl: false)
puts "Connecting to #{host}:#{port}/#{database}"
puts "Timeout: #{timeout}s | SSL: #{ssl}"
end
# Order can be anything — the name determines it
create_connection(
database: "app_production",
host: "db.example.com",
port: 5432,
ssl: true
)
# ANTI-PATTERN: positional parameters for methods with many arguments
def create_connection_old(host, port, database, timeout, ssl)
# the caller must remember the exact order — error-prone
end
create_connection_old("db.example.com", 5432, "app", 30, true)
# 5432, 30, true — what do these mean without names?
You can accept previously unknown keyword arguments with **:
def show_options(**options)
options.each { |k, v| puts " #{k}: #{v}" }
end
show_options(color: :blue, size: :large, bold: true)
# => color: blue
# => size: large
# => bold: true
The Splat Operator — Variable Arguments #
Splat (*) collects all remaining positional arguments into an Array:
def sum(*numbers)
numbers.sum
end
puts sum(1, 2, 3) # => 6
puts sum(10, 20, 30, 40) # => 100
puts sum # => 0 (empty array)
# Splat in the middle — capture the "rest" of the arguments
def first_middle_last(first, *middle, last)
puts "First: #{first}"
puts "Middle: #{middle.inspect}"
puts "Last: #{last}"
end
first_middle_last(1, 2, 3, 4, 5)
# => First: 1
# => Middle: [2, 3, 4]
# => Last: 5
Splat is also useful for passing an Array as separate arguments:
def add(a, b, c)
a + b + c
end
numbers = [1, 2, 3]
puts add(*numbers) # => 6 (splat "explodes" the array into separate arguments)
Combining All Parameter Types #
Ruby allows combining all parameter types — with an order that must be followed:
# Required order: positional → *splat → keyword → **double_splat → &block
def complex_method(required, optional = "default", *rest, keyword:, kw_optional: nil, **extra, &block)
puts "required: #{required}"
puts "optional: #{optional}"
puts "rest: #{rest.inspect}"
puts "keyword: #{keyword}"
puts "kw_optional: #{kw_optional.inspect}"
puts "extra: #{extra.inspect}"
block.call if block
end
complex_method("a", "b", "c", "d", keyword: "required", x: 1, y: 2) { puts "block!" }
Methods with Blocks #
One of Ruby’s most distinctive features is the ability to pass a block of code to a method. There are two ways to receive and run a block: with yield and with an explicit &block parameter.
yield #
yield calls the block passed to the method. You can pass values to the block as yield arguments:
def time_it(label)
start = Time.now
result = yield # call the block, capture its return value
duration = Time.now - start
puts "#{label} finished in #{(duration * 1000).round(2)}ms"
result
end
total = time_it("Calculation") do
(1..1_000_000).sum
end
puts "Result: #{total}"
# => Calculation finished in ~XXms
# => Result: 500000500000
# Passing values to the block
def transform(collection)
collection.map { |item| yield item }
end
result = transform([1, 2, 3, 4, 5]) { |n| n ** 2 }
puts result.inspect # => [1, 4, 9, 16, 25]
block_given? checks whether a block was passed — useful for making blocks optional:
def process(data)
result = data.map { |d| d.to_s.upcase }
if block_given?
yield result # give the block a chance to do something with the result
else
result # return directly if there's no block
end
end
process(["a", "b", "c"]) # => ["A", "B", "C"]
process(["a", "b", "c"]) { |h| h.join(", ") } # => "A, B, C"
Explicit Block Parameters with & #
If you need to store a block in a variable, pass it to another method, or call it more than once, use the & parameter:
def run_twice(&block)
block.call
block.call
end
run_twice { puts "Hello!" }
# => Hello!
# => Hello!
# Passing a block to another method
def filter_and_process(data, &condition)
data.select(&condition).map { |d| d * 10 }
end
result = filter_and_process([1, 2, 3, 4, 5]) { |n| n.odd? }
puts result.inspect # => [10, 30, 50]
&:method_name is the idiomatic shorthand for converting a Symbol into a block:
# ANTI-PATTERN: verbose blocks for simple methods
["rina", "budi", "citra"].map { |n| n.upcase }
["rina", "budi", "citra"].select { |n| n.start_with?("r") }
# CORRECT: Symbol to Proc — concise and expressive
["rina", "budi", "citra"].map(&:upcase)
["rina", "budi", "citra"].select(&:frozen?)
[1, -2, 3, -4].select(&:positive?)
[nil, 1, nil, 2].reject(&:nil?)
Method Visibility — public, private, protected #
Ruby controls method access through three visibility levels:
class BankAccount
def initialize(balance)
@balance = balance
end
# public — callable from anywhere (default)
def info
"Balance: #{format_rupiah(@balance)}"
end
def transfer(to, amount)
return "Insufficient balance" unless sufficient_balance?(amount)
reduce_balance(amount)
to.add_balance(amount)
"Transfer successful"
end
protected
# protected — callable by instances of the same class
def add_balance(amount)
@balance += amount
end
private
# private — only callable from within this class itself
def format_rupiah(number)
"Rp #{number.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1.').reverse}"
end
def sufficient_balance?(amount)
@balance >= amount
end
def reduce_balance(amount)
@balance -= amount
end
end
account = BankAccount.new(1_000_000)
puts account.info # => Balance: Rp 1.000.000
account.reduce_balance(100) # => NoMethodError: private method called
account.format_rupiah(5000) # => NoMethodError: private method called
flowchart TD
A[Method Visibility] --> B[public]
A --> C[protected]
A --> D[private]
B --> B1["Callable from anywhere\nOther objects, outside the class"]
C --> C1["Only from within the class\nand its subclasses\nUseful for operators like <=>"]
D --> D1["Only from within\nthe object itself\nwithout an explicit receiver"]In modern Ruby (since 2.7),privatecan be used directly as a prefix:private def internal_calculation; end. This is more concise than placing the method after theprivatekeyword below, and easier to read because the visibility intent is visible right next to the method definition.
Method Chaining #
Method chaining allows calling several methods in sequence within one expression. The key is that each method must return a relevant object — usually self:
class QueryBuilder
def initialize
@conditions = []
@order = nil
@limit = nil
end
def where(condition)
@conditions << condition
self # return self for chaining
end
def order(column)
@order = column
self
end
def limit(n)
@limit = n
self
end
def build
sql = "SELECT * FROM users"
sql += " WHERE #{@conditions.join(' AND ')}" unless @conditions.empty?
sql += " ORDER BY #{@order}" if @order
sql += " LIMIT #{@limit}" if @limit
sql
end
end
query = QueryBuilder.new
.where("active = true")
.where("age > 18")
.order("name ASC")
.limit(10)
.build
puts query
# => SELECT * FROM users WHERE active = true AND age > 18 ORDER BY name ASC LIMIT 10
Method chaining is very natural in Ruby because almost all Enumerable methods return a new collection that can be chained directly:
result = [3, 1, 4, 1, 5, 9, 2, 6, 5, 3]
.uniq
.select { |n| n > 2 }
.sort
.map { |n| n ** 2 }
.first(3)
puts result.inspect # => [9, 16, 25]
Method Naming Conventions #
Ruby has strong, meaningful naming conventions — a method’s suffix conveys information about its behavior:
# Methods that return booleans — ? suffix
user.active? # => true / false
string.empty? # => true / false
array.include?(5) # => true / false
number.zero? # => true / false
# Dangerous / object-modifying methods — ! suffix
name.upcase # returns a new String
name.upcase! # modifies name itself (destructive)
array.sort # returns a new sorted Array
array.sort! # sorts the array in place
string.strip # returns a new String without whitespace
string.strip! # modifies the string itself
# Setter methods — = suffix
class Product
def name=(value) # setter: product.name = "Laptop"
@name = value
end
def name # getter: product.name
@name
end
end
Method naming convention guide:
? suffix → predicate method, always returns a boolean
! suffix → destructive or "dangerous" version of a regular method
= suffix → setter method, called with assignment syntax
is_ prefix → ANTI-PATTERN in Ruby — use ? not is_active?
get_/set_ prefix → ANTI-PATTERN in Ruby — use the name directly
Methods at the Top Level #
Methods defined outside any class or module actually become private methods of the main object (an instance of Object). This makes them look like global functions:
def hello
puts "Hello from the top level!"
end
def add(a, b)
a + b
end
hello # => Hello from the top level!
puts add(3, 4) # => 7
# Behind the scenes, this is equivalent to:
# Object.send(:define_method, :hello) { puts "Hello!" }
# That's why top-level methods can be called from anywhere
Proc and Lambda as First-class Functions #
Ruby treats blocks, Procs, and Lambdas as first-class objects — they can be stored in variables, passed as arguments, and returned from methods. This opens up powerful functional programming patterns.
# Lambda — an anonymous method storable in a variable
square = ->(n) { n ** 2 }
add = ->(a, b) { a + b }
greet = ->(name, greeting: "Hello") { "#{greeting}, #{name}!" }
puts square.call(5) # => 25
puts square.(5) # alternative syntax
puts square[5] # third alternative syntax
puts add.call(3, 4) # => 7
puts greet.call("Rina") # => "Hello, Rina!"
puts greet.call("Budi", greeting: "Welcome")
# Lambda as an argument
def apply(value, transformation)
transformation.call(value)
end
puts apply(10, square) # => 100
puts apply(10, ->(n) { n + 5 }) # => 15
# Higher-order methods — return a lambda from a method
def multiplier_factory(factor)
->(n) { n * factor }
end
times_two = multiplier_factory(2)
times_ten = multiplier_factory(10)
puts times_two.call(7) # => 14
puts times_ten.call(7) # => 70
# Function composition
def compose(f, g)
->(x) { f.call(g.call(x)) }
end
add_one = ->(n) { n + 1 }
times_three = ->(n) { n * 3 }
add_then_multiply = compose(times_three, add_one)
puts add_then_multiply.call(4) # => (4+1)*3 = 15
Method Objects #
You can also take a reference to an existing method as an object using method():
def power_of_two(n)
n ** 2
end
fn = method(:power_of_two)
puts fn.call(6) # => 36
puts [1, 2, 3, 4].map(&fn).inspect # => [1, 4, 9, 16]
# Very useful for passing methods to iterators
[" Rina ", " Budi ", " Citra "].map(&method(:puts))
# prints each name
validator = method(:valid_email?)
emails.select(&validator) # filter using an existing method
Memoization in Methods #
The memoization pattern uses ||= to compute an expensive value only once and store it in an instance variable:
class Report
def initialize(year)
@year = year
end
def total_revenue
@total_revenue ||= calculate_revenue
end
def total_expenses
@total_expenses ||= calculate_expenses
end
def net_profit
total_revenue - total_expenses
end
private
def calculate_revenue
puts "Calculating revenue from DB..."
# expensive query — only run once
rand(1_000_000_000)
end
def calculate_expenses
puts "Calculating expenses from DB..."
rand(500_000_000)
end
end
report = Report.new(2024)
puts report.total_revenue # "Calculating..." appears
puts report.total_revenue # cached — "Calculating..." doesn't appear again
puts report.net_profit # uses the already-cached values
Summary #
- There’s always a return value — the last expression in a method is returned implicitly. Use
returnonly for early exits, not at the end of a method.- Keyword arguments are more expressive than positional — for methods with more than two or three arguments, keyword arguments prevent order confusion and clarify the caller’s intent.
- Splat
*for variable arguments — collect unlimited positional arguments into an Array. Double splat**for unlimited keyword arguments into a Hash.yieldfor simple blocks,&blockfor blocks that need storing — useblock_given?to make blocks optional.&:method_nameis the most concise idiom for passing simple methods to iterators.?suffix for booleans,!for destructive,=for setters — this isn’t just cosmetic convention; it communicates a method’s behavioral contract.privatefor internal implementation — hide implementation details so the class API stays clean and you’re free to change internals without breaking users.- Method chaining with
return self— returnselffrom state-modifying methods to enable expressive chaining.- Lambdas can be stored in variables and passed around — use them for higher-order functions, composition, and callbacks that need to survive more than one call cycle.
- Memoization with
||=— store expensive calculation results in instance variables so they aren’t recomputed on every call.