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

Add support for nullable static types in GDScript #162

Open
aaronfranke opened this issue Oct 16, 2019 · 207 comments · May be fixed by godotengine/godot#76843
Open

Add support for nullable static types in GDScript #162

aaronfranke opened this issue Oct 16, 2019 · 207 comments · May be fixed by godotengine/godot#76843
Labels
requires core feedback Feature needs feedback from core developers topic:gdscript
Milestone

Comments

@aaronfranke
Copy link
Member

aaronfranke commented Oct 16, 2019

Describe the project you are working on: This applies to many projects. This is an offshoot from #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
Copy link

Jummit commented Oct 16, 2019

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
Copy link

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
Copy link
Contributor

Xrayez commented Oct 16, 2019

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
Copy link

Jummit commented Oct 16, 2019

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
Copy link
Member Author

You could pass in Vector2.ZERO

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

@Jummit
Copy link

Jummit commented Oct 16, 2019

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
Copy link

fab918 commented Oct 26, 2019

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
Copy link

fab918 commented Oct 27, 2019

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
Copy link
Member Author

aaronfranke commented Oct 27, 2019

@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
Copy link

fab918 commented Oct 27, 2019

@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
Copy link

Wavesonics commented Dec 3, 2019

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
Copy link

mnn commented Jan 21, 2020

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
Copy link
Member

vnen commented May 15, 2020

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
Copy link
Member Author

aaronfranke commented May 15, 2020

@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
Copy link

fab918 commented May 15, 2020

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:

  • For those who seek a fast prototyping language, don’t use types. Make sense, they are probably not those who will be bothered with them.
  • For those who seek a robust application and/or the maximum performance, use types, but it should be the most restrictive as possible to actually be robust.

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
Copy link

Jummit commented May 16, 2020

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
Copy link

Related issue: godotengine/godot#7223

@Lucrecious
Copy link

Lucrecious commented Jun 23, 2020

@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
Copy link
Member Author

aaronfranke commented Jun 23, 2020

@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
Copy link

agausmann commented Jun 23, 2020

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
Copy link

agausmann commented Jun 25, 2020

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
Copy link

me2beats commented Jul 9, 2020

func a(x:int?)
func a()->int?

Macksaur pushed a commit to Macksaur/godot that referenced this issue Jun 21, 2024
This PR aims to add a feature-set to GDScript tailored
towards the safe usage of potentially-null expressions.
It was initially based off godotengine/godot-proposals#162
and through several discussions over RocketChat it grew to be what it is currently.
@twigleg2
Copy link

I read the entire thread and I want to add my support for Kotlin-style nullables, and I have a use case to share. I'm going to use GodotSteam in this example, but please keep in mind this would apply to wrapping any third-party API.

Consider this method from GodotSteam. GodotSteam wraps this and many other Steamworks API methods which are written in C++. These C++ methods often expect null values to function appropriately.

Currently, this method from GodotSteam is written to accept a string as the second parameter, which means that I can't use the full functionality of the Steamworks API by passing null in its place. The obvious "solution", if you can call it that, would be to change the GodotSteam method to accept any value as the second parameter, but that adds confusion to the method and its signature for anyone looking at the documentation, the auto-complete suggestion within the godot editor, etc. It's not a great user experience, as I've come to find out.

Adding increased flexibility with nullable types will help write cleaner code. I would like to highlight MrFrozenScreen's post above:

Untreated nulls are a problem, but it is what actually is happening by default when you don't make the type explicit. Your options are "accept anything" or "accept this type only". And if there is the possibility of getting a null, then you have to go with "accept anything".

Lastly, those developers who do not like nullables can choose not to use them and their experience with GDScript will be unchanged.

@pennyloafers
Copy link

There is a lot going on here, and i couldn't read it all.

If you dislike the Variant type because it cannot guarantee the type, but static typing is too strict, and you want more types, i.e. null. I think this is a nasty code smell. There are many reasons to not make null a first class citizen as it requires all your code to be defensive.

What will end up happening is that you will need to check if the value is null in the function. So why not check if the Variant value is the desired type, or null, now? Would this not be the same?

@SlugFiller
Copy link

What will end up happening is that you will need to check if the value is null in the function. So why not check if the Variant value is the desired type, or null, now? Would this not be the same?

