godotengine / godot-proposals

Godot Improvement Proposals (GIPs)
MIT License
1.16k stars 97 forks source link

Make objects non-nullable by default #11054

Open dalexeev opened 3 weeks ago

dalexeev commented 3 weeks ago

[!NOTE] This proposal breaks compatibility in a major way and is aimed at 5.0.

Describe the project you are working on

Proposals for improving Godot's type system and GDScript language. Providing null safety. This can be important for many projects.

Describe the problem or limitation you are having in your project

Valid and invalid empty/missing values

The null value (nil, None, undefined, etc.) is used differently in different languages:

  1. In C and C++, NULL and nullptr are used to denote a null pointer. There is no primary (not a pointer) Null type.
  2. In many other pointer-less languages, null is used as a special empty/missing value of a separate type. This avoids the use of special "empty" values ​​within the same type, such as 0, -1, "", etc.

In GDScript, null combines both of these qualities:

  1. Even though GDScript does not have pointers, objects are passed by reference and are nullable. This means that you can pass null to a function that expects an object, or assign null to a variable that is statically typed as an object.
  2. Other data types (including arrays and dictionaries that are passed by reference) are not nullable. You cannot pass null to a function that expects an int.

There are proposals for nullable (int?) and union (int|Nil, int|String) types, see #162 and #737. The fact that objects are nullable by default makes it difficult to implement a unified and consistent type system.

Also, the current behavior has the problem that you can't distinguish when null denotes a valid empty/missing value from an invalid value (e.g. you forgot to initialize a variable). Given that dereferencing a null pointer in C++ is a fairly common error, the same error with null in GDScript is no less common. I often see user posts in various Godot groups asking about Invalid access to property or key 'foo' on a base object of type 'Nil' and Invalid call. Nonexistent function 'foo' in base 'Nil' errors.

Let's say you have a function:

func test(a: Node, b: Node = null) -> void:
    # ...

Here you would probably expect that the parameter a should always be a valid node, and the parameter b can be either a valid node or null. However, you can call test(null, null) and it will not immediately cause an error. As long as you do not access the properties and methods on a, you will not detect the error. You can assign a to another variable and the error will be detected much later.

If this is an important method, you can check the value manually, like this:

func test(a: Node, b: Node = null) -> void:
    assert(is_instance_valid(a))
    # ...

However, doing this in every method is tedious. As you can see, the two nulls passed to test() are different from each other. The first null is an indicator of a bug in our code (parameter a should never be null), while the second null is a perfectly normal situation (null is a valid value for parameter b). It would be nice if the difference could be expressed through the type system, like this:

func test(a: Node, b: Node? = null) -> void:
    # ...

Or like this (union types are a superset of nullable types):

func test(a: Node, b: Node|Nil = null) -> void:
    # ...

Note that you could also not make the parameter b optional, but still specify that it can accept null. You would just have to explicitly pass some value, either a node or null.

func test(a: Node, b: Node?) -> void:
    # ...

Variant representation

_Here, Variant refers to the Godot C++ class, not the GDScript top type._

You might not know this, but there are actually three kinds of "null" values ​​in Godot/GDScript: null, Object#null, and Freed Object. See also #10098. Here I insert a short description:

All values ​​in GDScript are Variant. It has two fields: type (TYPE_NIL, TYPE_OBJECT, etc.) and content (a union). Also Godot/GDScript does not have a garbage collector. This means that objects can be destroyed before all references to the object become unreachable. Therefore, we have several possibilities to have an "invalid object" (null-like values):

This proposal only affects null (TYPE_NIL), you can still pass Object#null and Freed Object (TYPE_OBJECT) to a function expecting an object, or assign them to a variable statically typed as an object. This is important, see below for details.

Memory management

