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

Parse T? as Union(T, Nil) #2742

Closed
wants to merge 1 commit into from
Closed

Parse T? as Union(T, Nil) #2742

wants to merge 1 commit into from

Conversation

asterite
Copy link
Member

@asterite asterite commented Jun 4, 2016

The reason we'd want to introduce this is that now we can do Union(String, Nil) to refer to that union type in regular syntax, but nilable types are more commonly written as String? in other places where a type is expected. Now that Union can participate in many more places as it's a first class citizen, and given that nilable types are very common, maybe this change in syntax is good.

Note that T? and T ? are parsed differently: the first is a nilable type, the second is the ternary operator with T as a condition (of course if_true : if_false must follow, otherwise it's a syntax error). So this still allows using the previous "feature": using a type as a conditional (though that isn't useful by itself).

One could suggest adding T* after this, but this wouldn't be as useful as T? because pointers are unsafe, low-level.

@jhass
Copy link
Member

jhass commented Jun 4, 2016

Not everything is about usefulness, language consistency and coherence is as important, it allows you to predict what you can expect from a language, it allows you to abstract away different features into general mechanisms of the language. The more special cases there are, the more exceptions to a general rule, the harder it gets to keep those abstract concepts together and thus the harder it gets to fully comprehend a language. Sometimes that means rejecting a convenience since it would depend on too many more additions needed to make the big picture coherent again, to introduce the "abstract concept".

@asterite
Copy link
Member Author

asterite commented Jun 4, 2016

I agree with what you say, every new rule we add to the language could introduce additional complexity and make things not coherent.

The way I see it, we then have three options:

  1. Leave things as they are
  2. Make T? be equivalent to Union(T, Nil)
  3. Remove the T? shortcut from the type grammar

So, T? is a special case in the type grammar, doing T | Nil is the same, so we could remove it.

However, the ? at the end of something already means "nilable". For example [] and []?, to_i and to_i?, first and first?, etc. Having T? mean nilable would be consistent, in my opinion.

Then, some other languages also use T? to mean nilable types: C#, Swift (they call it optional types, but the concept is equivalent), Flow (though they put it as a prefix) and probably many others. That means that people coming from other language will find the concept familiar.

And, if Crystal is their first language, or they are not familiar with nilable types, they will still see T? in libraries and code, because it's used a lot (451 occurrences in the crystal repository), and they'll see it in API docs.

Now, the use cases:

  1. We could use it in JSON.mapping to pass Int32? instead of the more verbose Int32 | Nil
  2. In crystal-pg you can pass Int32|Nil as an expected type, so you could pass Int32?.
  3. In crystal-db we have read(T.class) and read?(T.class) methods to read non-nilable and nilable types from a database column. This is fine and works, but now let's write a method that reads an entire row, and you pass the expected types. You can't do that very easily. You'll have to call read? if the type is nilable (this check is kind of hard to do, it's not just using nil?) or read if the type is not nilable. It would be nice if one could pass T? or T to read and that's it. Then you can pass the types from the tuple directly. Of course one can use Union(T | Nil), but I don't see why we need the verbosity given that T? is available in another place in the language grammar.

A bit more of point 1. Check these codes, which one looks more consistent:

# 1
class Foo
  JSON.mapping({
    x: Int32,
    y: String | Nil,
  })

  property other : String?
end

# 2
class Foo
  JSON.mapping({
    x: Int32,
    y: String?,
  })

  property other : String?
end

If we don't introduce String? I can only imagine people asking "why I can't use String? here", not the other way around "Oh, this is not the type grammar".

Finally, making T? parse as Union(T, Nil) is not a breaking change, as it's unlikely that someone was using a type as a conditional in the ternary operator.

Anyway, I'll leave this PR floating here a bit more until we gather more opinion and evidence on this.

@jhass
Copy link
Member

jhass commented Jun 4, 2016

To clarify, the type grammar already has more special rules:

  • T? for T|Nil
  • T* for Pointer(T)
  • T -> U for Proc(T, U)
  • {T, U} for Tuple(T, U)
  • {foo: T, bar: U} for NamedTuple(foo: T, bar: U)

The inconsistency would be to port one of these to the regular grammar, but the others. In fact porting the last two is probably impossible, they are already valid but mean different things.

You simply move the "Oh, it's not the type grammar" from T? to one of the others.

@ozra
Copy link
Contributor

ozra commented Jun 4, 2016

In common syntax, I agree T? would be useful - it's such an integral part of the type-system, and idiomatic coding in Crystal. And of course the other (T* for instance) should not be facilitated. Personally I think T* should probably be usable only in lib/c-interfacing if at all (and that's from someone who do a lot of evil pointer working)

@asterite
Copy link
Member Author

asterite commented Jun 4, 2016

I understand. However, I think nilable types are more useful and used in general, as opposed to pointers, tuples and named tuples. I can also imagine wanting to read a nilable int from a database, or from a JSON, but not a nilable tuple, as these types usually don't exist in databases, JSON, etc.

There's on more, proc types, like Int32 -> String. But again, you can't parse a proc from a database, JSON, etc. Basically, I can imagine types being passed to methods to read/deserialize stuff, and nilable types are common, while pointers and procs are not (tuples and named tuples might appear too, but I'd say that they are not as common as nilable types).

@@ -1142,6 +1142,10 @@ describe "Parser" do

it_parses "Foo.foo(count: 3).bar { }", Call.new(Call.new("Foo".path, "foo", named_args: [NamedArgument.new("count", 3.int32)]), "bar", block: Block.new)

it_parses "Foo?", Crystal::Generic.new(Path.global("Union"), ["Foo".path, Path.global("Nil")] of ASTNode)
it_parses "Foo::Bar?", Crystal::Generic.new(Path.global("Union"), [Path.new(%w(Foo Bar)), Path.global("Nil")] of ASTNode)
it_parses "Foo(T)?", Crystal::Generic.new(Path.global("Union"), [Generic.new("Foo".path, ["T".path] of ASTNode), Path.global("Nil")] of ASTNode)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Foo?? and macro expanded types {{T}}? might be other nice test cases to be added.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added the Foo?? (and Foo????) cases, but {{T}}? shouldn't matter, that won't parse as {{T}} is not a type, and if inside a macro then it'll work because it will get expanded to something that later will be parsed.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unless T is a union itself since it is expanded as R|S and not Union(R, S). Maybe union to_s should be always be expanded to Union(...) syntax, unless a flag in the #to_s is given, like the one that skips parens...

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe. That doesn't save you when someone sends 'A|B' to a macro, because that's a call. The macro definition can use an explicit Union for this. This is what JSON mapping does.

@asterite asterite force-pushed the feature/nilable_syntax branch 2 times, most recently from 9746094 to 4fc32da Compare June 10, 2016 23:19
@asterite asterite force-pushed the feature/nilable_syntax branch from 4fc32da to 514c843 Compare June 27, 2016 21:12
@asterite asterite added this to the 0.18.6 milestone Jun 27, 2016
@asterite
Copy link
Member Author

Merged in 40c6f73

(I know some were against this, but it has many use cases and it'll eventually be more clear)

@asterite asterite closed this Jun 27, 2016
@asterite asterite deleted the feature/nilable_syntax branch July 3, 2016 23:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

4 participants