You're thinking from the direction of the callee. Yes, the callee can simply do type checking. It does create a "But what do you do if the value is neither null nor the desired type" sort of situation, but let's ignore that for a moment, or assume you can do an appropriate error return.

The issue is rather in the caller. The autocomplete for function parameters, as well as any auto-docs, simply see a parameter receiving Variant. There's no indication of what type it's supposed to be. Even if this is covered in the documentation text, the caller doesn't have any compile-time warnings if they send a value that is neither null, nor the expected type. None of the protections static typing generally provides are present. Instead, the caller ends up hitting this at runtime.

As the OP mentions, and provides examples, there are method, even in core, where a single static type is not an option, and they reluctantly use Variant, erasing the information about the type actually used.

Making a type system that properly tags what types are or aren't allowed isn't "code smell". Rather, using Variant as a catch-all, when 99% of value types could only result in a runtime error if passed, that is code smell.

@SlugFiller
Copy link

P.S. The fact that you can't make core be type-safe is a pretty big deal. Yes, some people prefer type safety, and some people prefer to run untyped. But you don't get to choose which core you use. Any type system that can't cover the core functionality of the engine, is an incomplete type system.

@pennyloafers
Copy link

pennyloafers commented Aug 11, 2024

"But what do you do if the value is neither null nor the desired type"

You treat it as null, assert immediately or do nothing. As this scenario implies you used the function wrong. So why try to handle undefined behavior gracefully?

You can't solve all the worlds problems in a single function just tell the programmer as soon as possible that they are not doing something the code can't handle.

@SlugFiller
Copy link

It is the policy of Godot that "a game should never crash". That includes handling developer error gracefully. Even if you do manage to walk out of an assert in one piece, it doesn't change the fact that it would only trigger at runtime, rather than compile time.

If you think about real-life-scale projects, where there could be tens of thousands of lines of codes, and many of them might not trigger unless reaching some advanced level or secret area in the game, you do not want to be left with the chance of an unexpected assert, where no automated tool can warn you that it's there before your game lands in the hands of hundreds of thousands of players who absolutely will stumble upon it.

And with the issue being present in core, there's no way to design around it, other than limiting to game types that do not require said core functions.

@pennyloafers
Copy link

pennyloafers commented Aug 11, 2024

Test and exercise the code.

You will begin to make assumptions when handling null, and what are the consequences to that? soft locking a game? Exploits? Unintended behavior? Choose your poison. I would change the intersection ray class to not return null instead of promoting handle-by-assumption patterns.

It puts an undue burden on the developer to handle all scenarios and it will still be an assumption. If i want to add two numbers and return the sum. It doesn't make sense to return a zero when one or more numbers are null as it should be undifined. You will want to tell the developer as soon as possible that the function didn't do what they wanted. And Godot already does this with static typing.

Function- "When my function signature conditions are met i will perform my responsibility. Not, i might do something."

@SlugFiller
Copy link

Please explain what you would change the ray class to. If it doesn't return null, what does it do instead? There's also a situation in which a ray might not hit any target, and the code necessarily has to handle that. There's no way around it. It can't assume an intersection and make calculations based on it when none occur.

With nullable type, the return type would be Vector3?. If the developer attempts to use it directly as a Vector3 (i.e. Assume an intersection), a compile time error would occur, indicating the error before the game is even executed, and without needing to write any tests. Using the intersection would require unpacking it, which implicitly entails a test on whether an intersection occurred or not (Especially if explicit narrowing syntax is used). This drives the developer to write handling (or graceful failure) for the case of no ray intersection, even if they were not originally aware that's a possibility.

The alternatives to returning Vector3? would be:

  1. Returning Variant, but meaning Vector3?, as is done now. The developer is forced to coerce the Variant to Vector3, or write untyped. The prior doesn't give any indication that there should first be a null test, as the code with or without a null test looks otherwise identical (Especially to the analyzer). Failing to do a null test gives a runtime error. The latter case is essentially the same, but without so much as a warning about doing type coercion.
  2. Returning a class or tuple (bool didIntersect, Vector3 intersectionPoint). If didIntersect is false, then accessing intersectionPoint is either undefined behavior, or a runtime error. Either way, nothing indicates to the developer, at compile time, that didIntersect needs to be checked prior to accessing intersectionPoint. So, the most natural course of action, which is to just access intersectionPoint, is basically equivalent to, or worse than, using an unchecked null.

