godotengine / godot-proposals

Godot Improvement Proposals (GIPs)
MIT License
1.07k stars 69 forks source link

Add support for nullable static types in GDScript #162

Open aaronfranke opened 4 years ago

aaronfranke commented 4 years ago

Describe the project you are working on: This applies to many projects. This is an offshoot from https://github.com/godotengine/godot-proposals/issues/737, example use cases and other discussions are welcome.

Describe the problem or limitation you are having in your project:

Let's say you have a method that accepts a 2D position, which would look something like this:

func whatever(vec):

A problem with this is that there's no type safety, so the function could unexpectedly break if the passed-in value is not a Vector2. One option is to use static typing:

func whatever(vec: Vector2):

This works, and now it's not possible for users to, for example, pass in a Color or any other type that's invalid for this method. However, now you can't pass in null to mean N/A or similar.

Describe how this feature / enhancement will help you overcome this problem or limitation:

If GDScript's static typing system allowed specifying nullable types, we would be able to restrict the type to either a valid value or null. The presence of a valid value can then be detected simply by checking if it is not null, as non-null nullable typed values must be valid values.

Show a mock up screenshots/video or a flow diagram explaining how your proposal will work:

My suggestion is to simply allow this by adding a question mark after the type name, which is the same syntax used in C#, Kotlin, and TypeScript. User code could look something like this:

func whatever(vec: Vector2?):

Describe implementation detail for your proposal (in code), if possible:

Aside from the above, I don't have any specific ideas on how it would be implemented.

However, I will add that we could expand this idea for engine methods. Many parts of Godot accept a specific type or null to mean invalid or N/A, or return a specific type or null when there is nothing else to return. For example, Plane's intersect methods return a Vector3 if an intersection was found, or null if no intersection was found. Nullable static typing could essentially self-document such methods by showing that the return type is Vector3? instead of Vector3.

If this enhancement will not be used often, can it be worked around with a few lines of script?: The only option is to not use static typing if you need the variable to be nullable.

Is there a reason why this should be core and not an add-on in the asset library?: Yes, because it would be part of GDScript.

Jummit commented 4 years ago

You could pass in Vector2.ZERO and check for it, as you would need a check either way. This is also safer, as null "could be the biggest mistake in the history of computing". I'm also kinda scared that the whole type system will get even more complex, which could scare away new users from gdscript. (I know the complex syntax of java scared me away)

bojidar-bg commented 4 years ago

Note that with nullable types we could require people to handle nulls explicitly, similar to how kotlin does it. So:

func whatever(v: Vector2?):
    print(v.x) # Warning or error: v could be null
    print(v?.x) # (if there is a null-checked dot operator) Prints null if v is null
    if v != null: print(v.x) # Does not print if v is null
    print(v.x if v != null else 42) # Prints 42 if v is null
Xrayez commented 4 years ago

Here's some current use cases of mine (if I understand the proposal correctly):

# Use custom RandomNumberGenerator, or the global one:
var ri = RNG.randi() if RNG != null else randi()
# Adjust impact sound volume by relative velocity
func _adjust_volume_to_velocity(velocity_override = null):
    var strength = max_strength
    if velocity_override:
        strength = velocity_override.length()
       # configure db ...

You could pass in Vector2.ZERO and check for it

The logic could fail exactly at Vector2.ZERO position if you depend on it here (for tile-based levels this could happen more often I believe):

var attach_pos = get_attach_point_position()
if attach_pos == null: # not attached to anything
    return linear_velocity
Jummit commented 4 years ago

The logic could fail exactly at Vector2.ZERO position if you depend on it here (for tile-based levels this could happen for often I believe):

I often use a getter (which returns a "safe" boolean) to check for stuff, for example:

 if not is_attached(): # not attached to anything
    return linear_velocity
aaronfranke commented 4 years ago

You could pass in Vector2.ZERO

@Jummit See the discussion in https://github.com/godotengine/godot/issues/32614 for why this doesn't work.

Jummit commented 4 years ago

You could pass in Vector2.ZERO

@Jummit See the discussion in godotengine/godot#32614 for why this doesn't work.

It works, just not everywhere. Also, OP makes a good point:

A better result might be to return Vector3(float.NaN, float.NaN, float.NaN).

I'm not proposing to use this in core functions, but in personal projects this is a good way to avoid null.

fab918 commented 4 years ago

It works, just not everywhere.

So it's not a good solution.