Why do Object#null and Freed Object even exist? It has to do with memory management and lifetime of different types:

  1. There are types that are passed by value and take up little memory (int, float, Vector2, Color, etc.). They are copied entirely, as they fit in a Variant.
  2. There are also types that are passed by value, but can store more data than the Variant size (for example, String). Under the hood, they use a reference counting mechanism and copy-on-write. However, this happens invisible to the user, so we can consider it an implementation detail.
  3. Automatically initialized types passed by reference (Array, Dictionary, and packed arrays). Several Variants can share a reference to the same array/dictionary, deletion occurs automatically when there are no more references to the array/dictionary. User-defined constructors/destructors are not allowed. The user has no way to directly influence the reference counter.
  4. Objects. In general, there is no automatic memory management (only RefCounted has reference counting and Node automatically deletes its children when deleted). Custom constructors and destructors are supported (in the form of NOTIFICATION_PREDELETE), for RefCounted there is an option to influence the reference counter (methods init_ref(), reference() and unreference()). At any time, an object can be deleted using free() (or by zeroing the reference counter), and also refuse to be deleted (cancel_free()). When an object is deleted, all remaining references to it become invalid, which is why a Freed Object appears (there is no point in updating all variants referencing it for performance reasons).

So objects, in general, have manual memory management. Automatic memory management for objects is usually implemented in languages ​​with garbage collection (most scripting languages, C#), languages ​​with lifetime and reference ownership analysis (Rust), or functional languages, where variables are immutable (but under the hood they usually also use garbage collection). Godot, as a game engine, avoids garbage collection, so I don't think we should expect fully automatic memory management.

If we make objects non-nullable, what about default initialization and memory management? What is node equal to here?

var node: Node

Given the above, I think it would be a bad idea to automatically instantiate node here. We could probably require explicit initialization of a Node variable (but not Node?), since null is not a valid value. However, there are still cases where we need some default value:

@onready var node: Node = $Node

func _init() -> void:
    # `node` is not initialized before ready.
    print(node)
var array: Array[Node]
array.resize(1)
# `array[0]` is not initialized until we assign a node explicitly.
print(array[0])

I propose to use Object#null as a default value in this case. It may seem like it doesn't change anything, but there is a big difference in semantics. null will be treated as a valid empty/missing value. There is a literal null and it will be considered normal to use, as long as it complies with the type system constraints. Whereas Object#null and Freed Object will be a sign of an error (you forgot to initialize the variable, accessed it before it was initialized, or after the object was deleted from memory). You should not and are unlikely to intentionally pass Object#null or Freed Object to functions, unlike null. This way, null will be spared its dual role and will only denote a valid empty/missing value.

Describe the feature / enhancement and how it helps to overcome the problem or limitation

Here are the key points of this proposal:

  1. This proposal implies support for nullable or union types in Godot core API and GDScript language.
  2. Disallow accepting null where an object type is expected. This includes core, GDScript, C#, and GDExtension.
  3. Change the default value of the Object type (and its descendants) from null to Object#null. The default value of the T? (or T|Nil) type is null.
  4. null should only denote a valid empty/missing value. Object#null and Freed Object should be treated as a sign of an error.
  5. Change the types of built-in properties and methods accordingly. For example, the Sprite2D.texture property should be of type Texture2D?, not Texture2D. If a method can return a node or null, its return type should be Node?, not Node. A built-in method should not return Object#null or Freed Object, unless this is the result of incorrect user action.

Describe how your proposal will work, with code, pseudo-code, mock-ups, and/or diagrams

If this enhancement will not be used often, can it be worked around with a few lines of script?

This affects the core and cannot be worked around with a few lines of script.

Is there a reason why this should be core and not an add-on in the asset library?

This affects the core and cannot be implemented as an add-on.

geekley commented 3 weeks ago

I know it's much much easier said than done but... to be completely honest, I would hope for Godot 5 that the entire thing was redesigned from scratch (instead of this incremental patch-by-patch approach) so we could have a proper type system with everything. And only THEN adapt the engine around it to make proper use of it. In the order of priority below.

Crucial missing features to fix forced Variant use in the engine (these require a deep redesign of the typing system):

Features to improve type-safety for the user (less priority, as less-optimized workarounds are usually possible):

This way we could finally "get rid of Variant" in the sense that we wouldn't ever have to deal with it unless we're really using reflection. E.g. we could have a project setting "no_reflection" to raise error if using Variant anywhere.

unfallible commented 3 weeks ago

Thanks for opening this issue.

I've been thinking a bit about non-nullables and initialization. I think you're right that using Object#null as the default value for non-nullable objects is preferable to the current approach. That said, my gut says that your example

@onready var node: Node = $Node
func _init() -> void:
    # `node` is not initialized before ready.
    print(node)

should not even be allowed to compile without a warning. I think it would be best for the compiler to identify a point after which all non-nullables variables must be initialized; the most logical point for this is after the call to _init. Perhaps this could be done by saying that every code path in _init should assign a non-nullable value to every non-nullable member variable. Within _init, perhaps the compiler could say that non-nullable member variables should only be read after all code paths have initialized them.

However, I can already think of challenges for an approach like this. For one thing, attempting to ensure that all variables are initialized when _init finishes would prevent @export from being used with non-nullable Nodes and Resources. I have some ideas for a work-around to @export problem though. I'm also trying to untangle the implications of inheritance for an system like this. The following classes gives a taste of the potential craziness that inheritance can cause when a class's methods are allowed to be called inside its _init:

class Fool:
    var node: Node
    func seems_harmless() -> void:
        pass

    func _init():
        seems_harmless()
        node = Node2D.new()

class Prankster:
    extends Fool
    func seems_harmless() -> void:
        print("Gotcha!")
        node.get_parent()

    func _init():
        super()

Any thoughts on these problems or other fatal flaws enforcing initialization in _init might have?

Also, while some of these are breaking changes, is this really true for all of them? It seems like some of them could probably be implemented without here. As some people mentioned in the discussion of issue 162, C# allows you to configure projects to disallow implicit nulls. Would that really be impossible in GDScript? I don't think people want perfect soundness, just the ability have the compiler flag dumb mistakes and inconsistencies.

dalexeev commented 3 weeks ago

That said, my gut says that your example [...] should not even be allowed to compile without a warning.

Even if this particular case can be validated by the static analyzer, in general we still cannot guarantee that the variable will not be accessed before initialization. For example, it could be in another function called in another variable's initializer (GDScript allows non-constant variable initializers). Or when resizing a typed array, as shown in the second example. Or for a million other reasons, see the halting problem and other fundamental computer science problems. Don't expect a compiler to prove Fermat's Theorem.

Also, while some of these are breaking changes, is this really true for all of them? It seems like some of them could probably be implemented without here. As some people mentioned in the discussion of issue 162, C# allows you to configure projects to disallow implicit nulls. Would that really be impossible in GDScript?

I am quite sure that most of what is described in this proposal cannot be implemented without major compatibility breakage. At least because it affects the core and its API. GDScript relies on and depends on core systems in many ways, we cannot solve this only on the GDScript side, it would require completely new layers of abstractions and runtime wrappers around the core.

I am also skeptical about this kind of configurability. It complicates the model and implementation, increases the maintainability burden and the number of config-specific bugs. It would make third-party plugins harder to support, worsen their portability and compatibility. In my opinion, we should not complicate the already complex and full of technical debt part of the engine.

unfallible commented 2 weeks ago

Even if this particular case can be validated by the static analyzer, in general we still cannot guarantee that the variable will not be accessed before initialization. For example, it could be in another function called in another variable's initializer (GDScript allows non-constant variable initializers). Or when resizing a typed array, as shown in the second example. Or for a million other reasons, see the halting problem and other fundamental computer science problems. Don't expect a compiler to prove Fermat's Theorem.

I want to clarify a few things:

  1. I never said that your example (or mine for that matter), shouldn’t be able to compile; I said it shouldn’t be able to compile without a warning.
  2. I also wasn't saying that the compiler should complain because node was guaranteed to be uninitialized - not least of all because node actually could be initialized before print() if your class were extended and its child class initialized node before calling its super constructor.
  3. I was certainly not suggesting that the compiler should somehow guarantee safety when resizing a typed array. There are all sorts of places in GDScript where a variable's type guarantees can be violated as the result of an outside script. You've already mentioned resize and free, off the top of my head I would add set_script to this list, and I'm sure there are many, many others. I think we agree that that's okay (the alternative would be deprecating these functions and radically altering Godot's architecture and design philosophy). When people call those functions, they understand that they are engaging in risky behavior and need to proceed with caution. As I said before, I don't think most want people want a type system that will catch everything, just one that can flag dumb mistakes and inconsistencies in "ordinary" game logic.

You say that "in general we still cannot guarantee that the variable [a variable?] will not be accessed before initialization." At the risk of saying something stupid, I can't figure out what you mean by this. What am I missing that would prevent the compiler from knowing that

var node: Node
func _init() -> void:
    node = Node.new()
    print(node)

will always be able to initialize all of the object's members and call print safely? Yes, I'm aware that the variable initializers can call other functions that might accidentally access a variable, but if all of a script's variable initializers are constant, then there's no risk of this happening. For that matter, even if other functions are called, as long as none of those functions receives a reference to the object being initialized (either explicitly as an argument or implicitly as self), I don't see how the object's uninitialized data could possibly be accessed. After all, how would an uninitialized object's data be accessed when no other part of the program knows knows about the uninitialized object's existence yet? So at the moment, it seems to me that the type checker should raise warnings when:

Your code snippet passes node to print() without first guaranteeing that node has been initialized. Moreover, it never guarantees that node will be initialized at all. These are the actual reasons why I felt your code snippet should trigger a warning. Can't both of these things be verified using static analysis techniques like an SSA representation?

I am quite sure that most of what is described in this proposal cannot be implemented without major compatibility breakage. At least because it affects the core and its API. GDScript relies on and depends on core systems in many ways, we cannot solve this only on the GDScript side, it would require completely new layers of abstractions and runtime wrappers around the core.

I am also skeptical about this kind of configurability. It complicates the model and implementation, increases the maintainability burden and the number of config-specific bugs. It would make third-party plugins harder to support, worsen their portability and compatibility. In my opinion, we should not complicate the already complex and full of technical debt part of the engine. In the discussion of #162, people were debating whether GDScript's reference types could be made non-nullable by default. Some people in that conversation were arguing that making GDScript treat object types as non-nullable by default couldn't be done in Godot 4 because it was a breaking change.

I thought (apparently incorrectly) that you opened this proposal specifically to move that debate into its own thread, so when I suggested that not all of your proposed changes needed to break compatibility, I was really just referring to one part of the second "key point" you identify in your proposal. Specifically, I was trying to say that we could "disallow accepting null where an object type is expected" in GDScript without breaking compatibility. To my mind this change would have enormous benefits, even if the rest of your proposal was never implemented. Back in the #162 discussion, @geekley suggested four possible paths for adding null-safety to GDScript:

  • 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

  • It's okay to have T, T? and T! syntax for 4.x, but make it just T and T? on 5.0

  • Use T and T? consistently on all types as not-null and null even on 4.x and break code.

  • 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

geekley’s view was that 3 is probably a non-starter, 4 was best, and 2 was bad but vastly preferable to 1. I agree with them; I think that holding this huge QoL improvement back until the engine's whole type system can be rewritten would be a shame. When you say you're skeptical of "this kind of configurability," I'm not sure if you're just saying that you're skeptical of adding null-safety to the whole engine and making it configurable system wide, or if you're saying that even limiting this configurability to GDScript would be a mistake. If you mean the former, then I agree completely. On the other hand, if you mean making object types null-safe by default should never be a configuration option, I agree with geekley that it would be better to add T! to the syntax than to wait for Godot 5 (I might even go further than them and suggest dropping T? until we can do things right in Godot 5).

geekley commented 2 weeks ago

geekley’s view was that 3 is probably a non-starter, 4 was best, and 2 was bad but vastly preferable to 1

Well, half-right. It's right, except for the last part @unfallible. While I did say that, I now also do think these are very valid points, actually:

I am also skeptical about this kind of configurability. It complicates the model and implementation, increases the maintainability burden and the number of config-specific bugs. It would make third-party plugins harder to support, worsen their portability and compatibility. In my opinion, we should not complicate the already complex and full of technical debt part of the engine.

So my current opinion on those approaches is a bit more like:

  1. I don't like it as it would take too long until 5.0; however if it means the whole type system is redesigned for a more mature and proper type system and we could get the whole package I mentioned above, then I'm more than happy to wait for this sort of work to be started for a 5.x branch first and only after it's all done decide whether or not it's worth back-porting to 4.x (not necessarily having to wait a 5.0 release in this case). Like I said, I think the best approach for new GDScript features would be a core fundamental redesign instead of patch-by-patch.
  2. I wouldn't say "vastly preferable", I said I would take it if it was temporary. I'm really against settling on a T! syntax, and I don't disagree with @Shadowblitz16 that it would be horrible to add syntax only to then remove it.
  3. Not doable, and I'm pretty sure Godot devs would agree. I'm strongly against breaking code like this without a major 5.x shift. Some people said "ah plugins break anyway" but we're talking about breaking Godot code for everyone, not specific plugins only some people opted to use.
  4. Yes, this would be the ideal in terms of getting the feature on 4.x while keeping compatibility... assuming the plan is still do it in this patch-by-patch approach like it has been done so far. But after considering the valid counter-argument above, I do think that if it means complicating development and compatibility, then (1) may be better - but only as long as many other features come with it, in a whole-package redesign of GDScript.

In general:

dalexeev commented 2 weeks ago

Can't both of these things be verified using static analysis techniques like an SSA representation?

SSA can only be used for local variables, because they cannot be reassigned from the outside[^1]. With class members, things are more complicated, they can (potentially) be changed by any call. Yes, we should add errors/warnings if the analyzer can reliably deduce that a class member or local variable is used before it's initialized. However, we should be aware that the analyzer can't guarantee this 100% of the time. The proposal only states that in this case you'll get Object#null at runtime, and nothing else.

Warnings are a bit more delicate matter as a balance is needed. We want to avoid false positives, because they annoy users and prevent them from finding useful warnings among the garbage. But false negative scenarios (no warning where it is needed) are also dangerous, they can completely negate the value of the warning due to its rarity, or give the user a false sense of security if they are not familiar with how static analysis works in a particular case.

In my opinion, this is more related to the implementation phase and further improvements of static analysis. The only reason I'm pointing this out is because this proposal introduces invalid default values ​​to the language.

I thought (apparently incorrectly) that you opened this proposal specifically to move that debate into its own thread, so when I suggested that not all of your proposed changes needed to break compatibility

One of my intentions with this proposal is to identify things that cannot be achieved in 4.x or that would be problematic to implement before 5.0. In my opinion, making objects non-nullable by default is one of those. This doesn't mean nullable types are unachievable in 4.x at all, I'm just skeptical that we can decouple null from objects without major compatibility breakage, in a consistent way, and without unnecessary complexity/configurability.

If we look at the current type system from the point of view of set theory, then I imagine it something like this:

So, I don't fully agree when someone says that Node is an implicit Node? and Variant is an implicit Variant?. Currently, Node is Node?, since it includes null. The implied Node part in Node? simply does not exist in the current type system, you can't exclude null. And Variant is a top type (i.e. Variant is a supertype of any type), it already includes null. Variant? and Node? are redundant in the same way as Node|Node. If B ⊆ A, then A ∪ B = A.

If we wanted to introduce non-nullable object types in 4.x without compatibility breakage, we would need type intersection and complement operations. Formally, T! = T & ~Nil, like T? = T | Nil (but I doubt we could provide type intersections and even more complement types to users).

This would seem pretty consistent to me, although not perfect. You could use it for new code, but the engine API, plugins, and other existing code would be unaffected. However, typed arrays and dictionaries currently don't support nested types, if you want something like Array[Node!] or Array[int?]. For that, we'd probably need first-class types (see the section "Unified type system" in https://github.com/godotengine/godot-proposals/issues/10807#issuecomment-2366926108).

[^1]: Even lambdas capture outer function's local variables by value, not by reference.

unfallible commented 1 week ago

In general:

* I think one of the major goals of 5.0 whenever that branch starts, should be to redesign the core typing system to make it possible to improve GDScript to a more proper language with all the type-safety features you expect on a modern programming language.

* It would be very helpful to get null-safety on 4.x, but if there's such controversy [...] better break it all on 5.0 to redo everything in a more evolved way from ground up.

* I really don't like this patch-by-patch approach to evolving the language. [...]

Thank you for clarifying your view, geekley. I wrote this assuming that a patch-by-patch fix for Godot 4 was compatible with a ground up redesign for 5.0, but if they're not, I agree with you that the latter is more important. While, I agree with most of your post, it's not clear to me why adding T! now and removing it in 5.0 would that big deal, especially when the replacement would just be a consolidation of existing syntax rather than an actual feature removal. Was there much controversy when this was done in the past with features like yield?

SSA can only be used for local variables, because they cannot be reassigned from the outside. With class members, things are more complicated, they can (potentially) be changed by any call.

This claim that class members can potentially be changed by any call is the exact premise that I’m disputing, dalexeev. When I asked, “how would an uninitialized object's data be accessed when no other part of the program knows knows [sic] about the uninitialized object's existence yet?”, the question was not purely rhetorical. Here's the basic argument describing my reasoning.

I agree with you that the static analyzer will never be able know whether outside code would access an uninitialized object’s members if the outside code had a reference to that object, but I think the analyzer can know that outside code will access an uninitialized object’s members only if the code has a reference to the object.

On my current understanding, the static analyzer can also be certain that no code outside of the initialization routine has a reference to the uninitialized object. This is possible because a script’s initialization routine (i.e. its initializers, _init() function, and its super class’s initialization routine) is the only code with a reference to its uninitialized object when the initialization routine begins. Because code can only access data it has a reference to, code can only get a reference to an uninitialized object if the reference is shared by other code that already has that reference. Since the initialization routine begins as the only code to start with a reference to the routine’s uninitialized object, outside code can only get a reference to an object being initialized if the object’s initialization routine “leaks” that reference. The static analyzer is perfectly capable of warning users if their initialization routine “leaks” a reference to self; I believe it would just need verify that an initialization routine only makes outside calls that would be allowed in a static function.

Insofar as the analyzer really can be certain that 1) outside code will access data only if the code has a reference to the data and 2) no code outside of the initialization routine has a reference to an object, then it follows that in these situations, the analyzer can be also be certain that 3) outside code will never access that object. This is why I suspect that by ensuring that a script’s initialization routine never leaks any references to self, the analyzer can know that outside calls made during object initialization will never access the uninitialized object’s members, just as the analyzer can know that outside calls will never affect local variables. It is entirely possible that my argument is missing something important, but I want to state it clearly.

