Trying to understand this ruby syntax

3.6k views Asked by At

I am new to ruby and lots of things are confusing me. I believe this specific one is from Sorbet which is some type checking library? not sure. This specific piece of code is right before a method declaration

sig { params(order: T::Hash[
          String, T.any(
            T.nilable(String), T::Hash[
              String, T.any(
                Integer, T.nilable(String)
              )
            ]
          )
        ]).returns(Types::Order) }

def self.build_prescription(prescription) 
# method implementation

The order object is a json object coming from REST API. Can someone explain what this nesting thing is going on.

2

There are 2 answers

0
marianosimone On BEST ANSWER

This is indeed a Sorbet signature. You can read more about Sorbet in their official documentation

The signature itself in this case is just describing the shape of the parameters and return type of the build_prescription method.

Slightly reformatting to make it easier to annotate:

sig { # A ruby method added by Sorbet to indicate that the signature is starting.
  params( # Start to declare the parameters that the method takes
    order: T::Hash[ # First param. In this case, it's a Hash...
      String, # ... where they key is a String ...
      T.any( # ... and the value can be either: ...
        T.nilable(String), ... # a nilable String
        T::Hash[ # or a Hash ...
          String, # ... where the key is a String...
          T.any(Integer, T.nilable(String)) # ... and the value is either an Integer or nilable string String
        ]
      )
    ])
  .returns(Types::Order) # Declaration of what the method returns
}
def self.build_prescription(prescription) # the regular Ruby method declaration

Note, though, that this will fail a Sorbet validation, as the signature declares the parameter as order, while the method declares it as prescription. Those two names should match.

There's a way to re-write this signature to make it a bit nicer to read/understand

sig do
  params(
    prescription: T::Hash[
      String,
      T.nilable(
        T.any(
          String,
          T::Hash[String, T.nilable(T.any(Integer, String)]
        )
      )
  ])
  .returns(Types::Order) # Declaration of what the method returns
}
def self.build_prescription(prescription)

Note that I'm moving the T.nilables out one level, as T.any(Integer, T.nilable(String)) means that same as T.nilable(T.any(Integer, String)) but it's more immediately obvious for a reader that the value can be nil

0
Jörg W Mittag On

Since you are specifically asking about syntax, not semantics, I will answer your question about syntax.

What you see here, is called a message send. (In other programming languages like Java or C#, it might be called a method call.) More precisely, it is a message send with an implicit receiver.

A message is always sent to a specific receiver (just like a message in the real world). The general syntax of a message send looks like this:

foo.bar(baz, quux: 23) {|garple| glorp(garple) }

Here,

  • foo is the receiver, i.e. the object that receives the message. Note that foo can of course be any arbitrary Ruby expression, e.g. (2 + 3).to_s.
  • bar is the message selector, or simply message. It tells the receiver object what to do.
  • The parentheses after the message selector contain the argument list. Here, we have one positional argument, which is the expression baz (which could be either a local variable or another message send, more on that later), and one keyword argument which is the keyword quux with the value 23. (Again, the value can be any arbitrary Ruby expression.) Note: there a couple of other types of arguments as well in addition to positional arguments and keyword arguments, namely splat arguments and an optional explicit block argument.
  • After the argument list comes the literal block argument. Every message send in Ruby can have a literal block argument … it is up to the method that gets invoked to ignore it, use it, or do whatever it wants with it.
  • A block is a lightweight piece of executable code, and so, just like methods, it has a parameter list and a body. The parameter list is delimited by | pipe symbols – in this case, there is only one positional parameter named garple, but it can have all the same kinds of parameters methods can have, plus block-local variables. And the body, of course, can contain arbitrary Ruby expressions.

Now, the important thing here is that a lot of those elements are optional:

  • You can leave out the parentheses: foo.bar(baz, quux: 23) is the same as foo.bar baz, quux: 23, which also implies that foo.bar() is the same as foo.bar.
  • You can leave out the explicit receiver, in which case the implicit receiver is self, i.e. self.foo(bar, baz: 23) is the same as foo(bar, baz: 23), which is of course then the same as foo bar, baz: 23.
  • If you put the two together, that means that e.g. self.foo() is the same as foo, which I was alluding to earlier: if you just foo on its own without context, you don't actually know whether it is local variable or a message send. Only if you see either a receiver or an argument (or both), can you be sure that it is a message send, and only if you see an assignment in the same scope can be sure that it is a variable. If you see neither of those things it could be either.
  • You can leave out the block parameter list of you're not using it, and you can leave out the block altogether as well.

So let's dissect the syntax of what you are seeing here. The first layer is

sig {
  # stuff
}

We know that this is a message send and not a local variable, because there is a literal block argument, and variables don't take arguments, only message sends do.

So, this is sending the message sig to the implicit receiver self (which in a module definition body is just the module itself), and passes a literal block as the only argument.

The block has no parameter list, only a body. The content of the body is

params(
  # stuff
).returns(Types::Order)

Again, we know that params is a message send because it takes an argument. So, this is sending the message params to the implicit receiver self (which is here still the module itself, because blocks lexically capture self, although that is part of the language semantics and you asked strictly about syntax). It also passes one argument to the message send, which we will look at later.

Then we have another message send. How do we know that? Well, it takes an argument and has a receiver. We are sending the message returns to the object that was returned by the params message send, passing the expression Types::Order as the only positional argument.

Types::Order, in turn, is a constant reference (Types), the namespace resolution operator (::), followed by another constant reference (Order).

Next, let's look at the argument to params:

params(order: T::Hash[
  # stuff
])

Here we have a keyword argument order whose value is the expression T::Hash[ … ]. T::Hash is of course again a constant reference, the namespace resolution operator, and another constant reference.

So, what is []? Actually, that is just another message send. Ruby has syntacic sugar for a limited, fixed, list of special messages. Some examples:

  • foo.call(bar) is the same as foo.(bar).
  • foo.+(bar) is the same as foo + bar. (And similar for *, **, /, -, <<, >>, |, &, ==, ===, =~, !=, !==, !~, and a couple of others I am probably forgetting.)
  • foo.+@ is the same as +foo. (And similar for -@.)
  • foo.! is the same as !foo. (And similar for ~.)
  • self.`("Hello") is the same as `Hello`, which is somewhat obscure.
  • foo.[](bar, baz) is the same as foo[bar, baz].
  • foo.[]=(bar, baz, quux) is the same as foo[bar, baz] = quux.

So, this is simply sending the message [] to the result of dereferencing the constant Hash within the namespace of the object obtained by dereferencing the constant T, and passing two positional arguments.

The first positional argument is String, which is again a constant reference. The second positional argument is the expression T.any( … ), which is a constant reference to the constant T, and then sending the message any to the object referenced by that constant, passing two positional arguments.

The first argument is the expression T.nilable(String), which is dereferencing the constant T, sending the message nilable to the result of dereferencing the constant T, passing a single positional argument, which is the result of dereferencing the constant String.

The second argument is the expression T::Hash[ … ] … and I am going to stop here, because there is really nothing more to explain here. There's constants, message sends, and arguments, and we've seen all of those multiple times before.

So, to summarize, as to your question about syntax: the syntax elements we are seeing here are

  • message sends
  • arguments
  • constants
  • the namespace resolution operator (which is actually not really a separate syntax element, but simply one of many operators)
  • and a block literal