[from your link against the null value] For example, if a function returns a positive int number, then upon failure to calculate the desired return value, the function could return -1 instead of null to signify the error.

I'm not agreed with that, basically you return a valid value when you precisely want to warn that something failed. This approach lack of consistency (the value sometime will be -1, some time an empty string, etc.) and limited: what do you do when the function can return the whole range of an int?

As far I know, the cleanest solution for the return type case, it's to raise exceptions that must be handled by the region of the code that call the function. But error handling isn't available in GDScript and don't solve the case described by @aaronfranke

[from you link against the null value] which means that the compiler can't warn us about mistakes caused by null at compile time. Which in turn means that null exceptions will only be visible at runtime.

At least there is an error visible somewhere, if you return a valid value and forgot to handle it, there will be the worst case scenario for a bug: no hint.

I find this proposal essential in a typed environnement, especially for the type returned by a fonction, it also useful for parameters like @aaronfranke suggested, to keep all the logic of a function, inside it. The alternative being to do pre-checks before to call the function, and so have a part of the function's logic outside it.

Moreover, it becomes more important in conjunction of #173, where you will be able to force the typing, without this proposal, the solution for returning a "error/null" value from a fonction will be to disable the type system with the any keyword...

fab918 commented 4 years ago

If we want to get rid of the null and have a safe/clean approach, I was thinking about the Optional object in Java (how java solved the null issue)

If we implemented something similar in a less verbose/python like way, that a very good solution I think:

whatever(Optional(vec)) # or whatever(Opt(vec))
whatever(Optional.EMPTY) # or whatever(Opt.EMPTY)

func whatever(vec: Vector2?):
   var result := vec.get() if vec.is_present else Vector2.INF
aaronfranke commented 4 years ago

@fab918 The only practical difference between your proposal and mine is that you're wrapping the value in an object and using methods and properties instead of using a nullable type. I'm generally opposed to wrapper objects and this just adds complexity to something that would likely be easier if we mimic the way other languages do it.

For a nullable type, I would remove .get() and replace .is_present with != null:

func whatever(vec: Vector2?):
   var result: Vector2 = vec if vec != null else Vector2.INF
fab918 commented 4 years ago

@aaronfranke I'm fine with your proposal.

But if the problem with your solution for some , it's the usage of null as @Jummit has pointed out, because "it could be the biggest mistake in the history of computing". My proposal is an alternative that protects users from runtime error that a forgot null value can produce (more cleaner but more verbose).

Anyways, no matter the implementation, I think this feature is essential to fully embrace the typed GDScript, especially with #173

Wavesonics commented 4 years ago

The Kotlin style ?. Syntax is very nice to work with, but it does lead you down a rabbit hole of dealing with nulls that new people might find wired. ?.let {} in Kotlin, gaurd in Swift.

If you are going to do strict null typing as part of the type system that is.

That being said, I too think this proposal is essential for GDscript to be and to be used in larger projects.

mnn commented 4 years ago

Yeah, this is quite a serious issue, forcing me very often to skip types.

From the proposed solutions (assuming no more improvements to the type system are done, like type narrowing), the != null is weaker than .get(). While both are not great, at least .get() forces user to unpack the value and crash on the exact spot where "null" should have been checked. If user forgets to use != null, that value may propagate and can cause crash in unrelated parts of code.

I'm generally opposed to wrapper objects and this just adds complexity to something that would likely be easier if we mimic the way other languages do it.

This way it's done for example in Scala, Java, Haskell, OCaml, Reason, PureScript and looking at wiki in many more languages I never used. So it's definitely something other languages are doing too. Sum types are stronger (type-wise), because they force user to handle (test) for an empty value.

!= null could work if implemented like in TypeScript (type narrowing). For example accessing position of Node2D? (Node2D | null in TS) would not compile, because null doesn't have such property. But if you do a check first, the type get narrowed (inside if block) to Node2D and you can access the property if a != null: a.position = x.

I personally would prefer != null with type-narrowing, but if type-narrowing wouldn't be implemented, then I would go with wrapper object.

I am not sure of the scope of this proposal, but I would like to see supported it everywhere, not just function arguments or return type.

vnen commented 4 years ago

I've been thinking about this and what I have in mind is that all variable will be nullable by default. So even if you type as a Vector2 or int, you can get a null instead. This will decrease safety in general but it will be much simpler for fast prototyping, which is the main focus of GDScript. I will still keep default values as non-null (except for objects), so it's less of a hassle if you don't initialize variables (even though you always should).

