Noel Rappin Writes Here

Better Know A Ruby Thing #3: Positional Arguments

Posted on January 29, 2024


Ruby has three ways to pass information from a method call to a method definition: positional arguments, keyword arguments, and block arguments. Each of these ways has:

  • A syntax to declare an argument of that type in a method definition
  • A syntax to declare an argument of that type in a method call
  • A class that can can be used to convert arbitrary objects into and out of method calls
  • A text marker that is associated with that kind of argument, *, **, or &.

Originally I was going to Better Know all three kinds of arguments in one article, but it got too long, so I’m going to split it up. This time, we’re doing positional arguments.

Sidebar about naming: a couple of the early reviewers of the Pickaxe book wanted us to be more precise, so in the book, the parts of a method call are called “parameters” when discussing the method definition, and “arguments” when discussing calling the method. That messed with my head trying to get it right. I think “arguments” is easier as the generic term for both, so that’s what I’ll be using here.


A brief commercial announcement – we’ve gotten some comments that the code snippets don’t look good on Apple Mail in dark mode. Buttondown is working on this, but if you need to, you can also find this newsletter on the web at https://noelrappin.com/blog/2024/01/better-know-positional-arguments.

If you like this and want to see more of it in your mailbox, you can sign up at http://buttondown.email/noelrap. If you really like this and would like to support it financially, thanks, and you can sign up for a Monthly Subscription for $3/month or an Annual Subscription for $30/year.

Also, you can buy Programming Ruby 3.3 in ebook from Pragmatic or from Amazon, shipping now.

Thanks!


Positional Arguments

Ruby’s positional arguments have the least amount of syntax and are probably the most similar to arguments in other languages.

Positional Argument Syntax

Positional arguments are declared by listing the names that the arguments will have inside the method:

def address(number, street, city, state, zip)

When calling the method, the values being passed are listed left to right and correspond in order with the declared argument names. The order of the arguments is significant.

address("1060", "W. Addison", "Chicago", "IL", "60613")

If you pass the wrong number of arguments to the method, Ruby will throw an ArgumentError at runtime, and the error message will attempt to give you some useful information:

address(3)
1:in `address': wrong number of arguments (given 1, expected 5) (ArgumentError)

Older versions of Ruby are a little unclear about this message in some cases, but the way to read this is that “given” is the number of arguments Ruby thinks you have passed in the method call, and “expected” is the number of arguments Ruby thinks are required by the method definition.

Default Values

You can declare an argument to have a default value by adding = <value> to a positional argument. This allows you to be flexible about the number of arguments passed to a method. In common Ruby style, there are spaces around the equal sign:

def method_with_defaults(a, b, c = 3)
  p "a: #{a}, b: #{b}, c: #{c}"
end

Now if I pass three arguments to the method, the three arguments are assigned left to right. If I only pass two arguments, the arguments are still assigned left to right, but c has the default value of 3:

method_with_defaults(1, 2)
"a: 1, b: 2, c: 3"

If I only pass one argument, I get an ArgumentError:

method_with_defaults(1)
(irb):41:in `method_with_defaults': wrong number of arguments (given 1, expected 2..3) (ArgumentError)

Notice that Ruby is now telling me that it expects 2 to 3 arguments — passing 4 arguments would also give me an ArgumentError.

Unlike some other languages, the default value in Ruby is re-calculated on every call, and earlier declared values are available:

def relative_default(a, b = a * 2)
  p "a: #{a}, b: #{b}"
end

> relative_default(2)
"a: 2, b: 4"

In this example, if b is not declared, the value for a is used in the calculation of the default. While there are cases where using an earlier value in a default can clean up the code (we’ll see one in the keyword argument blog), this is definitely a “think two or three times before you use it” kind of feature, because it’s easy to overlook and therefore can be confusing.

Default arguments do not have to be last in the list of positional arguments:

def method_with_defaults(a = 1, b = 2, c)
  p "a: #{a}, b: #{b}, c: #{c}"
end

Ruby’s behavior when called with default arguments that aren’t last might be a little surprising:

> method_with_defaults("d")
"a: 1, b: 2, c: d"
> method_with_defaults("d", "e")
"a: d, b: 2, c: e"

Basically Ruby is trying to make the call fit the declaration, arguments are assigned left to right, but Ruby will skip the rightmost arguments with a default value to ensure that arguments without a default value are assigned. So in the second example, Ruby assigns the leftmost incoming value, “d”, to the argument “a”, the skips the argument “b” so that the argument “c” gets assigned a value. This behavior is, at best, confusing, so I’d recommend keeping default values at the end of the list.

