crystal icon indicating copy to clipboard operation
crystal copied to clipboard

Syntactic type for "No Argument"

Open BrucePerens opened this issue 2 years ago • 13 comments

Avram builds arbitrarily long argument lists for setting database records - an argument for every field. Each of these fields is set optionally, and thus the argument type is always Type | Avram::Nothing = Avram::Nothing.new. In long argument lists you end up with many repetitions of this cybercrud, obscuring the relevant information.

How would we better do this? Nil is the natural type to use for this, so we'd at least have Type | Nil = nil, but I was thinking of adding a language convention to indicate that no argument was an option, which the compiler would understand and print as a type without the value assignment. So, Type | Nothing, for example.

BrucePerens avatar Jul 04 '22 08:07 BrucePerens

Somewhat related: https://forum.crystal-lang.org/t/rfc-undefined-type/2695.

Blacksmoke16 avatar Jul 04 '22 13:07 Blacksmoke16

It would be interesting to know why Avram doesn't use nil here.

My answer is that the language can't add a feature for every little thing a library defines or uses. nil is the way to represent "no value", I don't understand why you need an Avram::Nothing 😕

asterite avatar Jul 04 '22 13:07 asterite

I also don't see how we can change that. If instead of Avram::Nothing you see undefined in the output, you will have no idea what that undefined means and how to search it. I think showing the actual type and value is the way to go.

asterite avatar Jul 04 '22 13:07 asterite

It would be interesting to know why Avram doesn't use nil here.

My answer is that the language can't add a feature for every little thing a library defines or uses. nil is the way to represent "no value", I don't understand why you need an Avram::Nothing 😕

Avram explanation for Nothing type:

# This class is used in various places
# where the question of "Did I not pass in anything or did I pass in nil?"
# needs to be answered.

I guess it's used for telling the user whether it defaults to nil or not?

mdwagner avatar Jul 04 '22 14:07 mdwagner

I think the difference is:

  • nil means including the column in the update operation and set its value to nil
  • Nothing means to exclude the column in the update operation and do not change its value

So basically Nothing means "no change"/"keep existing value".

These semantics are related to Iterator::Stop which we also need as a special value to indicate the end of an iterator vs an iterator containing the value nil.

straight-shoota avatar Jul 04 '22 15:07 straight-shoota

Also related: https://github.com/crystal-lang/crystal/issues/11106

As for why we use Avram::Nothing instead of using Nil is basically as @straight-shoota said. Some values can be nil, and some can't, but they all can be Nothing.

I would say if we had an easier way to highlight the difference between what was passed in, and the missing arg type values, that would be a much better improvement. We can chat more about that in #11106

jwoertink avatar Jul 04 '22 16:07 jwoertink

I tried to split this off of #11106 as it is IMO a simpler and independent issue. I know that it's a convention of Crystal, and Ruby before, that everything is a value. But doesn't it sound kind of like we're discussing a Void type?

BrucePerens avatar Jul 04 '22 18:07 BrucePerens

But doesn't it sound kind of like we're discussing a Void type?

FWIW Void is already a type, so would need a diff name.

Blacksmoke16 avatar Jul 04 '22 19:07 Blacksmoke16

Void in Crystal is essentially an alias for Nil and is only useful for inter-language function definitions. We're really looking for a type that is never a valid value for anything, but can still be passed.

BrucePerens avatar Jul 04 '22 19:07 BrucePerens

Is there such a thing in other languages?

I'd like to understand what's the actual problem. Is it that the output of the error message becomes too verbose? Could we maybe show one argument per line in errors? I think this is just a duplicate of the other issue to improve error messages 🤔

asterite avatar Jul 04 '22 19:07 asterite

I agree with @asterite here that I'm not sure if having such a type would even help in this case. I think just having an easier way to read the compiler error would be a much better benefit.

Taking what Avram does for an example

class SaveUser
  def initialize(@id : Int64 | Nothing = Nothing.new, @created_at : Time | Nothing = Nothing.new, @updated_at : Time | Nothing = Nothing.new)
  end
end

This allows you to do:

SaveUser.new
SaveUser.new(id: 1)
SaveUser.new(id: 1, created_at: Time.utc)
SaveUser.new(id: 1, created_at: Time.utc, updated_at: Time.utc)

but also prohibits you from doing:

SaveUser.new(id: nil)

Since the args have defaults, they're optional to pass them in, and since none of them have Nil in the union, we can't pass in nil as an arg.

Now say we added some sort of type called Undefined here to replace this. What would that look like?

class SaveUser
  def initialize(@id : Int64 | Undefined, @created_at : Time | Undefined, @updated_at : Time | Undefined)
  end
end

If I did SaveUser.new, now the compiler wouldn't know what @id is supposed to be since it's undefined. And I guess doing Int64 | Undefined = Undefined.new would be no different than it is now. I'm just thinking about how undefined gets thrown around in javascript, and it seems a type like that wouldn't fix this use case. And we have uninitialized for other use cases where it would be similar.

# doing this
@id = uninitialized Int64

# as opposed to
@id = Undefined

I do agree that the UX we have currently isn't the greatest for newcomers, and I'd love a better solution, but I'm just not sure that a new type would make it any better in this case.

jwoertink avatar Jul 04 '22 20:07 jwoertink

My main issue is that Avram::Nothing = Avram::Nothing.new is overly verbose, and gets worse when your record has 20 fields. So, consider that for the Nothing type, the assignment is always implied. Thus, it's simpler to write, and to read, it is always Nothing with no assignment. We already do something like this with methods that are explicitly typed Nil, it's not necessary to explicitly return the nil in them.

Regarding whether other languages have things like this, one example is that IEEE 488 has both signaling and quiet NaN values, and they mean that an operation has not resulted in any valid numeric value.

BrucePerens avatar Jul 04 '22 21:07 BrucePerens

Something I wonder is what the signature for a lib struct's constructor should look like:

lib Foo
  struct Bar
    x : Int32
    y : Bar*
  end
end

# Error: wrong number of arguments for 'Foo::Bar.new' (given 1, expected 0)
#
# Overloads are:
#  - Foo::Bar.new()
Foo::Bar.new 1

The error message is obviously wrong, because these are all valid calls:

a = Foo::Bar.new x: 1, y: nil
b = Foo::Bar.new y: pointerof(a)

It comes from the fact that lib struct constructors are rewritten to something like the following, which explains why only the parameter-less constructor is necessary:

a = begin
  __temp_1 = Foo::Bar.new
  __temp_1.x = 1
  __temp_1.y = nil
  __temp_1
end
b = begin
  __temp_2 = Foo::Bar.new
  __temp_2.y = pointerof(a)
  __temp_2
end

If we were to define a constructor for diagnostics purposes, it might look like:

lib Foo
  struct Bar
    def initialize(*, x : Int32 = ..., y : Bar* = ...); end
  end
end

The parameters are not nilable, because actually passing nil would fail to compile (except for Pointer or Proc fields); instead, the ... here represents some placeholder that zero-initializes a field of any type usable in a lib struct, among them enums without zero and nested structs. This situation is rather similar to the Avram::Nothing example.

HertzDevil avatar Jul 10 '22 20:07 HertzDevil