Especially because internally the engine is mostly okay of taking null as values for the primitive types in functions and properties.

To counter that, we could introduce non-nullable hints, so it forces the value to never be null, even on objects. Something like this:

var non_nullable: int! = 0
var always_object: Node! = Node.new()

Using ! for non-nullable as opposed to using ? for nullables.

This will give an error if you try to set null, both at compile time and runtime if reaches that point.

aaronfranke commented 4 years ago

@vnen I very much dislike that idea. The amount of projects I've seen currently using static types everywhere shows that the use cases for non-nullable static types are extremely common, with this proposal only relevant for a small number of use cases. If your proposal was implemented, many people would fill their projects with !, or be left with bugs when nulls are passed.

I also think your proposal goes against the design goal that GDScript should be hard to do wrong. If a user specifies : Vector3, I think it's a bad idea to also allow null implicitly. It would be very easy to design a function that can't handle nulls and you forget to include the !.

I think I would actually rather have nothing implemented than !, since it would encumber GDScript with either many ! characters or many bugs, all for the few use cases where you want nullability, and in those cases there is already a decent work-around (no type hints).

There is also value in the simple fact that ? is familiar to programmers from many languages.

fab918 commented 4 years ago

Hi @vnen. First, thanks for all your good works, really amazing. It’s hard to deal with devs, and harder to make choices, so I wanted to support you

Here is my 2 cents, hope that can help.

I thought about the right balance between fast prototyping and robust code. I often saw this debate here and on discord, I ended to think the best is to avoid to compromise to ultimately satisfy no one. Instead, stick on 2 ways with opposite expectations:

So in this spirit, I will avoid decrease safety and stick on classic nullables approach in conjunction of #173 (seems directly related to this proposal to fully be able to embrace the type system). In addition, the use of nullable is rather standardized contrary to your proposal.

The type narrowing proposed by @mnn is the cherry on the cake but no idea of the implication on the implementation.

Jummit commented 4 years ago

Here is my workaround, that I think works in any case:

const NO_CHARACTER := Character.new()

func create_character(name : String) -> Character:
   if name.empty():
      return NO_CHARACTER
   return Character.new(name)

func start_game(player_name : String) -> void:
   var player_character := create_character(player_name)
   if player_character == NO_CHARACTER:
      return

You end up with some new constants, but I think it makes it more readable than using null.

Zireael07 commented 4 years ago

Related issue: https://github.com/godotengine/godot/issues/7223

Lucrecious commented 4 years ago

