crystal
crystal copied to clipboard
Syntactic type for "No Argument"
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.
Somewhat related: https://forum.crystal-lang.org/t/rfc-undefined-type/2695.
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
😕
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.
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 anAvram::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?
I think the difference is:
-
nil
means including the column in the update operation and set its value tonil
-
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
.
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
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?
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.
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.
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 🤔
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.
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.
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.