SanjiBukai
SanjiBukai

Reputation: 563

Ruby default assignment ( ||= ) vs Rescuing error

Since a ||= 1 is equivalent to a || a = 1, one can state that this is synctactic sugar for this:

if a.nil?
  a = 1
end

Likewise, given that session is a hash-like object, the following:

def increment_session_counter
  session[:counter] ||= 0
  session[:counter] += 1
end

is equivalent to:

def increment_session_counter
  if session[:counter].nil?
    session[:counter] = 0
  end
  session[:counter] += 1
end

Does that mean that the implicit if statement will be executed every time in the original definition of increment_session_counter? Since session[:counter] will mostly likely be nil only the first time (i.e. << 1% of the time), I have the feeling that the following code is better, since the implicit if won't be fired every time:

def increment_session_counter
  session[:counter] += 1
rescue NoMethodError
  session[:counter] = 1
end

Is this code better in such sense?

Having said that, I have no idea how Rescue is implemented in ruby and whether it's actually relevant regarding the tiny little optimization that can bring if so.

Upvotes: 5

Views: 694

Answers (3)

SanjiBukai
SanjiBukai

Reputation: 563

I tried some benchmarks with the following code. This is a rails app. Rails v.5.1.1 / Ruby v2.4.1

Originally the purpose is just to count the number of visits in any show actions of some controllers like so:

include SessionCount
...
def show
  ...
  @counter = increment_session_counter
  ...

Then I could display the counter in the relevant views.

So I made a concern with the appropriate code with both version of the default assignment I wanted to test:

module SessionCount
  private

  #Counter version A
  def increment_session_counter_A
    session[:counter] ||= 0
    session[:counter] += 1
  end

  #Counter version B
  def increment_session_counter_B
    session[:counter] += 1
  rescue
    session[:counter] = 1
  end
end

In order to test both versions of default assignment I changed my controllers code as following:

include SessionCount
...
def show
  ...
  t0 = Time.now
  1000000.times do
    session[:counter] = 0; #initialization for normalization purpose
    increment_session_counter_A
  end
  t1 = Time.now
  puts "Elapsed time: #{((end_time - beginning_time)*1000).round} ms"
  @counter = increment_session_counter_A
  ...

Remark: In that code the initialization is here in order to enforce the "happy path" (where the value is not nil). In real scenario this will occurs only the first time for a given user.

Here are the results:
I get an average of 3100 ms with version A (||= operator).
I get an average of 2000 ms with version B (rescue).

But the interesting part begins now..

In the previous code the code is executed following the "happy path" where no exception occurs..
So I changed the initialization I made for normalization purpose as following in order to enforce the "exception path":

1000000.times do
  session[:counter] = nil; #initialization for normalization purpose
  increment_session_counter_A
end

And here is the results:
I get an average of 3500 ms with version A (||= operator).
I get an average of around 60 000 ms with version B (rescue). Yes I tried a couple of times only..

So here I can conclude as spickermann said that exception handling is indeed quite expensive.

But I think that there are many cases where the first time initialization happen very rarely (like a blog where there is no post at the very beginning)..
In such situations there is no reason to test for nil every time and it could be interesting to use the exception handling.

Am I wrong?

I don't want to nitpick about some ms.. Here I just want to know if this idiom makes sense as a design pattern. I see the difference of both of these versions like the difference between while... end and do ... while since the intent is not the same.

Upvotes: 1

Stefan
Stefan

Reputation: 114218

session[:counter] += 1 does three things:

  1. fetch the value (Hash#[])
  2. increment the value (Integer#+)
  3. store the incremented value (Hash#[]=)

That's pretty convenient, but its brevity also makes it inflexible.

Providing a default is much easier if you separate the steps:

def increment_session_counter
  session[:counter] = session.fetch(:counter, 0) + 1
end

Upvotes: 3

Kathryn
Kathryn

Reputation: 1607

Catching the error is a pretty clever idea, but it's also harder to read than using ||=. But even easier would be to set the initial value when you create the hash in the first place:

@session = {:counter => 0}
def increment_session_counter
  @session[:counter] += 1
end

That doesn't work when the key isn't known beforehand:

def increment_user_counter(username)
  @session[username] ||= 0
  @session[username]  += 1
end

However, in that case your assumption that the value of the counter is only nil once is thrown into jeopardy. In fact, since many distributions follow the power law, it's likely 1 will be the most common count.

Therefore, if you know the possible values beforehand, it's best to set them to zero when the program or class is initialized and skip the need for the default value check. And if you don't know all possible values upfront, you will likely find the ||= statement is needed as often as not. There are probably few scenarios where using an exception is the best solution assuming checking for nil is substantially cheaper.

Upvotes: 3

Related Questions