@vnen Going off what @aaronfranke mentioned, adding ! to Godot 4.0 would break backwards compatibility more than I think is warranted. I think most projects assume that the base types are not null. Putting backwards compatibility aside for now (since 4.0 doesn't prioritize support with 3.2), I think we'd see tons of int!, Vector2!, String! everywhere. Part of the usefulness of these types is that they aren't nullable by default - but that's my opinion.


On another note, what do people think about adding more null conditions in general? Currently, to null check you need to do something like var x = y if y != null else 10 but I think this operation is common enough that is should be allowed to be shortened to: var x = y ?? 10.

aaronfranke commented 4 years ago

@Lucrecious The first line you posted doesn't behave as you may expect, since if y will be false not only for null, but also for 0 and 0.0 and false and Vector2(0, 0) etc. If you want a null check, it would have to be y if y != null else 10

agausmann commented 4 years ago

While I do like wrapper types, coming from powerful type systems like Rust that use that kind of pattern, an Optional really isn't that useful in GDScript. The reason is because there's no way to specify (and therefore check) the type of the inner value, much like Arrays. It's not much better than removing the type from the function signature altogether. Sure, you have another pattern to check whether the value is null, but then you've still only guaranteed that it's a non-null Variant.

agausmann commented 4 years ago

To add to my previous comment: If we were to add a syntax that allows generic types like Optional<int> or Optional[int], I still don't think Optional is the best/cleanest choice.

I'm not convinced that null is a bad thing here. Yes, it can be hazardous in places where null is accepted as a value for any type, however that's not the case here. The static typing we have already rejects null values (the types are already "non-null", to put it another way). Given the existence of null, the much cleaner solution would be to extend the type syntax and add a "nullable" indicator, like int? or ?int, which allows null values to be passed in addition to values of the given type.

me2beats commented 3 years ago
func a(x:int?)
func a()->int?
m50 commented 3 years ago

I have an example where being able to define null or Vector2 as a return type would be necessary (unless it's changed internally how TileSets work):

tile_set.cpp:594

Vector2 TileSet::autotile_get_subtile_for_bitmask(int p_id, uint16_t p_bitmask, const Node *p_tilemap_node, const Vector2 &p_tile_location) {
    ERR_FAIL_COND_V(!tile_map.has(p_id), Vector2());
    //First try to forward selection to script
    if (p_tilemap_node->get_class_name() == "TileMap") {
        if (get_script_instance() != nullptr) {
            if (get_script_instance()->has_method("_forward_subtile_selection")) {
                Variant ret = get_script_instance()->call("_forward_subtile_selection", p_id, p_bitmask, p_tilemap_node, p_tile_location);
                if (ret.get_type() == Variant::VECTOR2) {
                    return ret;
                }
            }
        }
    }

Technically _forward_subtile_selection is allowed to return a variant, and only acts on it if the type is a Vector2, which is great, but if you statically type it as returning a Vector2, there is no way around this.

func _forward_subtile_selection(autotile_id: int, bitmask: int, tilemap: Object, tile_location: Vector2) -> Vector2:
    return null

This errors with The returned value type (null) doesn't match the function return type (Vector2). So either you must leave off the return type, or completely reimplement autotiling code. Being able to define:

func _forward_subtile_selection(autotile_id: int, bitmask: int, tilemap: Object, tile_location: Vector2) -> ?Vector2:
    return null

just like in many languages would get around the problem. For reference, Vector2.ZERO is a valid value, so that can't be used. I suppose a negative vector may be fine, but it would be better if we had either union types or nullable types.

dsnopek commented 3 years ago

On Twitter, @vnen wrote:

My suggestion for "default nullable" was more to be consistent because object types always have to be nullable.

See https://twitter.com/vnen/status/1290697381470711810

So, my question is: why do object types always have to be nullable in GDScript?

There are other languages where objects type hints are non-nullable by default, for example, in PHP:

class MyClass { }

function null_not_allowed(MyClass $variable) { }

function null_allowed(?MyClass $variable) { }

// Works.
null_allowed(null);

// Fails.
null_not_allowed(null);

Why can't GDScript work in the same way as PHP, where objects type hints are non-nullable by default?

Working with the optional typing in PHP (where the type hints are non-nullable by default) is honestly really great. One of my least favorite things about working in C# (7.0 or earlier) is that all object type hints are nullable, so you have to constantly be accounting for one of your method parameters being null and/or deal with the inevitable crashes. BTW, this is generally considered a mistake in the design of C# (article by the lead designer of C#) which is apparently fixed in C# 8.0 (although, I haven't personally used 8.0 yet).

Could GDScript perhaps avoid having this design mistake? :-)

(As an aside, when optional type hinting was added to PHP, there were only object types and they were only non-nullable. Primitive type hints and nullable type hints were only added later.)

reduz commented 3 years ago

Doing this will probably significantly affect the VM ability to optimize the code and the complexity of the parser because, internally, things are either typed or not and this is a grey area. Additionally, I don't like the feature myself, I think it makes code more difficult to understand (you can't take things for granted when reading it, you need to add an extra state for variables) and does not really add a lot of value for the large cost it implies implementation wise.

dsnopek commented 3 years ago

@reduz Thanks for responding!

does not really add a lot of value for the large cost it implies implementation wise.

Type hints provide two possible values:

  1. Allowing the VM to optimize performance based on the types (not done in Godot 3.x, but planned for Godot 4.x)
  2. Allowing the developer to depend on type safety so that you can write code with the assumption that a particular method will always return a certain type, or that your method will be called with parameters of a certain type, to prevent unexpected crashes.

I don't know the internals of the compiler and the VM the way that you and @vnen do, but it sounds like you're arguing that having nullable types isn't worth implementing because it wouldn't be able to easily provide the performance value.

However, I'd personally be happy with nullable types sacrificing performance (ie. running the same as untyped code) while still providing the safety of type hints. If nullable type hints acted internally just like untyped code, but simply changed the type checks that happen with checking the parameters and return values on a function call, that'd be absolutely fine. :-)

In fact, if we imagine a world where in Godot 4.x type hinted code runs with faster performance, but doesn't have nullable types, then the only option we have when we need to return or accept null is to drop type hints completely, which means we lose both performance and safety. If there was a way to keep safety (and a nice developer experience), even at the expense of performance, that's still an improvement from where we're starting from (in this imaginary world).

vnen commented 3 years ago

So, my question is: why do object types always have to be nullable in GDScript?