It’s hard to think of a reason to have default arguments not be last, or at least it’s hard not to think of a reason to have default arguments not be last where it wouldn’t be a better idea to use keyword arguments.

Even though default arguments don’t have to be last, they do have to all be grouped together, the following is a syntax error:

def this_is_an_error(a, b = 2, c, d = 4)

I’m not completely sure why this is not allowed – if there’s an obvious reason why this is ambiguous, I’m not seeing it, but I suppose it would make the parser more complicated to no likely real-world benefit.

Positional Arguments and Arrays

Positional arguments have a relationship with Ruby Arrays that uses a single asterisk, * to convert between positional arguments and arrays.

In a method declaration, prefixing a positional argument with * causes any overflow positional arguments to be assigned to that name as an array. This is a mechanism for allowing an arbitrary number of extra arguments. The * is often called a “splat”, and the argument in the method is guaranteed to be an array (though it might be an empty one).

def method_with_splat(a, b, *c)
  p "a: #{a}, b: #{b}, c: #{c}"
end

> method_with_splat(1, 2, 3)
"a: 1, b: 2, c: [3]"

> method_with_splat(1, 2)
"a: 1, b: 2, c: []"

> method_with_splat(1)
wrong number of arguments (given 1, expected 2+) (ArgumentError)

You can also use the splat on the calling side. If you have an argument that is an array, and you prefix it with an *, Ruby will unroll the array left to right to match the positional arguments of the method.

def method_to_call(a, b, c)
  p "a: #{a}, b: #{b}, c: #{c}"
end

> method_to_call(*[1, 2, 3])
"a: 1, b: 2, c: 3"

> method_to_call(1, *[2, 3])
"a: 1, b: 2, c: 3"

> > method_to_call(*[1, 2], 3)
"a: 1, b: 2, c: 3"

> method_to_call(*[1, 2, 3, 4])
wrong number of arguments (given 4, expected 3) (ArgumentError)

In the first case, the [1, 2, 3] value of the array becomes the first, second, and third positional argument of the method. In the next two cases, we show that a splatted array can be any part of the argument list. But a call with a splatted array has the same requirements as a regular call – if the method expects three arguments, the total of regular and splatted arguments needs to be three.

Allowing Either Array or Individual Arguments

Often, I have a method that takes an arbitrary sized array as an argument, and I’d like the API to accept either an array foo([1, 2, 3]) or just a regular list of arguments foo(1, 2, 3). I don’t want the caller to have to worry about the argument should be an array or a list or a splatted array.

The easiest way to manage that is to use flatten.

def ambiguious(*args)
  args = args.flatten
  # rest of the method
end

In this case, calling ambiguous(1, 2, 3) sets args to [1, 2, 3] but so does the call ambiguous([1, 2, 3]) and the call ambiguous(*[1, 2, 3]). Without the flatten, the middle call would have args have the value [[1, 2, 3]], which is unlikely to be what we want.

Dereferencing Array Arguments

Something I learned while researching the book that I’ve never actually used in real life, is that you can dereference an array in the argument list of a method:

def method_with_dereference(a, (b, c), d)
  p "a: #{a}, b: #{b}, c: #{c}, d: #{d}"
end

> method_with_dereference(1, [2, 3], 4)
"a: 1, b: 2, c: 3, d: 4"

In this case the size of the array has to exactly match the dereference in the method definition – but you can splat inside the dereference

def method_with_dereference_and(a, (b, *c), d)
  p "a: #{a}, b: #{b}, c: #{c}, d: #{d}"
end

> method_with_dereference_and(1, [2, 3, 4], 5)
"a: 1, b: 2, c: [3, 4], d: 5"

And I am literally realizing this as I write, that means you can have two splats in the method defintion:

def method_with_dereference_and_splat(a, (b, *c), *d)
	p "a: #{a}, b: #{b}, c: #{c}, d: #{d}"
end
> method_with_dereference_and_splat(1, [2, 3, 4], 5, 6)
"a: 1, b: 2, c: [3, 4], d: [5, 6]"

Huh.

Trying and failing to come up with a reason why you might use that.

Anonymous Positional Arguments

Recent Ruby versions allow you to keep the splat anonymous if you are only going to pass the values along to another method without using them.

def passthrough(*)
  other_method(*)
end

The * value here does not completely behave like a regular variable. You can’t assign it, but you can dereference it

def passthrough(*)
  illegal = *           # can't do this
  somehow_legal = [*]   # somehow, this works
  a, b, *c = [*]        # as does this
  other_method(*)
end

My advice here is not to get fancy with this, the * is a perfectly valid signal for “I’m not using this here”, but then don’t use it.

Blocks and Positional Arguments

