Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Discussion] Disable Style/RaiseArgs cop #155

Open
wants to merge 1 commit into
base: main
Choose a base branch
from

Conversation

wvanbergen
Copy link
Contributor

@wvanbergen wvanbergen commented Apr 17, 2020

This cop makes it more cumbersome to use custom exception classes that do not use a message as only argument to its constructor, e.g.

Class CommandFailure
  def initialize(command)
    @command = command
    super("`#{command}` (pid: #{command.pid}) #{termination_status}")
  end
end

raise CommandFailure.new(command_that_failed)

According to the cop, I should change the raise call to
raise CommandFailure, command_that_failed

While that works (accidentally), that goes against how raise is documented in Ruby:

With no arguments, raises the exception in $! or raises a RuntimeError if $! is nil. With a single String argument, raises a RuntimeError with the string as a message. Otherwise, the first parameter should be the name of an Exception class (or an object that returns an Exception object when sent an exception message). The optional second parameter sets the message associated with the exception, and the third parameter is an array of callback information.

(The reason why you can use exception instance is an exception instance returns itself when you call #exception on it.)

There are several ways to work around this and appease Rubocop:

  • Use a keyword argument rather than a positional argument: CommandFailure.new(command: command_that_failed)
  • Introduce a second argument (for which I have no reason): CommandFailure.new(command, nil)
  • Assign the exception to a variable first, and then raise the variable: raise command_failure
  • # rubocop:disable Style/RaiseArgs

When we force people to use a workaround like this, maybe Rubocop is not the best tool to enforce this. However, we can also decide that custom exceptions with a single argument constructor are rare enough that a workaround is acceptable.

This cop makes it more cumbersome to use custom exception classes that do not use a message as only argument to its constructor, e.g. 

```
Class CommandFailure
  def initialize(command)
    @command = command
    super("`#{command}` (pid: #{command.pid}) #{termination_status}")
  end
end

raise CommandFailure.new(command_that_failed)
```

According to the cop, I should change the raise call to 
`raise CommandFailure, command_that_failed`

While that works (accidentally), that goes against how `raise` is documented in Ruby: 

“With no arguments, raises the exception in $! or raises a RuntimeError if $! is nil. With a single String argument, raises a RuntimeError with the string as a message. Otherwise, the first parameter should be the name of an Exception class (or an object that returns an Exception object when sent an exception message). The optional second parameter sets the message associated with the exception, and the third parameter is an array of callback information.”

(The reason why you can use exception instance is an exception instance returns itself when you call `#exception` on it.)

There are several ways to work around this and appease Rubocop:
- Use a keyword argument rather than a position arguments: `CommandFailure.new(command: command_that_failed)`
- Introduce a second argument (for which I have no reason): `CommandFailure.new(command, nil)` 
- Assign the exception to a variable first, and then raise the variable: `raise command_failure`

When we force people to use a workaround like this, maybe Rubocop is not the best tool to enforce this.
@rafaelfranca
Copy link
Member

How about using the compact style? If we always require a exception instance we would be able to be consistent and avoid making devs have to decide if they should create an instance or not.

@wvanbergen
Copy link
Contributor Author

@gmalette was saying something around raise ExceptionClass, "message" being more performant than raise ExceptionClass.new("message") but I am not sure if I remember it correctly.

@gmalette
Copy link
Contributor

I don't know if there is a practical difference in MRI, but VMs will typically be able to optimize the raise Class, msg more easily

@volmer
Copy link
Contributor

volmer commented Jun 2, 2020

I don't think we should stop enforcing this rule (or reverse it into the "compact" style) because I would expect any subclass of Exception to be able to receive new with an optional error message, and therefore the raise ErrorClass, "message" style should work for all those.

Having children of Exception getting initialized with values other than the message sounds like a Liskov violation to me, and that's a smell. I would not encourage that in the Style guide.

I definitely see cases when you want to raise an object that is already initialized, or was initialized in another fashion. For those I would suggest calling raise with the instance at hand in a local variable, for example.

@gmalette
Copy link
Contributor

gmalette commented Jun 3, 2020

I would expect any subclass of Exception to be able to receive new with an optional error message

That's not an expectation that the Ruby's core lib fulfills; some of the core exceptions don't accept strings as the first positional argument.

Having children of Exception getting initialized with values other than the message sounds like a Liskov violation to me

While raise FooException, not_a_string is a Liskov violation, raise FooException.new(not_a_string) is not.

in a local variable

... and that's not a smell?

@rafaelfranca
Copy link
Member

VMs will typically be able to optimize the raise Class, msg more easily

@chrisseaton do you know if that is the case for TruffleRuby and JRuby?

@chrisseaton
Copy link

TruffleRuby implements this logic in Ruby:

def raise(exc=undefined, msg=undefined, ctx=nil)
  internal_raise exc, msg, ctx, false
end
module_function :raise

def internal_raise(exc, msg, ctx, internal)
  skip = false
  if Primitive.undefined? exc
    exc = $!
    if exc
      skip = true
    else
      exc = RuntimeError.new ''
    end
  elsif exc.respond_to? :exception
    if Primitive.undefined? msg
      exc = exc.exception
    else
      exc = exc.exception msg
    end
    raise TypeError, 'exception class/object expected' unless exc.kind_of?(Exception)
  elsif exc.kind_of? String
    exc = RuntimeError.exception exc
  else
    raise TypeError, 'exception class/object expected'
  end

  unless skip
    exc.set_context ctx if ctx
    exc.capture_backtrace!(2) unless exc.backtrace?
    Primitive.exception_set_cause exc, $! unless exc.equal?($!)
  end

  if $DEBUG
    STDERR.puts "Exception: `#{exc.class}' #{caller(2, 1)[0]} - #{exc.message}\n"
  end

  Primitive.vm_raise_exception exc, internal
end

I think the key for this discussion is this bit:

  elsif exc.respond_to? :exception
    if Primitive.undefined? msg
      exc = exc.exception
    else
      exc = exc.exception msg
    end
    raise TypeError, 'exception class/object expected' unless exc.kind_of?(Exception)
  elsif exc.kind_of? String
    exc = RuntimeError.exception exc
  else

So we always check if it respond_to? :exception - if it does, we just call it, either with the message or not, if it doesn't we check if it's a string instead, etc. JRuby works in a similar way to this code here, but it's implemented in Java.

        if (optionalMessage == null) {
            exception = obj.callMethod(context, "exception");
        } else {
            exception = obj.callMethod(context, "exception", optionalMessage);
        }

To me, raise FooException.new(message) is more explicit and better. raise FooException, message isn't a syntax replicated anywhere else in Ruby and continues to look strange to me.

I think raise FooException.new(message) is better for optimising VMs, but the reason why is quite complicated - it puts the call-site for the construction of the exception lower down the call stack and so in a place where it is more likely to get an independent inline cache, which will optimise better. This does apply to both JRuby and TruffleRuby in theory. I think you might struggle to prove it in a benchmark though, due to it being quite subtle in practice.

I would say that raise FooException.new(message) is cheaper for all of MRI, JRuby, and TruffleRuby to run, but it's not a strong difference.

In practice, I see most people write raise FooException, message and sometimes people ask me what I'm doing when I write raise FooException.new(message) instead.

Base automatically changed from master to main March 15, 2021 17:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

5 participants