There are other languages where objects type hints are non-nullable by default [...]

You have to remember that GDScript is made in the context of Godot. It isn't always possible to replicate what you see in other languages.

In the context of Godot, there are many APIs that will return a null instead of an object, like when some error happens. If you try to get_node() and it doesn't exist, you get null as a result. Changing this requires changing a lot of the behavior in the internal API, even when GDScript is not involved. While this isn't impossible, it is a great amount of work, so you to be very convincing on the advantages of it.

This is generally not the case for built-in types. If something says it returns a Vector2, it always return a Vector2 (I know there might be some exceptions but they are likely some mistake and should be solved, and it's a much smaller amount of work).


Regarding nullable types, we do have to consider all possible implications in the VM, type-checking, optimizer, etc. to see if it brings enough advantage to be justified.

If we do add nullable types, we could say that object types are non-nullable and mark all of the API as being nullable when it comes to objects. That would soon get in your way and a lot of extra checks would be needed in your code, even in the cases where you can be confident. It would be a hassle to write code like this (unless you declare all of your variables to be nullable, but then it misses the point of the feature).

That's why I mentioned the addition of a non-nullable mark: then you could add those checks when relevant and be safer in the rest of the code when that's important. People say that then it would litter the code with those marks, but I have a feeling it would be the opposite: if you add a nullable mark then you would use that a lot, because you would be kind of forced given the internal APIs.

Again, we need to consider the implications of adding such notation. Might not be as beneficial as people think.

fabriceci commented 3 years ago

@vnen thank you for explaining the stakes, you should always do it, now I understand why you propose this "unexpected" solution.

so you to be very convincing on the advantages of it.

Typing's users want a fully typed environment, currently we have 2 bigs problems that make impossible to have a fully typed project:

In the context of Godot, there are many APIs that will return a null instead of an object, like when some error happens. If you try to get_node() and it doesn't exist, you get null as a result. ...That would soon get in your way and a lot of extra checks would be needed in your code

that's why other languages use error handling when an error happens (get_node not found) instead of null. You can implement extra logic if you want to handle that case in a script or let the error for the output console. I think is the cleanest approach.

But I/we understand that this kind of solution will involve too much work (for now?) or maybe performance/design issue. That lead us for 2 choices:

For my point of view, you proposal is fine now we know that's not just a decision based on syntax preference. I prefer you spent time for performances like AOT (is there a proposal for that?) instead of turn the ! to ?.

Lucrecious commented 3 years ago

Typing's users want a fully typed environment, currently we have 2 bigs problems that make impossible to have a fully typed project:

  • No nullable solution, in particular for functions (arguments and return)
  • Any type with an option to force typing in the project script.

I agree that these are the big problems with GDScript if users want a fully typed environment.

I'm still confused as to way this is such a difficult endeavor?

Isn't it possible to simply treat a nullable Vector2 as its own type? Maybe under a wrapper class, like for weakref and automatically generate corresponding nullable types to all the value types like Vector2, Transform2D, etc. i.e. a type can be NullableVector2 and then we can use Vector? as syntactic sugar. NullableVector2 ~can simply be a ref with functions like is_null, in addition to reflecting~ can reflect all Vector2 methods and return true/false on equality checks with null.

# if we have
var vec: Vector2
var n_vec: Vector2?

# then
type(vec) != type(n_vec)
not n_vec is Vector2
n_vec is Vector2?

# as operator always returns a nullable type
var other_vec: Vector2? = n_vec as Vector2
var other_vec2: Vector2 = n_vec as Vector2 # works like before, if n_vec were a variant, error when it's null

Would something like this be a possible solution?

Wavesonics commented 3 years ago

I'm mostly just lurking here and following along, but the point about nullible types requiring a bunch of extra code from the user can be mitigated quite a bit with things like the null coalescing operator foo.?bar() # only calls bar if foo isn't null

That would be a really nice operator to have even if we don't go the full nullable types route.

Kotlin has a very strict and full featured type system incorporating null but they've made it very nice to work with even though you are forced to deal with nullability everywhere.

me2beats commented 3 years ago

Sorry if I missed something, but I don't see what additional code is required from the user when using nullable types.

As vnen already said, many godot API functions can return null. hence godot already forces the user to check these values ​​for null.

gdscript is also known to have no try/except mechanism.

In this case, if I create a function that should return for example an integer, but it does not return it for some reason, then what should I do? I want to continue to use the static typing convenience.

So I see two solutions here: nullable types or try/except

reduz commented 3 years ago

@me2beats You can return null for Object types, the rest of the built-in types do not support it.

I also don't think a fully typed GDScript is something desired from the practical standpoint. GDScript is meant to be a simple language with optional typing. Unlike languages like C#, the balance will always be towards being a dynamic language rather than a typed one, this makes the implementation simple and easy to maintain. The main advantage you will get with typing will be performance. If you wanted a fully typed language I think its better to use C#, C++ or something else.

reduz commented 3 years ago

This is difficult to do because right now type propagation assumes types are set in stone. This allows the compiler to do much better optimizations when emitting typed instructions for the VM. If types all could suddenly become null, you would need to do either one of two things:

So in general, this is just not really a good idea. For languages like C# this kinda makes sense because they use pointers for everything and have little amount of built-in types, but GDScript does not and it has a large amount of built-in types so this feature will most likely never happen.

You have to understand due to the way the engine is designed and what its main purpose is (being a simple, easy to use language that you can effortlessly embed into the engine) makes this impossible, this is why I don't think GDScript will ever go the path of becoming a language with more advanced strong typing other than mostly hints and used for some optimizations. C# is clearly the way to go for this from our current options.

aaronfranke commented 3 years ago

@reduz As mentioned in the OP, the current way to have a nullable types like Vector2 is by simply not using the type hints, so it is already possible. var vec: Vector2? would prevent Vector3 or Node etc, but still allow both null and Vector2 just like is possible currently by not writing the static type (var vec).

Also, I don't see what this has to do with GDScript "being a simple, easy to use language", since if someone doesn't use Vector2? in their code then this feature shouldn't impact them.

Also, structs in C# do not use pointers, they are value types passed by value, just like int.

Also, what do you think of this from the OP?

However, I will add that we could expand this idea for engine methods. Many parts of Godot accept a specific type or null to mean invalid or N/A, or return a specific type or null when there is nothing else to return. For example, Plane's intersect methods return a Vector3 if an intersection was found, or null if no intersection was found. Nullable static typing could essentially self-document such methods by showing that the return type is Vector3? instead of Vector3.

dsnopek commented 3 years ago

Unlike languages like C#, the balance will always be towards being a dynamic language rather than a typed one, this makes the implementation simple and easy to maintain. The main advantage you will get with typing will be performance. If you wanted a fully typed language I think its better to use C#, C++ or something else.

While I'd still personally prefer the developer experience of PHP-style optional types that are non-nullable by default (and that you can optionally mark as nullable), I think you may have won me over, although I'm still thinking it through...

I hadn't realized that object type hints were, in fact, already nullable in GDScript in Godot 3.x (ie. func my_func(a : Node2D) actually allows null to be passed in as a parameter). I had thought that all type hints were non-nullable, but after some quick experimentation, it looks like it's only builtin types that are non-nullable. So, we've basically already made the design decision I was imagining that GDScript could avoid.

That really makes @vnen's original proposal of marking types as non-nullable make a lot more sense to me now.

If the purpose of type hints is really just about increasing performance, and NOT improving developer experience, then, yeah, maybe it's best to just treat GDScript like a language without type hints (ie. Python), and then use C# for when you want everything to be fully typed.

I think I may have just got caught up in the idea that I should try and put type hints on everything in GDScript, and so these situations where I couldn't (and nullable type hints would have allowed me to) were really annoying. :-) But maybe I just need to relax and accept that not everything can be type hinted in GDScript, and that's OK? (And, consequently, to just use C# when I have a lots of custom classes and would really benefit from having everything fully type hinted.)

reduz commented 3 years ago

@aaronfranke Not using the type hints is not a good idea because then you need to propagate that types are nullable and then even if to you the code is typed, the performance will be abysmally worse.

You guys have to understand that: 1) Languages will nullable types just works different, and they assume all types are pointers. Its not the case of GDScript because bult-in types are not pointers, so it's just a big hack no matter how you want to do it. 2) The sacrifices, in my opinion, are not worth the feature

reduz commented 3 years ago

You just have to assume that GDScript is and will be a dynamically typed language with optional types and a simple VM, there is no chance it will become a language similar to something like Haxe, C#, etc. Code simplicity and maintainability is priority over corner case features.

dsnopek commented 3 years ago

Like I said above, I'm mostly won over to the idea that GDScript shouldn't have nullable type hints, and that C# or other languages should be used when fully typing your code is desired. That said... :-) I hacked up a quick, naive implementation of nullable type hints:

https://github.com/godotengine/godot/pull/41057

I've never been in the code for GDScript before, so I'm sure it's bad and wrong. ;-) But I'd be very interested to know how it's wrong to fully understand the roadblocks to the low-level implementation of this. Of course, I know you guys are busy doing more important things, so feel free to ignore if you'd like. :-)