If you have a third option, one that prevents the developer from assuming an intersection occurred (unless on purpose), I'm all ears.

And, mind you, Godot doesn't exactly have a framework for unit-testing scenes and scripts. So hitting an issue at runtime rather than compile time is a big deal. Although, that might be a different venue to chase after.

@julian-a-avar-c
Copy link

The third option is an Option type. Imaginary Godot syntax for generics:

class Option[T]:
  var value: T
  func _init(a: T):
    self.value = a

class Some[T] extends Option[T]:
  func _init(a: T):
    super(a)

class None extends Option[Nothing]:
  func _init():
    super(pass) # Like Scala's ???, or Kotlin's todo, I suppose the closest thing in gdscript is `pass` since it can be used to not return the thing you promised you were gonna return, but I suppose that's up to debate.

The type that you'd be looking at in that case would be: Option[Vector3], and you can construct it as follows Some.new(4), Option.new(Vector2(1, 2)), None.new().

The cool thing about this approach, is the variations, like Result, Either, Future, etc. And its all part of the generics issue thread.

Another alternative is union types. var x: A | B can contain an A or a B, and therefore Vector3 | Null could be type. There are many other ways that are more generic and extensible.


But the approach that was implemented a while ago iirc makes every variant implicitly nullable, and then has some flags for type safety, which makes it equivalent to a union type that only works for the Null type.

@sullyj3
Copy link

sullyj3 commented Aug 12, 2024

I think a good compromise is to provide an equivalent of rust's unwrap. This provides an escape hatch for those who want one to avoid handling cases they believe can't happen.

var v1: Vector3? = some_function()
var v2: Vector3 = some_function().unwrap() # crashes if null

@sullyj3
Copy link

sullyj3 commented Aug 12, 2024

Another possible compromise could be a project wide configuration setting like C#, which when on statically enforces handling the possibility of nulls, and when off defers the checks to runtime.

@SlugFiller
Copy link

Variant can already be implicitly null, as part of being able to be just about anything else.

Option[T] is just T? but with more letters. It's functionally, and semantically, identical.

Union types are indeed a valid generalization. I believe there's already a proposal for it. The issue is that it's too generic, and hence more difficult to implement and maintain. Additionally, it potentially violates Godot's problem->solution principle. i.e. There's a clear concrete case where you'd need Vector3 | Null, but no concrete case for e.g. Vector3 | Vector2, or any other combination of types of which the nearest common ancestor is Variant. Maybe int | float, although float semantically behaves like an ancestor of int.

As for .unwrap(), it would violate Godot's "a game should never crash" principle. If you're absolutely sure that it couldn't possibly happen, you can use ?? to force the unwrap, e.g. some_function() ?? Vector.ZERO. Vector.ZERO would potentially be nonsense and/or bug-inducing if used, but if you can safely presume it could never possibly be used, then it doesn't matter. If there's even a tiny chance a null-return could occur, then you must have a proper graceful implementation of what to do in that case. And, at any rate, the bugged result of using a nonsense value is still better than crashing the game. That's the difference between a game, where progress and continuous activity are the most important, and it's better to have some visual bugs or even odd behavior or even exploits than to crash, vs e.g. server software, where data integrity is the most important, and it's better to crash than to end up with corrupt data.

@sullyj3
Copy link

sullyj3 commented Aug 12, 2024

When developing, I would much rather have an immediate crash that tells me what went wrong than try to trace my way back to where the type correct but invalid value was introduced.

I'm a Haskeller, I've used Elm, I've seen where dogmatic adherence to "all errors at compile time, no crashes" leads. It leads to languages that are a giant pain in the ass to use. You need escape hatches as a concession to practicality. It's irritating and demoralizing to write a bunch of code to deal with cases that you know are impossible but the compiler is too stupid to understand. It impedes rapid iteration, which is something that is a far more important priority for game development. You need to be able to try out gameplay ideas quickly, or you'll never get anywhere. Nobody wants to fight the compiler over code they'll likely end up throwing out.

@sullyj3
Copy link

sullyj3 commented Aug 12, 2024

I think having a default of forcing programmers to handle nulls is plenty enough of a pit of success. Every programmer should understand that calling "unwrap()" is promising that this will never happen, and they're taking full responsibility for the consequences. If they get it wrong, that's easily fixed by grepping "unwrap". "games should never crash" should not be used as dogma to impede a programmer from doing what they want, if what they need in that moment is to get something up and running fast. That's their decision, and if good defaults are provided, it's paternalistic to prevent them from opting out.

