Endless method - a quick intro
An overview of the endless method in Ruby programming
The endless method was added to Ruby version 3.0 in 2020. In a previous article, I wrote about its history and presented an example of how it was used to refactor a piece of code. Now I am going to explore some use cases.
Here is how it is described in the Ruby doc (see the yellow part):
Before we begin
Now, let's be clear about the purpose of this discussion. I'm not recommending the endless method as a superior alternative to the normal method, nor am I suggesting that you use it in your code. I want to explore the potential impact on code structure and how we can shape our code using the endless method.
One-line methods were possible in Ruby before
We could write one-line methods even before endless methods are supported.
The following code is valid Ruby syntax:
def valid_array?(array) array.each { |element| validate!(element) } end
def validate!(value) @validator.call(value) end
def name; @name end
When looking at the code above let's ask if we really need the end
keyword to know that for example the valid_array?
method is finished after the closing bracket }
. I am asking this visually reading this code.
What if I blurry a bit the end
keyword. How much does it affect the way you read this code?
And here is how this code will look like written with endless method:
def valid_array?(array) = array.each { |element| validate!(element) }
def validate!(value) = @validator.call(value)
def name = @name
I think there is not too much difference than the example that is a one-line normal Ruby method.
Endless methods are not required to be one-liners
One thing about the endless method is that it is defined as "single expression" and not single line.
Thus we can write - if we want - code like this:
def valid?(value) = if value.start_with?("ShortRuby")
true
else
false
end
Or even code like this:
def assignee(organisation, permission:) = case permission[:role]
when "admin" then Assignee.new(organisation, "admin")
when "member" then Assignee.new(organisation, "member")
else Assignee.new(organisation, "guest")
end
Or maybe formatted like this:
def assignee(organisation, permission:) = case permission[:role]
when "admin"
Assignee.new(organisation, "admin")
when "member"
Assignee.new(organisation, "member")
else
Assignee.new(organisation, "guest")
end
Or maybe like this:
def assignee(org, permission:) = case permission[:role]
when "admin"
Assignee.new(org, "admin")
when "member"
Assignee.new(org, "member")
else
Assignee.new(org, "guest")
end
Why use it?
Because using the endless method is cheap (it does not add more lines of code to your file), one of the main advantages I see is to use it to define computed attributes, predicates and in general to give a good name to a statement and thus increase readability.
Here is an example, that is quite common. When writing code to deal with some REST API, we are usually defining a general get
or post
methods and then write better (semantically names) for the operations that we plan to use so that when references in code we use the domain name.
Here is an example of a Twitter client where we want to define me
and tweets
that wraps a call to get <url>
so that when this client is used we can write things like client.tweets
and know that we want to query the tweets.
class Twitter::Client
def me
get "/users/me"
end
def tweets(tweet_ids:)
get "/tweets?ids=#{tweet_ids.join(',')}"
end
private
def get(path)
request(:get, path)
end
def post(path, body: {}, headers: {})
request(:post, path, body:, headers:)
end
def request(method, path, body: {}, headers: {})
# ...
end
end
and this could be easily written as:
class Twitter::Client
def me = get "/users/me"
def tweets(tweet_ids:) = get "/tweets?ids=#{tweet_ids.join(',')}"
private
def get(path) = request(:get, path)
def post(path, body: {}, headers: {}) = request(:post, path, body:, headers:)
def request(method, path, body: {}, headers: {})
# ...
end
end
Example in the wild
Here is Keygen - an open, source-available software licensing and distribution API built with Ruby on Rails.
First example: Environment
object
I am going to highlight and comment on some pieces of code extracted from their open source app.
# Source: https://github.com/keygen-sh/keygen-api/blob/master/app/models/environment.rb
class Environment < ApplicationRecord
# ...
def cache_key = Environment.cache_key(id, account:)
def clear_cache! = Environment.clear_cache!(id, code, account:)
def isolated? = isolation_strategy == 'ISOLATED'
def shared? = isolation_strategy == 'SHARED'
def admins = account.admins.for_environment(self, strict: false)
def self.codes = reorder(code: :asc).pluck(:code)
# ...
end
In this example we can see a couple of cases for the endless method:
Defining predicates
where the endless method helps naming predicated without adding 2 extra lines of code for each name (as it will be the case for a normal method)
# Endless method
def isolated? = isolation_strategy == 'ISOLATED'
def shared? = isolation_strategy == 'SHARED'
# vs normal method
def isolated?
isolation_strategy == 'ISOLATED'
end
def shared?
isolation_strategy == 'SHARED'
end
I don't see a big readability issue with having an equality comparison (multiple =
inside the method body) because I read the definition as:
define isolated as comparing isolation_strategy with ISOLATED
Naming a scoped collection of records
def admins = account.admins.for_environment(self, strict: false)
This provides a nice label for a series of scopes chained starting with account. I find it useful to make sure that everywhere in the code where I want the list of admins, I will get the same list and I don't have to remember to add the specific scopes for it.
Consistent naming
Further on in the same model we see:
def cache_key = Environment.cache_key(id, account:)
def clear_cache! = Environment.clear_cache!(id, code, account:)
This is a good DX to attach these commands to a model and thus ask the model what is your cache key or to command it to clear the cache.
Second example: LicenseFile
object
Another example from the same codebase is from the LienceseFile
object.
# Source: https://github.com/keygen-sh/keygen-api/blob/master/app/models/license_file.rb
class LicenseFile
# ...
def persisted? = false
def id = @id ||= SecureRandom.uuid
def product = @product ||= license&.product
def owner = @owner ||= license&.owner
def account = @account ||= Account.find_by(id: account_id)
def account=(account)
self.account_id = account&.id
end
def license = @license ||= License.find_by(id: license_id, account_id:)
def license=(license)
self.license_id = license&.id
end
# ...
end
Allow me to say a couple of things about the endless methods here.
Memoized simple attributes
One case for the endless method here is used to load a delegated object:
def id = @id ||= SecureRandom.uuid
def product = @product ||= license&.product
def owner = @owner ||= license&.owner
def account = @account ||= Account.find_by(id: account_id)
# ...
def license = @license ||= License.find_by(id: license_id, account_id:)
Cohesion
Another case is keeping code together without wasting space. See here how the getter and setter are close together:
def account = @account ||= Account.find_by(id: account_id)
def account=(account)
self.account_id = account&.id
end
It could have been written like:
def account
@account ||= Account.find_by(id: account_id)
end
def account=(account)
self.account_id = account&.id
end
But I think it might be something interesting here to write the endless method for the getter just above the setter so then keep them together:
def account = @account ||= Account.find_by(id: account_id)
def account=(account)
self.account_id = account&.id
end
def license = @license ||= License.find_by(id: license_id, account_id:)
def license=(license)
self.license_id = license&.id
end
Setters cannot be written as endless methods
You can notice in the example above that the setters are normal methods. That is because the endless method does not support setters. See this note for reference.
Conclusion
The endless method could be a good fit for simple, one expression methods, when giving a name to some expressions improves readability or consistency. The same can be achieve with a normal method but it will take a bit more vertical space: a normal method takes at least 3 lines for a single one line statement while the endless method can one single line. But of course you can write multi-line endless methods. See the if
and case
examples above.
Recommended reading
Another good introduction was written by Victor Shepelv on RubyReferences website and he also wrote a more in-depth exploration on his blog. I strongly recommend you to read them both, specially the in-depth exploration.
Enjoyed this article?
๐ Subscribe to my Ruby and Ruby on Rails courses over email at learn.shortruby.com- effortless learning about Ruby anytime, anywhere
๐ Join my Short Ruby Newsletter for weekly Ruby updates from the community and visit rubyandrails.info, a directory with learning content about Ruby.
๐ค Let's connect on Ruby.social or Linkedin or Twitter where I post mainly about Ruby and Rails.
๐ฅ Follow me on my YouTube channel for short videos about Ruby