DEV Community

Sina Rezaei
Sina Rezaei

Posted on AI-assisted

Getting to Know Yourself: Understanding self in Ruby

self is one of those Ruby keywords that looks easy until you meet it in real code.

You see it in a setter:

self.name = normalized_name
Enter fullscreen mode Exit fullscreen mode

Then in a class method:

def self.from_cents(...)
Enter fullscreen mode Exit fullscreen mode

Then somewhere inside a DSL or instance_eval, and suddenly the question isn't really:

What does self mean?

The better question is:

Who is self right here?

That small change makes Ruby's object model much easier to follow.

The setter case is where it starts to matter

Consider a model-like object:

class Customer
  attr_accessor :name, :email

  def initialize(name:, email:)
    @name = name
    @email = email
  end

  def normalize
    self.name = name.strip.downcase
    self.email = email.strip.downcase
  end

  def contact
    "#{name} <#{email}>"
  end
end

customer = Customer.new(
  name: "Maya",
  email: "MAYA@EXAMPLE.COM "
)

customer.normalize

puts customer.contact
Enter fullscreen mode Exit fullscreen mode

There is something easy to miss here:

self.name = name.strip.downcase
Enter fullscreen mode Exit fullscreen mode

Why not:

name = name.strip.downcase
Enter fullscreen mode Exit fullscreen mode

Because those two lines are doing different things.

The second one is a local variable assignment.

The first one calls the name= setter on the current object.

Ruby lets us call the getter without explicitly writing the receiver:

name
Enter fullscreen mode Exit fullscreen mode

But assignment creates an ambiguity. When we write:

self.name = ...
Enter fullscreen mode Exit fullscreen mode

we are making the receiver explicit.

In other words:

self.name = "Maya"
Enter fullscreen mode Exit fullscreen mode

means:

Call name= on the object I'm currently working with.

That object is self.

And notice something important.

We didn't create self.

We didn't assign it.

Ruby already knew what self was when normalize started running.

That is the first useful clue.

self is less about a special keyword and more about the object currently receiving the work.

Most of the time, Ruby hides it from you

Take a slightly more realistic object:

class Invoice
  def initialize(customer, total)
    @customer = customer
    @total = total
    @status = :open
  end

  def close
    return false unless valid_for_closing?

    mark_as_paid
    notify_customer
    true
  end

  private

  def valid_for_closing?
    @total.positive? && @status == :open
  end

  def mark_as_paid
    @status = :paid
  end

  def notify_customer
    puts "Invoice notification sent to #{@customer}"
  end
end
Enter fullscreen mode Exit fullscreen mode

Inside close, Ruby sees:

mark_as_paid
notify_customer
Enter fullscreen mode Exit fullscreen mode

You could make the receiver explicit:

self.mark_as_paid
self.notify_customer
Enter fullscreen mode Exit fullscreen mode

But normally there is no reason to.

The receiver is already there.

It is self.

So when Ruby evaluates:

mark_as_paid
Enter fullscreen mode Exit fullscreen mode

the useful mental model is:

self.mark_as_paid
Enter fullscreen mode Exit fullscreen mode

The receiver is implicit.

This is one of those Ruby features that makes code pleasant to read, but it can also hide an important detail.

The receiver isn't missing.

It is invisible.

And that becomes interesting when we start asking when Ruby changes that context.

There are scope gates for self

This is where I find it useful to stop thinking about every Ruby construct as if it creates a new self.

It doesn't.

A block is a good example.

Consider:

class Report
  def initialize(rows)
    @rows = rows
  end

  def failed_rows
    failures = []

    @rows.each do |row|
      next unless row[:status] == :failed

      failures << {
        id: row[:id],
        reason: row[:error]
      }
    end

    failures
  end
end
Enter fullscreen mode Exit fullscreen mode

Inside the block:

@rows.each do |row|
Enter fullscreen mode Exit fullscreen mode

row is the block's local value.

It is not self.