reduz commented 3 years ago

@dsnopek as I mentioned, right now the bytecode is all unityped so it does not make much of a difference, the problem is with the new bytecode interpreter.

reduz commented 3 years ago

Ah actually, I have an idea on a way that this could be done, but it's up to @vnen to decide anyway.

In the bytecode, when you use a nullable type for anything, we can emit an extra instruction in the bytecode to check whether its null. This would solve the performance and complexity.problems.

Lucrecious commented 3 years ago

Isn't it possible to simply create new types that inherit from basic type or something, and then use syntactic sugar to make them "nullable types" and clean to use as a user?

class NullableVector2 extends basic_type:
    var vec
    func get():
        return vec

Then use nice syntactic sugar evaluated by the parser/tokenizer:

var vec: Vector2? = Vector2(1, 0)
var rotated_vec := vec?.rotated(PI/4) as Vector2?

Would be translated to something like:

var vec: NullableVector2 = NullableVector2(Vector2(1, 0))
var rotated_vec := (vec.get().rotated(PI/4) if vec.get() != null else null) as NullableVector2
dsnopek commented 3 years ago

as I mentioned, right now the bytecode is all unityped so it does not make much of a difference, the problem is with the new bytecode interpreter.

@reduz Is there a branch somewhere that we could see the new bytecode interpreter? Or is that just a work-in-progress on @vnen's local drive at this point?