@Gnumaru
Copy link

Gnumaru commented Aug 12, 2024

As I have said before in this thread, nullable types are just syntatic sugar over unions with null. And unions are, again, just an specialization of the catch-all godot Variant.

For nullable types in gdscript to be implemented first we would need to define a standard way of implementing generic classes in core. I haven't looked at the current generics implementation in the Array class (and the pending PR for Dictionaries), but we would need to set in stone a standardized system for doing generics in core that can be exposed to script.

After implementing generics, we would need a new variant similar to c++17 std::variant, but with a different name to not cause confusion. Suppose it is called Union and used with variadic generic parameters like Union<null, int>, Union<null, int, float>, Union<null, int, float, String> and so on up to some maximum number of parameters like 4, 8 or whatever. Once that is done, the core c++ implementation of the functions that receive and return Variant could be refactored to receive and return things like Union<int,null>. Up to this point whe whould have only implemented things in core, nothing related to gdscript.

Then, we could finally change the gdscript module to add functionality to use Unions and first add the syntax needed to parse var v:Union<int, null> and next the sugar needed to write var v:int? instead of that.

edit: We should not that there is already a union that occurs all the time, the union between TYPE_NIL and TYPE_OBJECT. I have no idea if this was implemented only in gdscript or if it also happens in core, but at the variant level object and null are diferent variant types, and yet, we have nullable objects by default. In gdscript a variable declared as object can be in fact three things, a null (TYPE_NIL), wich is 'falsy', an object (TYPE_OBJECT) wich is an already freed instance (wich is falsy) and a non freed instance, wich is 'truthy'.

edit 2: besides adding things to core, due to the flexibility of the catch-all variant type, we could also not implement anything in core, only implement things in the gdscript parser, and do type erasure with things like union types and nullable types. The syntax for nullable and option types would exist in the parser only, but under the hood variables would be just plain variants. Most of the time type safety is only needed at compile time, not at runtime. Ask anyone that has used type erased generics for years (java and type-script developers) if not having runtime type safety is realy such a big problem.

@SlugFiller
Copy link

On the flip side, there are too many examples of games on Steam that, on occasion, "poof" while you are playing them. This is the worst case scenario. Much worse than having difficulties during development. For that reason, GDScript doesn't so much as have a concept of exceptions.

As a result, there's nothing sensible the GDScript VM can do if .unwrap() fails, i.e. if it's called on a null value. It can't actually crash the game. It can't return an error, because there's no way to catch and handle said error. It can't "do nothing", because it has to return a value of the type with which it is defined.

If you're trying to fast iterate, and just want to get something up and running, the solution is to be untyped. That way, Godot can still do whatever it considers to be "sensible" in terms of operations involving null. You can then later activate unsafe code warnings, and add the necessary typing before release.

To be clear: The use of typed code doesn't only add checks at compile time. It also removes checks at run time, to improve performance. Except, these checks can sometimes be the difference between getting an alternate value, and either corrupting memory or outright crashing. Any operation between two Variants, even if one of the Variant is a null, has a valid Variant result. But coercing a Variant into a type, and hence unchecked, variable, requires adding a coercing operation with a known safe fail behavior that does not crash the game. With no coercing operator, the compiler simply fails, since it cannot guarantee the safety of the assignment, and it cannot safely remove the runtime checks used for untyped code.

@geekley
Copy link

geekley commented Aug 12, 2024

I also strongly believe MyType? syntax for nullable is the best (Option[T] is unnecessarily verbose).

I also think constructs like x ?? return, x ?? break, x ?? continue are absolutely essential if we won't/can't have a non-null assert operator like ! in x!.prop.


I strongly believe nullability needs to work for all types, as the benefit is too great to ignore on objects like Node and my custom classes. But I'm opposed to requiring another syntax like MyNode! for non-null types. Thus, IMO something akin to C# nullable reference types (i.e. #nullable and its project-wide <Nullable> directive) is needed here.
This approach is appropriate for GDScript because like C# it already differentiates between types which are currently always assumed nullable (Variant + Object and its subclasses) and not nullable (the other variant types).

