Understanding Protected Methods in Ruby

As a rule of thumb, we define methods in our Ruby class and consider their accessibility. Often, we declare them public and private. In exceptional cases, we set them protected.

As protected methods are exceptionally used, remembering and recalling their use cases is quite difficult when encountering them in the source code. I don’t feel confident about adding a protected method to the class unless I have dug deep into its definition and implementation. Honestly, it’s over and over again! Hopefully, I’m not alone 🙂

I write this post as a reference to return one day to review how to use protected methods in Ruby quickly.

Before we jump straight to protected methods, it is important to understand the basics of public and private methods. Let’s dive deep!

Understanding Public and Private Methods

Ruby offers public methods by default. However, there is an exception to the initialize method, which is always private. Public methods can be invoked from anywhere inside and outside the scope of the class without access controls.

On the other hand, private methods can only be called inside the scope of the class and by its subclasses. The characteristic of the private method is that they can’t be called with an explicit receiver. We cannot specify an object that will invoke the method.

Let’s look at BankAccount example demonstrating the usage of the public and private methods.

class BankAccount
  attr_accessor :balance

  def initialize(initial_balance = 0)
    @balance = initial_balance
  end

  def deposit(amount)
    @balance += amount
  end

  def withdraw(amount)
    if amount <= balance
      @balance -= amount
    else
      puts "Insufficient balance."
    end
  end

  def transfer(amount, other)
    unless amount <= balance
      puts "Transfer failed. Insufficient balance." 
      return
    end
    
    withdraw(amount)
    other.deposit(amount)
    puts "Transferred successfully!"
  end
end

# Usage
account1 = BankAccount.new(1000)
account2 = BankAccount.new(500)

puts "Account 1 Balance: $#{account1.balance}"
puts "Account 2 Balance: $#{account2.balance}"

account1.transfer(200, account2)

puts "After transfer:"
puts "Account 1 Balance: $#{account1.balance}"
puts "Account 2 Balance: $#{account2.balance}"

BankAccount has everything as public from the balance attribute to the transfer method.

As you can see, there is no restriction on calling the public methods from outside the scope of the class by explicit objects such as account1.balance, account2.balance, and account1.transfer, and inside the class within the context of transfer method like withdraw(amount) and other.deposit(amount).

More usage can be that we can deposit money toward account1 and withdraw some from it as well. For instance, account1.deposit(300) and account1.withdraw(100).

What if BankAccount wants to hide the raw balance from outside of the world?

The implementation is to set the attribute balance private. Ruby 3.

private attr_accessor :balance

Now when you attempt to access the available balance account1.balance like above, you will get NoMethodError. But, you can call it directly in the scope of the class similarly in deposit, withdraw, and transfer.

Using Protected Methods

Similar to private methods, protected methods prevent outside attempted access explicitly with a receiver. They can only be invoked from within the implementation of a class or its subclasses.

The main difference is that you can explicitly invoke a protected method on any object of the same class. With protected methods, objects can access the internal state of other objects of the same class.

Let’s go through BankAccount class again.

class BankAccount
  def initialize(initial_balance = 0, active = true)
    @balance = initial_balance
    @active = active
  end

  def deposit(amount)
    @balance += amount
  end

  def withdraw(amount)
    if amount <= @balance
      @balance -= amount
    else
      puts "Insufficient balance."
    end
  end

  def transfer(amount, other)
    unless other.active?
      puts "Transfer failed! Target account is inactive."
      return
    end

    unless amount <= balance
      puts "Transfer failed! Insufficient balance."
      return
    end

    unless greater_balance_than?(other) 
      puts "Transfer failed! Other account balance is sufficient."
      return
    end

    perform_transfer(amount, other)
  end

  def to_s
    status_str = active? ? "Account is active." : 'Account is inactive.'
    balance_str = balance.zero? ? "Current balance is empty." : "Current balance is positive."

    "#{status_str} #{balance_str}"
  end 

  protected

  attr_accessor :balance, :active

  def active?
    active
  end

  private
  
  def greater_balance_than?(other)
    return balance > other.balance
  end

  def perform_transfer(amount, other)
    withdraw(amount)
    other.deposit(amount)
    puts "Transferred successfully #{amount} USD to other account."
  end