And the block does not automatically replace the surrounding self.

So inside that block, self is still the Report instance.

This gives us a useful mental distinction:

Not every nested piece of Ruby code is a new self scope.

A block can introduce local variables and control flow without automatically changing the receiver.

But structures such as:

class ...
module ...
def ...
Enter fullscreen mode Exit fullscreen mode

create important boundaries in how Ruby evaluates code and what self refers to.

And then there are APIs that deliberately change it.

For this article, I'll call these boundaries scope gates. That's a mental model, not an official Ruby term.

The useful idea is simply:

A block is not automatically a self gate. Context-changing constructs and APIs are.

Once you see that, some Ruby behavior that initially feels inconsistent becomes much easier to predict.

And now we can look at the class side.

The same keyword can point at the class

Consider a factory-style class method:

class Invoice
  def initialize(customer, total)
    @customer = customer
    @total = total
  end

  def self.from_cents(customer, cents)
    new(customer, cents / 100.0)
  end

  def summary
    "#{@customer}: $#{format('%.2f', @total)}"
  end
end

invoice = Invoice.from_cents("Maya", 12500)

puts invoice.summary
Enter fullscreen mode Exit fullscreen mode

This line:

def self.from_cents
Enter fullscreen mode Exit fullscreen mode

can look like special Ruby grammar.

It isn't really a separate universe.

The important part is still the receiver.

While Ruby evaluates the body of the class definition, self refers to the Invoice class object.

So:

def self.from_cents
Enter fullscreen mode Exit fullscreen mode

is essentially saying:

Define from_cents on the object currently referenced by self.

And in this context, that object is Invoice.

That is why this works:

Invoice.from_cents(...)
Enter fullscreen mode Exit fullscreen mode

The class itself is an object, and that object can receive method calls.

Now compare the two situations:

invoice.summary
Enter fullscreen mode Exit fullscreen mode

Here the receiver is an Invoice instance.

Invoice.from_cents(...)
Enter fullscreen mode Exit fullscreen mode

Here the receiver is the Invoice class object.

Nothing about the keyword changed.

The context did.

That distinction is much more useful than memorizing "instance method versus class method" as two unrelated Ruby features.

Rails makes this even more visible

You see the same idea constantly in Rails models.

For example:

class Subscription < ApplicationRecord
  scope :active, -> {
    where(status: :active)
  }

  def self.for_customer(customer)
    active.where(customer: customer)
  end

  def cancel!
    update!(
      status: :cancelled,
      cancelled_at: Time.current
    )
  end
end
Enter fullscreen mode Exit fullscreen mode

There are several receivers involved here.

When you call:

subscription.cancel!
Enter fullscreen mode Exit fullscreen mode

the receiver is the subscription instance.

Inside cancel!, the call to:

update!(...)
Enter fullscreen mode Exit fullscreen mode

is being sent to that instance.

But:

Subscription.for_customer(customer)
Enter fullscreen mode Exit fullscreen mode

is sent to the Subscription class object.

And Rails adds another interesting layer with scope.

The block passed to scope is not simply "a new self." The surrounding Ruby context and the way Rails evaluates the scope both matter.

This is why a useful question when reading Rails code is not just:

What method is this?

but:

What object is receiving this method here?

That question scales much better when the code gets more dynamic.

Then instance_eval deliberately moves the context

Now we can make the context change explicit.

Consider a configuration object:

class Configuration
  def initialize
    @values = {}
  end

  def [](key)
    @values[key]
  end

  def values
    @values.dup
  end
end

config = Configuration.new

config.instance_eval do
  @values[:environment] = "production"
  @values[:region] = "eu-west"
  @values[:logging] = :verbose
end

puts config[:environment]
puts config[:region]
Enter fullscreen mode Exit fullscreen mode

Why can this block access:

@values
Enter fullscreen mode Exit fullscreen mode

directly?

Because instance_eval evaluates the block with the receiver as self.

Before entering the block, self referred to the surrounding context.