Since we can't break current syntax, I suggest with this feature we make some assumptions by default:

  • type Variant is by default an alias to Variant?
  • type Object is by default an alias to Object?
  • every type T subclass of Object is by default an alias to T?

You can use either syntax for those. With no breaking changes we can still start using nullable basic types like int? and Vector2? as the non-? version already means not-null. People who are used to current behavior can just ignore it for objects and Variant and use ? just for those basic types, if they want to. They can still assume e.g. Node means Node? like it is now. They'd still benefit from more specific API types in core and libraries using nullability.

To change this behavior, add these, which apply in order of precedence (higher first):

  • a file-wide annotation @null_safe (no argument means the same as @null_safe(true))
  • an addon-wide null_safe option overriding the project-wide setting when present
  • a project-wide null_safe option (false by default on existing projects, set to true on new projects)

This mode would go further to make even Variant, Object and its T subclasses mean not nullable on that file when without the ?, so nullable would require the T? syntax for those types too.
With this, you can incrementally port code to enable null-safety on existing projects using the annotation temporarily; and use the project-wide option when done. On new projects you start with null-safe syntax on all types.

Just adding my 2 cents (sorry if this was already mentioned, thread too long).

@Shadowblitz16
Copy link

Shadowblitz16 commented Aug 14, 2024

Variant and Object should not be implicitly null.

var x:Type should guarantee a non null value
var y:Type? should guarantee it could be null or contain a value.

Do not make nullables optional like in C#, it completely makes nullables and the type safety pointless because it's optional.

If you want all your types to be nullable then use Type? but don't blame me when you get thousands of repo issues or build errors because you try to do something on a null value.

Value and Reference types should both require a non null value if it's not nullable

@geekley
Copy link

geekley commented Aug 14, 2024

Variant and Object should not be implicitly null.

@Shadowblitz16 I agree having nullability in all types by default would be the ideal, but it's impossible to do this for Variant and Object-derived types without breaking existing code -- unless they were to have yet another syntax like Node! meaning non-null nodes. I don't know if this is what you're suggesting, but IMO it's not beneficial to introduce more verbosity, I really dislike this approach of having both T? and T! (as well as T).

Do not make nullables optional like in C#, it completely makes nullables and the type safety pointless because it's optional.

I don't think forced null-safety is realistic (at least until whenever Godot 5 / GDScript 3.0 comes), considering that in GDScript, even having type-safety at all is optional. That's why it's unavoidable to leave the decision to the programmer.
The other option would be to have nullability only on variant types, which would be a total waste. We don't want that.

In any case, the point of my proposal, like in C#, is not to make null-safety optional (I edited my comment to emphasize it should be enabled by default on new projects). Null-safety being optional is an unavoidable consequence of the way the language was designed. The point of the option is that it allows you to port code and introduce nullability gradually, until your existing code is adapted (and checked, where hopefully you catch some bugs in the process) until your whole project is null-safe. When you have lots of code, you need a way to test it gradually while you're partially porting it.

@Shadowblitz16
Copy link

Shadowblitz16 commented Aug 18, 2024

Variant and Object should not be implicitly null.

@Shadowblitz16 I agree having nullability in all types by default would be the ideal, but it's impossible to do this for Variant and Object-derived types without breaking existing code -- unless they were to have yet another syntax like Node! meaning non-null nodes. I don't know if this is what you're suggesting, but IMO it's not beneficial to introduce more verbosity, I really dislike this approach of having both T? and T! (as well as T).

Do not make nullables optional like in C#, it completely makes nullables and the type safety pointless because it's optional.

I don't think forced null-safety is realistic (at least until whenever Godot 5 / GDScript 3.0 comes), considering that in GDScript, even having type-safety at all is optional. That's why it's unavoidable to leave the decision to the programmer. The other option would be to have nullability only on variant types, which would be a total waste. We don't want that.

In any case, the point of my proposal, like in C#, is not to make null-safety optional (I edited my comment to emphasize it should be enabled by default on new projects). Null-safety being optional is an unavoidable consequence of the way the language was designed. The point of the option is that it allows you to port code and introduce nullability gradually, until your existing code is adapted (and checked, where hopefully you catch some bugs in the process) until your whole project is null-safe. When you have lots of code, you need a way to test it gradually while you're partially porting it.

I would be up for T! instead of T? but I still strongly disagree with nullable's being optional.
Either use the type system or don't