end

# Example usage:
account1 = BankAccount.new(100)
account2 = BankAccount.new(50)

account1.transfer(20, account2)

puts "Bank account 1: #{account1.to_s}"
puts "Bank account 2: #{account2.to_s}"

account1.withdraw(80)
puts "Bank account 1: #{account1.to_s}"

This revision introduces protected attributes: balance and active, and active? method. The protected method active? behaves as an alias of the attribute active, which directly returns a boolean value of the attribute active.

If you try to explicitly call either account1.balance or account1.active? it will raise NoMethodError.

However, it runs in the case of other.active? in transfer method and other.balance invoked in greater_balance_than? method.

other is an object of the BankAccount class receiving from account1.transfer(20, account2)– it refers to account2 instance in this context – so that it can access its internal state.

This does not apply to private attributes and methods. When balance and active? are private, you call them the same way as being protected it will raise NoMethodError.

def transfer(amount, other)
  unless other.active?
    puts "Transfer failed! Target account is inactive."
    return
  end
  
  ...

  unless greater_balance_than?(other) 
    puts "Transfer failed! Other account balance is sufficient."
    return
  end
end

private

attr_accessor :balance, :active

def active?
  active
end

def greater_balance_than?(other)
  return balance > other.balance
end

Invocation on other.active? in transfer method, and on other.balance in greater_balance_than? method will raise NoMethodError.

Usage with self

In this context, self refers to the current object within the class context. Upon calling account1.transfter(20, account2), self is set to account1 which is an instance of the BankAccount class. self can access all kinds of methods, such as public, private (Ruby version 3), and protected methods.

Let’s modify our BankAccount class using self written in Ruby version 3:

class BankAccount
  attr_accessor :balance, :active

  private :active
  protected :balance

  def initialize(initial_balance = 0, active = true)
    self.balance = initial_balance
    self.active = active
  end

  def deposit(amount)
    self.balance += amount
  end

  def withdraw(amount)
    if amount <= self.balance
      self.balance -= amount
    else
      puts "Insufficient balance."
    end
  end

  def transfer(amount, other)
    unless other.active?
      puts "Transfer failed! Target account is inactive."
      return
    end

    unless amount <= self.balance
      puts "Transfer failed! Insufficient balance."
      return
    end

    unless self.greater_balance_than?(other) 
      puts "Transfer failed! Other account balance is sufficient."
      return
    end

    self.perform_transfer(amount, other)
  end

  def to_s
    status_str = self.active? ? "Account is active." : 'Account is inactive.'
    balance_str = self.balance.zero? ? "Current balance is empty." : "Current balance is #{self.balance} USD."

    "#{status_str} #{balance_str}"
  end

  protected

  def active?
    self.active
  end

  private

  def greater_balance_than?(other)
    return self.balance > other.balance
  end

  def perform_transfer(amount, other)
    self.withdraw(amount)
    other.deposit(amount)
    puts "Transferred successfully #{amount} USD to other account."
  end
end

# Example usage:
account1 = BankAccount.new(100)
account2 = BankAccount.new(50)

account1.transfer(20, account2)
puts "Bank account 1: #{account1.to_s}"
puts "Bank account 2: #{account2.to_s}"

# Output
# Transferred successfully 20 USD to other account.
# Bank account 1: Account is active. Current balance is 80 USD.
# Bank account 2: Account is active. Current balance is 70 USD.

Conclusion

Ruby’s protected methods are useful in some ways so we should understand when to use them. They come into place when you intend to restrict access to an object’s internal states from the outside world and share those only with other objects.

I hope this summary explanation gives you a clue about Ruby’s protected methods. In case you have a comment and want to add something don’t hesitate!

Happy Digging For Ruby!!! 🙂

Leave a Reply

Your email address will not be published. Required fields are marked *