reduz commented 3 years ago

@dsnopek currently its being worked on, I think @vnen will commit it in some weeks

Xrayez commented 3 years ago

You just have to assume that GDScript is and will be a dynamically typed language with optional types and a simple VM, there is no chance it will become a language similar to something like Haxe, C#, etc. Code simplicity and maintainability is priority over corner case features.

To all people reading this and still expecting that GDScript (or Godot) is going to solve performance problems in exchange of added complexity, this is stated at documentation level now:

Also, Godot's philosophy is to favor ease of use and maintenance over absolute performance. Performance optimizations will be considered, but they may not be accepted if they make something too difficult to use or if they add too much complexity to the codebase.

I personally accepted this some time ago myself. 😛

mnn commented 3 years ago

I don't like the feature myself, I think it makes code more difficult to understand (you can't take things for granted when reading it, you need to add an extra state for variables) and does not really add a lot of value for the large cost it implies implementation wise

Totally disagree. In every project when I/we moved from wild west to something saner (e.g. JavaScript -> TypeScript in strict mode [null and undefined must be explicitly stated via union or ?], Ruby -> Haskell [Maybe monad] or Clojure -> Haskell), bugs dropped down significantly (e.g. no longer undefined spreads like a plague in whole app which is quite hard to track down). Yes, it meant learning something new and we/I were slower at first, but boy it was worth it. I rather spend few more minutes talking with a compiler and fixing errors it found, than pushing a feature and then, few days later, going back to it several times because of course I wasn't perfect and there are some undefined/null or other type errors in less frequent use cases.

If you wanted a fully typed language I think its better to use C#, C++ or something else.

I learned the hard way that using a non-first-class language (for which the library/engine wasn't designed, isn't primarily developed for/in) is not worth it.

I tried C# with Godot some time ago (a year?) and it was missing even basic features from GDScript, overall felt like a tacked on hack (it was common for a project file to break, there were issues with error reporting and so on). At least it had some docs. I also tried C++ with Godot (not modules, the Native"Script" or how it's called) and that was even worse - virtually no docs, undocumented differences to GDScript, undocumented custom memory management, bugs in basic stuff like exports and very hard to set up initially (set up was documented, but incorrectly - you ended up with a mess which didn't even compile).

For me, it's either Godot+GDScript or Unity+C#. I don't intend to repeat the previous experience, waste one or more workdays.

Code simplicity and maintainability is priority over corner case features.

Hmm, so type safety and error handling is a corner case feature. This "corner case feature" is so insignificant, that giants like rigid Java has implemented it, majority of new languages are emphasizing in their descriptions how they are improving null handling and assuring developers they are not repeating past mistakes.