Warnings are a bit more delicate matter as a balance is needed. We want to avoid false positives, because they annoy users and prevent them from finding useful warnings among the garbage. But false negative scenarios (no warning where it is needed) are also dangerous, they can completely negate the value of the warning due to its rarity, or give the user a false sense of security if they are not familiar with how static analysis works in a particular case.

I was thinking through these initialization questions in the context of a broader question about type safety and gradual typing. I thought my original post made sense without that context, but I see now that it didn’t.

Currently, GDScript’s type system is weak enough that it is impossible to write a real game in GDScript without making unsafe calls all over the place (the lack of options for type narrowing is a good example of this). Even in places where type safe code is possible, doing so may require so much boilerplate that it’s currently impractical. Consequently, there’s really no point in making the compiler call out every individual instance of an unsafe function call; in any reasonably sized project, the compiler would mostly end up dredging up false positives.

However, I suspect everyone in this thread would like to see GDScript’s type improved to the point where ordinary game logic can be written in completely type-safe GDScript. Should that glorious day ever arrive (and I'm talking about 5.0 features here), we fervent believers in static typing will want to be know wherever our code is unsafe and perhaps even want the compiler to ask for explicit permission to compile such code. If I forget an important type annotation, I want to know about it. At the same time, there’s a certain kind of person who doesn’t forget type annotations; they omit them. Everywhere. Those people, incorrigible though they are, still make up an important part of this community, and if the compiler were statically typed all the way through, the engine would become unusable for them.

So the problem I was trying to think through was, “how can we meet the needs of those who want type annotations everywhere without alienating people who like permissive type systems?” The potential solution to this problem that I was exploring was to make the compiler as permissive as possible while raising extensive warnings that could be handled according to the particular user's needs. Basically, the compiler would fail only if it could prove that, given a set of "normal" assumptions (e.g. no calls to free() or set_script(), function arguments have the correct type, etc.), an operation will fail. To accommodate those who want a stronger type system, the compiler could raise a warning about an operation if the analyzer could not prove that, given the aforementioned set of "normal" assumptions, the operation will succeed. Here's an example of what I'm talking about.

var foo: Node2D = Node2D.new()

func compiler_test_success(x: Node2D):
    foo = x #Node2D is always Node2D, so compiles without issue

func compiler_test_failure(x: Node3D):
    foo = x #Node3D is never Node2D, so this fails to compile

func compiler_test_warning(x: Node):
    foo = x #Node is sometimes Node2D, so this compiles with a warning

func compiler_test_warning(x): #Because x lacks a type annotation, its type is implicitly Variant?
    foo = x #Variant? is sometimes Node2D, so this compiles with a warning

Users would then be given the ability to configure the “strength” of the type checker by determining how type-check warnings should be handled (e.g. printed as-is, elevated to errors, or suppressed). The type checker’s “strength” could then be configured as a project wide configuration setting and then overridden at more specific levels. Some examples of cases where overriding the type checker's strength would be desirable are a project that is strongly typed except for a handful of files which were hacked together as proof of concept for a new feature, a project that is weakly typed except for a couple plugins, and a file that is strongly typed except for a couple lines of code that the author knows will never fail. I was interested in an approach like this was because it could be implemented without adding any configurability options to the compiler itself.

In my opinion, this is more related to the implementation phase and further improvements of static analysis. The only reason I'm pointing this out is because this proposal introduces invalid default values ​​to the language.

My concern here is that if we say that "declaringvar x: Node means that x will never be null but x can be Object#null at literally any time," then we've pushed users back into a situation where they have to call is_instance_valid(x) at the top of every method call. This is essentially what introducing invalid default values ​​without setting a cutoff point for initialization does, and in my opinion it would make the ability to declare members to be non-nullable much less useful. I'm suggesting that var x: Node should instead mean that under "ordinary" conditions, x will be a valid instance of Node any time after x's owner is initialized. I think the implementation of the static analyzer is relevant here because I believe part of what makes this conception of non-nullability practical is the fact that the analyzer is capable of helping users arrive at a workflow where outside code can assume assume that an object's variables have been initialized. The concept of non-nullability you have in your head may be different from mine, but I wasn't able to tease it out of your original proposal.

geekley commented 1 week ago

While, I agree with most of your post, it's not clear to me why adding T! now and removing it in 5.0 would that big deal, especially when the replacement would just be a consolidation of existing syntax rather than an actual feature removal.

I can only speak for myself, but mainly because it would mean having 3 different possibilities: T, T?, T! for 2 different nullability options, where T has to be preserved for compatibility, and T could mean either T? or T! depending on whether or not it's a class/object. I feel like it adds confusion, more than the annotation/configuration option, as at least with the latter you can force/expect consistency (at least within a file), but with 2 different options for syntax you can't.

Even if you do have a setting to forbid plain T, I also don't like the extra verbosity of both ? and ! everywhere.

It's just better if T is non-null (= strictness by default) and we add a ? only when needed, as in this proposal. Similar to how C# uses private by default, and you add to public API only on an as-needed basis. This is better because I highly doubt the risk of forgetting to make something nullable is very significant (you would already get an error if the implementation needs to use null) -- it's surely much less bad than the risk of making an API nullable by mistake. So I'd argue there's no inherent benefit in forcing a T! syntax for not-null over it being the default.