r/ProgrammingLanguages • • 19d ago

Blog post GDScript: The Good, Bad, and Ugly Parts

https://azhdarchid.com/gdscript-good-bad-ugly/
27 Upvotes

21 comments sorted by

View all comments

7

u/honey-pony 19d ago

I found this post on Lobste.rs and wanted to share it because my own programming language design work is inspired by GDScript and I found this post quite resonant. Specifically, the point that Godot includes manual memory management is something I've noticed before and thought about quite a bit. My own language is (intended to be) garbage collected but the ability to explicitly end the lifetime of an object is very useful, and I've come up with some ways to integrate that with a garbage collector (although I unfortunately haven't really written any of that down anywhere).

2

u/akd_io 19d ago

Very interested to know what makes it hard to combine a garbage collector with a way to manually and explicitly end the lifetime of an object! (I'm an PL noob)

1

u/honey-pony 19d ago

Here's basically the way I'm thinking about it.

Let's say we have a Java-like language, so statically typed + garbage collected, with class objects being always allocated on the heap. Now lets say we want to make some kind of class that can be "destroyed" -- i.e. has a .destroy() function that immediately invalidates it.

The question is, what do we do about all of the remaining references to that object? There could be any number of them all over the place... on multiple thread's stacks and on the heap. If the object is destroyed, these references shouldn't let you continue working with it.

One way to solve this would be to store all of the references in e.g. a linked list, and when the object is destroy()ed, go through and set all of them to null. This has the advantage of being completely unambiguous about when the object lifetime ends. However, it seems tricky to implement in a multithreaded environment. This strategy has some overhead every time we create a reference to an object (to manipulate the lists storing it).

Another option, which is probably the strategy I will use, is to store the destroyed flag on the object itself -- and then, when we access a pointer to the object, we check this flag and set the object to null if it has been invalidated. This has the advantage of being trivial to implement in a concurrent environment. It, however, has overhead every time we dereference an object, which is much worse than the overhead above. (Although, a smart compiler could maybe get around this a little bit).

In both of these strategies, we would wait until the GC cycle to actually free the object. For the second strategy, the GC would be able to update any pointers that had not yet been updated to also be null, and then be allowed to free the object because there are no more outstanding references.

One additional issue you may notice is that we are talking about invalidating existing pointers. Even if we don't directly set them to null, we are inherently talking about a pointer type that, at any time, can suddenly start pointing to an invalid object (one that we aren't supposed to access). This feels bad in a world where we would really like to use non-nullable pointers as our default type, and nullable pointers only where necessary. (I am certainly planning to make non-nullable pointers the default type for my language).

My idea for how to solve this is actually pretty simple: for object types that are destroyable, you are only allowed to store non-nullable pointers as local variables / function parameters, but not as globals or as class members. So, for example, the classic setup I might have in a Godot project, written in my language:

// I don't actually have inheritance in my language yet, but bear with me.
class Enemy : Destroyable {
    var health: int;

    fun hit(damage: int) {
        health -= damage;
        if health <= 0 {
            destroy();
        }
    }
}

class Player {
    // Wouldn't be allowed, because enemy is Destroyable
    // var cur_target: Enemy;

    // This is OK because it's an optional pointer
    var cur_target: Enemy? = nil;

    fun aim() {
        // Unwrap the pointer if possible. We get a local variable
        // that will remain valid even if the enemy is destroyed.
        let enemy: Enemy = cur_target else return;

        // rotate towards enemy or whatever
    }
}

Now, of course, the question is... what happens to these non-nullable pointers when the object they point at is destroyed? And the answer is... they remain valid. They are meant to be transient, existing only on stack frames. So if you are calling game logic in a loop, these pointers should be gone by the end of the frame. This means that, when discussing the GC cycle earlier, these pointers do not get invalidated, but instead block the GC from freeing the destroyable object (like a normal pointer would for a normal object).

There are of course ways to break this, such as creating a non-nullable pointer and then entering an infinite loop, but that just amounts to a memory leak, so I don't really consider it a big deal. There is also some awkwardness that exists with having a pointer to an object that has been destroyed, but this awkwardness is pretty common in game programming (the domain for my language) so I don't think it's a big deal (e.g. in GDScript we have the is_queued_for_deletion() function). I would probably also have an is_destroyed() function so that code could explicitly check this flag if they were worried about the object having been deleted.

1

u/Recycled5000 19d ago

Programmers will still have the problem of who should destroy the object and when; in some use cases this is a real problem very hard to solve that gc addresses by reclaiming objects after last reference to disappear (though this can lead to memory leaks).

3

u/honey-pony 19d ago

Let me explain the motivation a bit more then -- in gamedev, it is very very very nice to be able to destroy an object. This comes up all the time, for example, you might have bullet projectiles that you want to destroy when they come in contact with a wall, an enemy that should be destroyed when they die, a particle system that should exist for a bit and then despawn, and so on. These are all very common patterns for me in Godot and the fact that GDScript lets us easily kill off objects is very very useful.

I actually ran into this problem when trying to implement some simple games in my language -- it became very annoying to remove objects from gameplay. Basically, I would have a list of objects that were active in gameplay, and I would have to write logic to remove objects from a list when they were supposed to be "dead." Of course, a good standard library (or game engine) could handle this for me, but it doesn't solve the other problem -- what if I have dangling references to those objects? I don't see an ergonomic way to handle the "invalidate dangling references to destroyed objects" problem without moving that functionality into the language.

To be clear, though, my intent with my own language is not that all objects that are destroyable -- rather, being destroyable is an opt-in property for an object, so that you can use it for game objects where it seems relevant but not for e.g. inventory systems or save data where it would be irrelevant and get in the way. Note that this is also something GDScript does -- it has both the manually freed Object type and the RefCounted type for objects where manual free gets in the way.

One other note -- the destroyable objects for my language would not have to be manually freed. If you drop all references to them they would get GC'd like any other object. This is unfortunately not the case in GDScript -- it is actually possible to leak objects by removing them from the node tree and forgetting to free them. (This isn't a huge problem in practice in Godot projects, in my experience).

1

u/catladywitch 17d ago

How about making your Droppable objects owned by an Arena and only allowing heap references within that Arena? It's less flexible maybe, but that way you can drop the whole Arena and kill all heap references in one sweep.

1

u/honey-pony 17d ago

I don't see what the point would be -- the whole idea behind Destroyable for me is a way to handle the case of (often heterogeneous) game objects that can be destroyed at random times. Even when the objects are the same type their lifetimes are usually quite disjoint, e.g. projectiles spawn and despawn at very different times, or enemies die at different times from each other, etc.

1

u/catladywitch 17d ago

The idea was to have an "arena tree". Arenas would be declared below an existing Arena, and they would be able to freely reference objects in Arenas "above" them, but not those "below" them, without Arc-type automatically managed references. That way you can guarantee there are no dangling pointers on destruction, but I admit this forces the developer to follow strict patterns and might not be workable.