Inside:

config.instance_eval do
Enter fullscreen mode Exit fullscreen mode

self becomes config.

That is a deliberate context change.

And this is where the earlier idea of scope gates becomes useful.

The block itself didn't inherently create a new self.

instance_eval did.

Ruby gives the programmer an explicit mechanism for saying:

Evaluate this code as if this object is the current receiver.

That is a very different thing from simply passing a block around.

instance_exec takes the same idea one step further

There is a subtle difference between instance_eval and instance_exec that becomes important when designing DSLs.

Suppose we want to configure an object, but we also want to pass information into the block:

class Endpoint
  def initialize
    @settings = {}
  end

  def settings
    @settings
  end
end

endpoint = Endpoint.new

method = :post
path = "/users"

endpoint.instance_exec(method, path) do |http_method, route|
  @settings[:method] = http_method
  @settings[:path] = route
  @settings[:authenticated] = true
end

puts endpoint.settings
Enter fullscreen mode Exit fullscreen mode

The important part is:

endpoint.instance_exec(method, path) do |http_method, route|
Enter fullscreen mode Exit fullscreen mode

instance_exec changes self to endpoint, just like the context-changing idea we saw with instance_eval.

But it also lets us pass arguments into the block.

So inside the block we have both:

self
Enter fullscreen mode Exit fullscreen mode

pointing at endpoint, and:

http_method
route
Enter fullscreen mode Exit fullscreen mode

coming from the outside.

That combination is useful when building expressive APIs.

It lets a DSL control the execution context without losing the ability to feed the block data.

And suddenly a pattern like:

resource :users do
  method :get
  path "/users"
end
Enter fullscreen mode Exit fullscreen mode

starts looking less magical.

The DSL designer may be combining:

  • a receiver that becomes self
  • a block that defines the configuration
  • arguments or yielded values
  • method dispatch through that context

The syntax is the surface.

The context is the machinery underneath.

This is why Ruby DSLs can feel strange

Once you start thinking in terms of context, Ruby DSLs become less mysterious.

You might see code that looks almost like configuration:

resource :users do
  path "/users"
  method :get
  authentication :required
end
Enter fullscreen mode Exit fullscreen mode

The interesting question isn't only:

What does resource do?

It is also:

What is self while this block is being evaluated?

Maybe the DSL is yielding to a builder object.

Maybe it is using instance_eval.

Maybe it is using instance_exec because it needs both a changed receiver and arguments.

Maybe it is delegating method calls.

The surface syntax can look almost identical while the underlying execution model is very different.

That's why self becomes particularly useful when reading metaprogramming-heavy Ruby.

Instead of getting stuck on the syntax, look for the receiver.

Then look for the context.

Then look for the point where that context changes.

So, who is self?

I don't think of self as a definition I need to memorize anymore.

I think of it as a signpost.

When I see:

self.name = value
Enter fullscreen mode Exit fullscreen mode

I ask:

Which object is receiving this assignment?

When I see:

def self.build
Enter fullscreen mode Exit fullscreen mode

I ask:

What is self while Ruby is evaluating this class body?

When I see:

something.instance_eval do
Enter fullscreen mode Exit fullscreen mode

I ask:

Did Ruby just change the receiver for this block?

When I see:

something.instance_exec(data) do |value|
Enter fullscreen mode Exit fullscreen mode

I ask two questions:

What is self now?

and:

What data was passed into this context?

And when I see a Ruby DSL that seems to have methods appearing out of nowhere, I ask:

Who is self here, and where did that context come from?

That question usually gets me closer to the actual behavior than simply remembering what the keyword means.

Because self isn't really the interesting part.

Context is.

Once you start reading Ruby through that lens, self becomes less of a keyword to memorize and more of a way to locate yourself inside Ruby's object model.

And I'm curious how other Ruby developers approach this.

Do you consciously think about self while reading Ruby code, or has it become one of those things you only notice when the context suddenly changes?

Top comments (0)