Even if T? was done and forced it would most likely be done for 5.0 so when people switch to 5.0 and update their code to the 5.0 api, updating their nullables wouldn't be too much more work.

Besides plugins break for each major godot release anyways and nullables are a good reason to break something so better code can be achieved in the future.

@geekley
Copy link

geekley commented Aug 18, 2024

@Shadowblitz16 I do agree with you that forcing null-safety on 5.0 is the best course of action, as major upgrades can (and should) break code. But that's probably going to be years from now.

So, I'm not sure if I agree on what to do w.r.t. 4.x, as I'm not sure what you're suggesting.
Are you saying...?

  1. It shouldn't be implemented until 5.0 where it can be forced, to never have 2 "conflicting" semantic interpretations for T (without ?) at all on the same language version
  2. It's okay to have T, T? and T! syntax for 4.x, but make it just T and T? on 5.0
  3. Use T and T? consistently on all types as not-null and null even on 4.x and break code.
  4. It's okay to make null-safety on Variant and objects optional for 4.x as long as that option is removed on 5.0

My opinion:

  1. I don't like this as it may take too long for us to get the feature.
  2. I really dislike this. But I guess I would take it as long as it will be simplified later.
  3. Not realistically doable IMO.
  4. The best option IMO, as this would facilitate preparation for 5.0 by porting code gradually, e.g. in add-ons. For new projects and addons it's enabled by default like I said.
    If you want to push people even more strongly to null-safety I suggest showing a warning if it's not enabled project-wide (or addon-wide for each addon) so they're compelled to port existing code.
    People can still suppress the warning, but they have to make a conscious choice in this case. People dislike seeing warnings or going out of their way to suppress them as it feels like they're doing something wrong.
    So that makes them
    (a) learn about nullability if they're not familiar with it, and
    (b) either improve the code or understand the consequences.

@Shadowblitz16
Copy link

@Shadowblitz16 I do agree with you that forcing null-safety on 5.0 is the best course of action, as major upgrades can (and should) break code. But that's probably going to be years from now.

So, I'm not sure if I agree on what to do w.r.t. 4.x, as I'm not sure what you're suggesting. Are you saying...?

  1. It shouldn't be implemented until 5.0 where it can be forced, to never have 2 "conflicting" semantic interpretations for T (without ?) at all on the same language version
  2. It's okay to have T, T? and T! syntax for 4.x, but make it just T and T? on 5.0
  3. Use T and T? consistently on all types as not-null and null even on 4.x and break code.
  4. It's okay to make null-safety on Variant and objects optional for 4.x as long as that option is removed on 5.0

My opinion:

  1. I don't like this as it may take too long for us to get the feature.
  2. I really dislike this. But I guess I would take it as long as it will be simplified later.
  3. Not realistically doable IMO.
  4. The best option IMO, as this would facilitate preparation for 5.0 by porting code gradually, e.g. in add-ons. For new projects and addons it's enabled by default like I said.
    If you want to push people even more strongly to null-safety I suggest showing a warning if it's not enabled project-wide (or addon-wide for each addon) so they're compelled to port existing code.
    People can still suppress the warning, but they have to make a conscious choice in this case. People dislike seeing warnings or going out of their way to suppress them as it feels like they're doing something wrong.
    So that makes them
    (a) learn about nullability if they're not familiar with it, and
    (b) either improve the code or understand the consequences.
  1. This was what I was suggesting.
  2. No this would be horiable.
  3. I don't think this would be a good idea for 4.0
  4. as long as the optionial nullables were removed in 5.0 I think it would be ok.

@btarg
Copy link

btarg commented Sep 15, 2024

As a new Godot dev I find the current way things work in GDScript to be quite confusing. Some types are allowed to be null and can be checked i.e. if value: but others will produce errors. Nullable types could introduce some more clarity in regards to whether something should be null or not. null checking is very useful for things like custom resources! I hope so see this added soon.

EDIT: in response to people concerned that this will confuse newbies or become a major foot-gun for lazy devs, I suggest that by default nullable types could show warnings, just like unused variables currently do. That way, those of us who know what we're doing can simply disable this warning in the options and still be able to code how we want!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
requires core feedback Feature needs feedback from core developers topic:gdscript
Projects
Status: On Hold
Development

Successfully merging a pull request may close this issue.