Positional arguments in block declarations behave the same as in method declarations – almost.

There’s one thing you can’t do in block positional arguments, and one extra thing you can do…

The thing you can’t do is use the bare * as an anonymous passthrough. What’s confusing abut this is that you can use a regular splat argument like *args to hold an optional number of arguments, you just can’t use the anonymous * by itself.

The other thing that’s confusing is that if you try to use argument forwarding in a block in Ruby 3.2, it’s syntactically legal, it just doesn’t do what you want (thanks to Victor Shepelev for posting a clear example, which is not exactly this example):

def outer_method_with_params(*)
  %w[one two three].map { |*| puts * }
end

This method behaves differently in Ruby 3.2 and Ruby 3.3. In Ruby 3.2, it is syntactically allowed, but the * in puts * is the argument to the outer method, not the argument to the block – the argument to the block is ignored. In Ruby 3.3 you get a syntax error, which is preferable, but it’s probably more preferable to have the block allow a passthrough. Maybe in Ruby 3.4.

Implicit Block Arguments

The thing you can do in blocks that you can’t do in methods is implicitly refer to positional arguments in blocks without naming them. The first argument is _1, the second is _2 and so on. You should probably only use this feature with one argument…

[1, 2, 3].map { _1 * _1 }

In Ruby 3.4, it will be available as a synonym for _1, so you will be able to write this code as:

[1, 2, 3].map { it * it }

I kind of like it?

There was a long and somewhat convoluted development debate around _1, with a couple of other options suggested (I think @1 actually made it into one of the prereleases for a few weeks). The _1 syntax has grown on me as I’ve been using it.

As I was writing this, I realized for the first time that I don’t remember seeing anybody suggest that this syntax should be applied to methods:

def this_doesnt_work
  puts "you called this with #{_1}"
end

this_doesnt_work("banana")

I mean, maybe somebody suggested this, I didn’t really follow the whole debate. It’s an interesting point about the subtleties of language design that users.map { puts _1 } feels reasonable to me but that method version absolutely does not.

The fact that the block version is generally small and localized is the reason, for me. In something like users.map { puts _1 }, the context makes it clear to me that there’s one argument, and the context also suggests that the argument is going to be a User because of the name of the receiver variable. In the method context, it’s much more confusing what the argument is supposed to be and what a call to the method looks like, especilly since the call is likely to be far away from the definition.

So that’s two paragraphs arguing against a syntax option that (probably) nobody made.

Hot Takes

I have two kind of hot takes on positional arguments.

You are probably using too many of them. Usual disclaimers apply – I don’t know you or your code, etc. But I think that if there’s any chance that somebody using your code would be confused by the ordering of the arguments, then you should take the typing hit and use keyword arguments. In practice that means anything 3 or more arguments, though I guess there are some cases where 3 arguments do have a clear order.

I mean, if you have a lot of methods with 4 or more arguments, that probably indicates a different problem. Using positional arguments where the ordering is arbitrary is only going to make that problem worse.

My other take is that using _1 in things like users.map { _1.first_name } is good, and in particular is better than the more commonly used users.map(&:first_name).

I come by this opinion from trying to explain both syntaxes to new Ruby developers. I explain block syntax, and show something like [1, 2, 3, 4, 5].select { |num| num.odd? }. This is different from most other programming languages, so it does take some explanation.

At that point, I say there are some alternate syntaxes. You can do [1, 2, 3, 4, 5].select { _1.odd? }. This usually makes sense to the people I’m talking to pretty quickly, it’s a straightforward replacement.

Then, I say there’s another syntax. [1, 2, 3, 4, 5].select(&:odd?). And I take a deep breath, and I talk about symbols (which we’ve already talked about), and what an & means in an argument list, which is different then what it means in a method chain, and about to_proc and about why this is a regular argument and not a block argument. It’s a lot. And I get why, for experience programmers select(&:odd) is easier to write than select { |num| num.odd }, but it’s not that much easier to write that select { _1.odd? } and I think the new version is much clearer as to what’s actually happening.

In Ruby 3.4, there will be another way, select { it.odd? }, I want to like it, and I’m sure I’ll get used to it, but it’s not immediately obvious that it is clearer than _1. (Though it is true that it encourages users to see this as something you only do with one argument, which I think is good).

Next time

I’m looking to do the Better Know posts alternating with other topics. Next up is Conway’s Law, then we’ll come back and Better Know keyword arguments.



Comments

comments powered by Disqus



Copyright 2024 Noel Rappin

All opinions and thoughts expressed or shared in this article or post are my own and are independent of and should not be attributed to my current employer, Chime Financial, Inc., or its subsidiaries.