I call it my billion-dollar mistake…At that time, I was designing the first comprehensive type system for references in an object-oriented language. My goal was to ensure that all use of references should be absolutely safe, with checking performed automatically by the compiler. But I couldn’t resist the temptation to put in a null reference, simply because it was so easy to implement. This has led to innumerable errors, vulnerabilities, and system crashes, which have probably caused a billion dollars of pain and damage in the last forty years. – Tony Hoare, inventor of ALGOL W.

In my opinion and experience, any project larger than few dozens lines of code should be using a statically typed language. Every time I break this, I regret it later and end up rewriting the project (if it's possible, if not I will do my best to never touch it again). Unless you want to target only students and small hobbyists, I would recommend either improve static typing of GDS or make some other proper language a first-class one.

aaronfranke commented 3 years ago

@mnn The reason you experienced issues with C# isn't due to C# being second-class, but due to C# being a work-in-progress. C# support has been constantly improving over the 3.x series, and by 4.0/4.1 should be just as feature-complete as GDScript. GDNative is also a work-in-progress, although GDNative isn't constantly improving like C# is, it's poorly maintained.

That said, I absolutely agree with you that I don't see this as a corner case feature. Aside from major languages implementing this feature, when it comes to Godot specifically, the over 50 :+1: on this proposal indicates that many Godot users want this feature. IMO a good type system is 10x more important than, for example, lambdas.

mnn commented 3 years ago

Oh, I didn't know the C# support is considered experimental. Only thing on the official page I found is:

Script with full freedom More choices than any other engine. Full C# 8.0 support using Mono.

Obviously I don't agree with the second line (if your choice is fairly well functioning and integrated GDScript or anything else which has too many issues, it's not really a choice). The third line suggests that C# support is complete, or at least that is how I understand it, maybe incorrectly.


Clarification to my previous post - majority of that experience is on non-gamedev projects. I don't think it matters much, but I realized that maybe it is a bit different in gamedev. That said, I am not entirely green in gamedev and Godot, our current project has over 10k LoC and I miss better typesystem quite often. Utils library we are using has a bunch of _or_crash and _or_null suffixes in functions to make it more explicit what it returns and what should the code calling it be handling.

Calinou commented 3 years ago

@mnn That sentence about full C# 8.0 support is actually incorrect, since only the C# 8.0 language features are supported (not the standard library features). This has been fixed in the master branch thanks to https://github.com/godotengine/godot/pull/40595 :slightly_smiling_face:

It's probably not possible to backport it to the 3.2 branch though: https://github.com/godotengine/godot/pull/40595#issuecomment-664581523

We should probably edit the sentence in question on the website, but I don't know if we should replace it with "Full support for the C# 8.0 language (C# 7.0 standard library) using Mono". It sounds quite verbose.

y0nd0 commented 3 years ago

I just think aloud ... I'm coming from TypeScript and have an idea for union-types in GDScript. I found this issue: #737 Maybe different topics. In best case both proposals are nice to have. Optional and Union-Type. But union types are more powerful, because it also can handle nullable like:

var pos: Vector3 | null
var value: int | String
var num: int | float
var rnd: int | float | String | null
func get_something() -> Vector3 | null:

# Optional (TypeScript-like)
var pos?: Vector3
func do_it(pos?: Vector3) -> void:
# or rather?
var pos: Vector3?
# Also methods / func:
func get_something() -> Vector3?:
# or:
func get_something()? -> Vector3:
# or
func get_something() -> ?Vector3:
var foo: ?Vector3

In TypeScript, ? on function return type is not possible. Only union-type. Optional chaining would be great too. pos?.x or get_pos?.()?.x

Syntax:

obj?.prop
obj?.[expr]
arr?.[index]
func?.(args)

Just some thoughts. I'm not very experienced in Python or GDScript. Just to possibly trigger ideas. I refer to JavaScript and TypeScript optional typing, nullable values, union types and optional chaining. Anyway, nullable (optional) types would be nice. Otherwise it's not possible to set null on a type. And e.g. Vector3(0,0,0) is not the same as null. Zero is a valid value.

Another idea is, to set a global flag for this handling. Like in TypeScript "strict-mode" which disallows nullable use (if not checked). By default (not strict-mode) it allows the use of null, also if this is not declared. This is maybe the simplest way for Godot / GDScript.

ShalokShalom commented 3 years ago

I love the idea to implement it as a union, that is exactly as almost all the other languages who implement an option type do it. I know GDScript is not like every other language, while I believe they have a good reason to implement it like that and dont regret it. ☺