godotengine / godot-proposals

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

Add structs in GDScript #7329

Open reduz opened 1 year ago

reduz commented 1 year ago

Describe the project you are working on

Godot

Describe the problem or limitation you are having in your project

There are some cases where users run into limitations using Godot with GDScript that are not entirely obvious how to work around. I will proceed to list them.

Again, save for the third point, these are mostly performance issues related to GDScript that can't be just fixed with a faster interpreter. It needs a more efficient way to pack structures.

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

The idea of this proposal is that we can solve these following problems:

How does it work? In GDScript, structs should look very simple:

struct MyStruct:
    var a : String
    var b : int
    var c # untyped should work too

That's it. Use it like:

var a : MyStruct
a.member = 819
# or
var a := MyStruct(
   a = "hello",
   b = 22)

You can use them probably to have some manually written data in a way that is a bit more handy than a dictionary because you can get types and code completion:

var gamedata : struct:
      var a: int 22
      var enemy_settings : struct:
             var speed : String

And you should also get nice code completion.

But we know we want to use this in C++ too, as an example:

STRUCT_LAYOUT( Object, ProperyInfoLayout, STRUCT_MEMBER("name", Variant::STRING, String()), STRUCT_MEMBER("type", Variant::INT), STRUCT_MEMBER("hint", Variant::INT, 0), STRUCT_MEMBER("hint_string", Variant::STRING), STRUCT_MEMBER("class_name", Variant::STRING, String()) );

// .. // 

class Object {

//..//

// script bindings:
// Instead of
TYpedArray<Dictionary> _get_property_list();
// which we have now, we want to use:
TypedArray<Struct<ProperyInfoLayout>> _get_property_list();
// This gives us nice documentation and code completion.
//..//
}

This makes it possible to, in some cases, make it easier to expose data to GDScript with code completion.

Note on performance

If you are worried whether this is related to your game performance, again this should make it a bit clearer:

Q: Will this make your game faster vs dictionaries? A: Probably not, but they can be nice data stores that can be typed.

Q: Will this make your game faster vs classes? A: Structs are far more lightweight than classes, so if you use classes excessively, they will provide for a nice replacement that uses less memory, but performance won't change.

Struct arrays

Imagine you are working on a game that has 10000 enemies, bullets, scripted particles, etc. Managing the drawing part efficiently in Godot is not that hard, you can use MultiMesh, or if you are working in 2D, you can use the new Godot 4 CanvasItem::draw_* functions which are superbly efficient. (and even more if you use them via RenderingServer).

So your enemy has the following information:

class Enemy:
   var position: Vector2
   var attacking : bool
   var anim_frame : int

var enemies : Array[Enemy]
enemies.resize(10000)

for e in enemies:
  e.position = something
  # enemy logic

This is very inefficient in GDScript, even if you use typed code, for two reasons:

  1. Classes are big, maybe 16kb as base, using so many can be costly memory wise.
  2. When you start having and processing this many elements, memory is all over the place so there is not much cache locality. This causes memory bottleneck.

You can change the above to this:

struct Enemy:
   var position: Vector2
   var attacking : bool
   var anim_frame : int

var enemies : Array[Enemy]
enemies.resize(10000)

for e in enemies:
  e.position = something
  # enemy logic

This will use a lot less memory, but performance will be about the same. You want the memory of all these enemies to be contiguous.

NOTE: Again, keep in mind that this is a special case when you have tens of thousands of structs and you need to process them every frame. If your game does not use nearly as many entities (not even 1/10th) you will see no performance increase. So your game does not need this optimization.

Flattened Arrays

Again, this is a very special case, to get the performance benefits for it you will need to use a special array type.

struct Enemy:
   var position: Vector2
   var attacking : bool
   var anim_frame : int

var enemies : FlatArray[Enemy]
enemies.resize(10000)

for e in enemies:
  e.position = something
  # enemy logic

FlatArrays are a special case of struct array that allocate everything contiguous in memory, they are meant for performance only scenarios. Will describe how they work later on, but when this is used together with typed code, performance should increase very significantly. In fact, when at some point GDScript gets a JIT/AOT VM, this should be near C performance.

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


// Implementation in Array

// ContainerTypeValidate needs to be changed:

struct ContainerTypeValidate {
    Variant::Type type = Variant::NIL;
    StringName class_name;
    Ref<Script> script;
        LocalVector<ContainerTypeValidate> struct_members; // Added this for structs, assignment from structs with same layout but different member names should be allowed (because it is likely too difficult to prevent)
    const char *where = "container";
};

// ArrayPrivate needs to be changed:

class ArrayPrivate {
public:
    SafeRefCount refcount;
    Vector<Variant> array;
    Variant *read_only = nullptr; // If enabled, a pointer is used to a temporary value that is used to return read-only values.
    ContainerTypeValidate typed;

    // Added struct stuff:
    uint32_t struct_size = 0; 
    StringName * struct_member_names = nullptr;
    bool struct_array = false;

    _FORCE_INLINE_ bool is_struct() const {
        return struct_size > 0;
    }

    _FORCE_INLINE_ bool is_struct_array() const {
        return struct_size > 0;
    }

    _FORCE_INLINE_ int32_t find_member_index(const StringName& p_member) const {
        for(uint32_t i = 0 ; i<struct_size ; i++) {
            if (p_member == struct_member_names[i]) {
                return (int32_t)i;
            }
        }

        return -1;
    }

    _FORCE_INLINE_ bool validate_member(uint32_t p_index,const Variant& p_value) {
        // needs to check with ContainerValidate, return true is valid
    }

};

// Not using LocalVector and resorting to manual memory allocation to improve on resoure usage and performance.

// Then, besides all the type comparison and checking (leave this to someone else to do)
// Array needs to implement set and get named functions:

Variant Array::get_named(const StringName& p_member) const {
    ERR_FAIL_COND_V(!_p->is_struct(),Variant();
    int32_t offset = _p->find_member_index(p_member);
    ERR_FAIL_INDEX_V(offset,_p->array.size(),Variant());
    return _p->array[offset];
}

void Array::set_named(const StringName& p_member,const Variant& p_value) {
    ERR_FAIL_COND(!_p->is_struct());
    int32_t offset = _p->find_member_index(p_member);
    ERR_FAIL_INDEX(offset,_p->array.size());
    ERR_FAIL_COND(!p->validate_member(p_value);
    _p->array[offset].write[offset]=p_value;
}

// These can be exposed in Variant binder so they support named indexing
// Keep in mind some extra versions with validation that return invalid set/get will need to be added for GDScript to properly throw errors

// Additionally, the Array::set needs to also perform validation if this is a struct.

// FLATTENED ARRAYTS
// We may also want to have a flattneed array, as described before, the goal is when users needs to store data for a huge amount of elements (like lots of bullets) doing
// so in flat memory fashion is a lot more efficient cache wise. Keep in mind that because variants re always 24 bytes in size, there will always be some
// memory wasting, specially if you use many floats. Additionally larger types like Transform3D are allocated separately because they don't
// fit in a Variant, but they have their own memory pools where they will most likely be allocated contiguously too.
// To sump up, this is not as fast as using C structs memory wise, but still orders of magnitude faster and more efficient than using regular arrays.

var a = FlatArray[SomeStruct]
a.resize(55) // 
print(a.size()) // 1 for single structs
a[5].member = 819

// So how this last thing work?
// The idea is to add a member to the Array class (not ArrayPrivate):

class Array {
    mutable ArrayPrivate *_p;
    void _unref() const;
    uint32_t struct_offset = 0; // Add this
public:

// And the functions described above actually are implemented like this:

Variant Array::get_named(const StringName& p_member) const {
    ERR_FAIL_COND_V(!_p->struct_layout.is_struct(),Variant();
    int32_t offset = _p->find_member_index(p_member);
    offset += struct_offset * _p->struct_size;
    ERR_FAIL_INDEX_V(offset,_p->array.size(),Variant());
    return _p->array[offset];
}

void Array::set_named(const StringName& p_member,const Variant& p_value) {
    ERR_FAIL_COND(!_p->struct_layout.is_struct());
    int32_t offset = _p->find_member_index(p_member);
    ERR_FAIL_COND(!p->validate_member(p_value);
    offset += struct_offset * _p->struct_size;
    ERR_FAIL_INDEX(offset,_p->array.size());
    _p->array[offset].write[offset]=p_value;
}

Array Array::struct_at(int p_index) const {
    ERR_FAIL_COND_V(!_p->struct_layout.is_struct(),Array());
    ERR_FAIL_INDEX_V(p_index,_p->array.size() / _p->struct_layout.get_member_count(),Array())
    Array copy = *this;
    copy.struct_offset = p_index;
    return copy;
}

// Of course, functions such as size, resize, push_back, etc. in the array should not be modified in Array itself, as this makes serialization of arrays
impossible at the low level.
// These functions should be special cased with special versions in Variant::call, including ther operator[] to return struct_at internally if in flattened array mode.
// Iteration of flattened arrays (when type information is known) could be done extremely efficiently by the GDScript VM by simply increasing the offset variable in each loop. Additionally, the GDScript VM, being typed, could be simply instructed to get members by offset, and hence it could use functions like this:

Variant Array::get_struct_member_by_offset(uint32_t p_offset) const {
    ERR_FAIL_COND_V(!_p->struct_layout.is_struct(),Variant();
    int32_t offset = p_offset;
    offset += struct_offset * _p->struct_size;
    ERR_FAIL_INDEX_V(offset,_p->array.size(),Variant());
    return _p->array[offset];
}

void Array::set_struct_member_by_offset(uint32_t p_offset,const Variant& p_value) {
    ERR_FAIL_COND(!_p->struct_layout.is_struct());
    int32_t offset = p_offset;
    offset += struct_offset * _p->struct_size;
    ERR_FAIL_INDEX(offset,_p->array.size());
    _p->array[offset].write[offset]=p_value;
}

// TYPE DESCRIPTIONS in C++

// Another problem we will face with this approach is that there are many cases where we will want to actually describe the type.
// If we had a function that returned a dictionary and now we want to change it to a struct because its easier for the user to use (description in doc, autocomplete in GDScript, etc) we must find a way. As an example for typed arrays we have:

TypedArray<Type> get_someting() const;

// And the binder takes care. Ideally we want to be able to do something like:

Struct<StructLayout> get_someting() const;

// We know we want to eventually do things like like this exposed to the binder.

TypedArray<Struct<PropertyInfoLayout>> get_property_list();

// So what are Struct and StructLayout?

//We would like to do PropertyInfoLayout like this:

STRUCT_LAYOUT( ProperyInfo, STRUCT_MEMBER("name", Variant::STRING), STRUCT_MEMBER("type", Variant::INT), STRUCT_MEMBER("hint", Variant::INT), STRUCT_MEMBER("hint_string", Variant::STRING), STRUCT_MEMBER("class_name", Variant::STRING) );

// How does this convert to C?

// Here is a rough sketch
struct StructMember {
    StringName name;
    Variant:Type type;
    StringName class_name;
        Variant default_value;

    StructMember(const StringName& p_name, const Variant::Type p_type,const Variant& p_default_value = Variant(), const StringName& p_class_name = StringName()) { name = p_name; type=p_type; default_value = p_default_value; class_name = p_class_name; }
};

// Important so we force SNAME to it, otherwise this will be leaked memory
#define STRUCT_MEMBER(m_name,m_type,m_default_value) StructMember(SNAME(m_name),m_type,m_default_value)
#define STRUCT_CLASS_MEMBER(m_name,m_class) StructMember(SNAME(m_name),Variant::OBJECT,Variant(),m_class)

// StructLayout should ideally be something that we can define like

#define STRUCT_LAYOUT(m_class,m_name,...) \
struct m_name { \
        _FORCE_INLINE_ static  StringName get_class() { return SNAME(#m_class)); }
        _FORCE_INLINE_ static  StringName get_name() { return SNAME(#m_name)); }
    static constexpr uint32_t member_count = GET_ARGUMENT_COUNT;\
    _FORCE_INLINE_ static const StructMember& get_member(uint32_t p_index) {\
        CRASH_BAD_INDEX(p_index,member_count)\
        static StructMember members[member_count]={ __VA_ARGS__ };\
        return members[p_index];\
    }\
};

// Note GET_ARGUMENT_COUNT is a macro that we probably need to add tp typedefs.h, see:
// https://stackoverflow.com/questions/2124339/c-preprocessor-va-args-number-of-arguments

// Okay, so what is Struct<> ?

// Its a similar class to TypedArray

template <class T>
class Struct : public Array {
public:
    typedef Type T;

    _FORCE_INLINE_ void operator=(const Array &p_array) {
        ERR_FAIL_COND_MSG(!is_same_typed(p_array), "Cannot assign a Struct from array with a different format.");
        _ref(p_array);
    }
    _FORCE_INLINE_ Struct(const Variant &p_variant) :
            Array(T::member_count, T::get_member,Array(p_variant)) {
    }
    _FORCE_INLINE_ Struct(const Array &p_array) :
            Array(T::member_count, T::get_member,p_array) {
    }
    _FORCE_INLINE_ Struct() {
            Array(T::member_count, T::get_member) {
    }
};

// You likely saw correctly, we pass pointer to T::get_member. This is because we can't pass a structure and we want to initialize ArrayPrivate efficiently without allocating extra memory than needed, plus we want to keep this function around for validation:

Array::Array(uint32_t p_member_count, const StructMember& (*p_get_member)(uint32_t));
Array::Array(uint32_t p_member_count, const StructMember& (*p_get_member)(uint32_t),const Array &p_from); // separate one is best for performance since Array() does internal memory allocation when constructed.

// Keep in mind also that GDScript VM is not able to pass a function pointer since this is dynamic, so it will need a separate constructor to initialize the array format. Same reason why the function pointer should not be kept inside of Array.
// Likewise, GDScript may also need to pass a Script for class name, which is what ContainerTypeValidate neeeds.

// Registering the struct to Class DB
// call this inside _bind_methods of the relevant class

// goes in object.h
#define BIND_STRUCT(m_name) ClasDB::register_struct( m_name::get_class(), m_name::get_name(),  m_name::member_count, m_name::get_member);

Then you will also have to add this function `Array ClassDB::instantiate_struct(const StringName &p_class, const StringName& p_struct);` in order to construct them on demand.

// Optimizations:

// The idea here is that if GDScript code is typed, it should be able to access everything without any kind of validation or even copies. I will add this in the GDScript optimization proposal I have soon (pointer addressing mode).

// That said, I think we should consider changing ArrayPrivate::Array from Vector to LocalVector, this should enormously improve performance when accessing untyped (And eventually typed) arrays in GDScript. Arrays are shared, so there is not much of a need to use Vector<> here.

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

N/A

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

N/A

dalexeev commented 1 year ago

Related:

nlupugla commented 1 year ago

Based on my understanding, it looks like users will be able to add and remove fields from their struct at runtime. Is that a "feature" or a "bug" of this implementation?

reduz commented 1 year ago

@nlupugla No, I think they should not be able to add and remove fields from structs. If you want this kind of flexibility, Dictionary should be used. That would make it also super hard for the typed compiler to optimize.

Calinou commented 1 year ago

Can structs have default values for their properties?

danilopolani commented 1 year ago

Can structs have default values for their properties?

IMO structs should not have default properties; as Go structs, they are just interfaces basically

nlupugla commented 1 year ago

@nlupugla No, I think they should not be able to add and remove fields from structs. If you want this kind of flexibility, Dictionary should be used. That would make it also super hard for the typed compiler to optimize.

To be clear, are you saying that users can't add/remove fields with the current proposal or are you saying they shouldn't be able to add/remove fields. My reasoning was that since these structs are basically just fancy arrays, you can arbitrarily add and delete elements. Maybe there is something wrong in that reasoning though.

reduz commented 1 year ago

@Calinou I am more inclined to think they should not, at least on the C++ side.

On the GDScript side, maybe, It would have to be handled internally in the parser, and emit the initializer code as instructions if it sees a struct initialized.

reduz commented 1 year ago

@nlupugla Adding and removing elements would break the ability of the typed optimizer to deal with it, because it will assume a property will always be at the array same index when accessing it. The resize() and similar functions in Array, as example, should not work in struct mode.

nlupugla commented 1 year ago

@nlupugla Adding and removing elements would break the ability of the typed optimizer to deal with it, because it will assume a property will always be at the array same index when accessing it. The resize() and similar functions in Array, as example, should not work in struct mode.

This has implications for many of the "normal" array functions as they all now have to check weather they are a struct or not to determine weather adding/deleting is okay right? To be clear, I agree that users should not be able to add or remove fields from structs at runtime, I just want to be sure the proposal is clear about how this restriction will be implemented.

GsLogiMaker commented 1 year ago

How would memory be managed for structs? Would they be reference counted, passed by value, manual, or other?

michaldev commented 1 year ago

Great idea. I believe it would be worth expanding it with defaults - something similar to Pydantic.

nlupugla commented 1 year ago

How would memory be managed for structs? Would they be reference counted, passed by value, manual, or other?

They are basically fancy arrays, so I think the memory will be managed exactly as arrays are. As a result, I believe they will be ref counted and passed by reference. Feel free to correct me if I'm wrong reduz :)

reduz commented 1 year ago

@nlupugla that is correct!

reduz commented 1 year ago

The main idea of this proposal is to implement this with very minimal code changes to the engine, making most of the work on the side of the GDScript parser pretty much. Not even the VM needs to be modified.

nlupugla commented 1 year ago

Two follow up questions.

  1. Is it possible to have a const struct? In other words, in GDScript, will MyStruct(a = "hello", b = 22) be considered a literal in the same way that {a : "hello", b : 22} would be?
  2. If there are no user-defined defaults, how are the fields of "partially" constructed filled? In other words, what will MyStruct(a = "hello", c = 3.14).b return? Will it be null, a runtime error, an analyzer error, or the default of whatever type b is?
adamscott commented 1 year ago

I think we should create a separate "entity" for flatten arrays of structs, even if it's pure syntactic sugar for GDScript.

struct Enemy:
   var position: Vector2
   var attacking : bool
   var anim_frame : int

var enemies : = StructArray(Enemy) # single struct
enemies.structs_resize(10000) # Make it ten thousand, flattened in memory
# or
var enemies :  = StructArray(Enemy, 10000) 
sairam4123 commented 1 year ago

Can methods be supported like in Structs in C++?

struct Tile:
  var pos: Vector2
  var bitmask: Bitmask

  func mark_flag():
     bitmask |= Mask.FLAG

  func more_func():
    pass

it will be similar to classes.

reduz commented 1 year ago

@sairam4123 nope, if you want methods you will have to use classes.

reduz commented 1 year ago

@adamscott I thought about this, but ultimately these are methods of Array and will appear in the documentation, so IMO there is no point in hiding this.

The usage of the flattened version is already quite not-nice, but its fine because its very corner case, so I would not go the extra length to add a new way to construct for it.

Not even the syntax sugar is worth it to me due to how rarely you will make use of it.

nlupugla commented 1 year ago

@adamscott I thought about this, but ultimately these are methods of Array and will appear in the documentation, so IMO there is no point in hiding this.

The usage of the flattened version is already quite not-nice, but its fine because its very corner case, so I would not go the extra length to add a new way to construct for it.

What about having StructArray inherit from Array? That way the methods for StructArray won't show up in the documentation for Array.

Another bonus would be that StructArray can have different operator overloads for math operations. For example my_array + 5 appends 5, but my_struct_array + 5 adds 5 element-wise.

sairam4123 commented 1 year ago

@sairam4123 nope, if you want methods you will have to use classes.

@reduz But C++ supports Struct Methods right? I don't think there is any use for Struct (for me atleast) if it does not support Methods.

methods help us change the state of the structs without having to resort to methods in the parent class.

Here's an example:


struct Tile:
   var grid_pos: Vector2
   var mask: BitMask

func flag_tile(tile: Tile):
   tile.mask |= Mask.FLAG

As you can see the methods are separated from the struct which is of no use. If you want the structs to be immutable, then provide us MutableStructs which can support methods that mutate it. Using classes is not a great idea for us.

JuanFdS commented 1 year ago

Could there be an special match case for structs? I think it could be useful in cases where there for example 2 different structs that represent bullets that behave in different ways, so one function with one match could concisely handle both cases (similar to how its handled in languages with functions and pattern matching).

nlupugla commented 1 year ago

@sairam4123 C++ structs are literally classes but with different defaults (public by default instead of private) so I don't think we should be looking to C++ as an example here. Ensuring that structs are as close to Plain Old Data as possible also makes them easy to serialize.

KoBeWi commented 1 year ago

You could sort of use methods if you make a Callable field.

nlupugla commented 1 year ago

You could sort of use methods if you make a Callable field.

I thought about that too, although it would be super annoying without defaults as you'd have to assign every instance of the struct the same callables manually.

sairam4123 commented 1 year ago

You could sort of use methods if you make a Callable field.

I thought of it, yes.

@sairam4123 C++ structs are literally classes but with different defaults (public by default instead of private) so I don't think we should be looking to C++ as an example here. Ensuring that structs are as close to Plain Old Data as possible also makes them easy to serialize.

Yes, I was looking into difference between structs and classes and yeah I'll take back my argument.

sairam4123 commented 1 year ago

So structs won't support any of the OOPS (inheritance, generics, etc.) right? Please do correct me if I am wrong.

TheDuriel commented 1 year ago

Sounds great.

Any thoughts on the ability to save/load structs to disk like we can variants? store_var() get_var() This might provide a nice alternative to users using resources/dictionaries for this purpose.

reduz commented 1 year ago

@TheDuriel yes, the struct information can be added to the serialization, so when you deserialize you get a struct again.

avedar commented 1 year ago

Could the system handle lots and lots of structs? I'm wondering if structs could be used as a way of getting some named parameter functionality when calling functions. e.g.

struct ParamStruct:
    var name : String
    var amount : int

func _ready():
    myFunc(ParamStruct(name="Joe", amount=5))

func myFunc(params : ParamStruct):
    # do stuff...
nlupugla commented 1 year ago

Could the system handle lots and lots of structs? I'm wondering if structs could be used as a way of getting some named parameter functionality when calling functions. e.g.

struct ParamStruct:
  var name : String
  var amount : int

func _ready():
  myFunc( ParamStruct( name="Joe", amount=5 ) )

func myFunc( params : ParamStruct ):
  # do stuff...

Structs are proposed to be just arrays under the hood, so you can have as many structs as you could have arrays. Structs would be absolutely ideal for the use case above.

badlogic commented 1 year ago

What would this do?

var a : MyStruct
var b: MyStruct

a = b
badlogic commented 1 year ago

Related, based on your code example above:

for i in range(enemies.structs.size()):
  var a = enemies.struct_at(i)
  foo(a)

Assume foo() holds on to a in a global variable or field, then enemies is resized, so the storage location of the one enemy passed to foo() is no more. I don't think this can work without value semantics for structs.

TheYellowArchitect commented 1 year ago

There is one more reason to have structs not mentioned in OP or list of related issues: Any advanced usage of RPCs e.g. https://github.com/godotengine/godot-proposals/issues/6216

michaldev commented 1 year ago

Could a struct also describe the content of classes? I would like to have the ability to describe methods and objects that must be implemented in a given game object. Something like interfaces in Java/Kotlin.

willnationsdev commented 1 year ago

@sairam4123

So structs won't support any of the OOPS (inheritance, generics, etc.) right? Please do correct me if I am wrong.

Since each struct instance would just be an abstraction over an Array in the engine C++ code, and the definition of said abstraction is purely a property layout defined in the binding code, your assumption is correct. No object-related features would be available to them, or anything else that goes beyond associating a name and type with a given offset within the Array instance to ease the binding process in the scripting language implementation.

@reduz

The main idea of this proposal is to implement this with very minimal code changes to the engine, making most of the work on the side of the GDScript parser pretty much. Not even the VM needs to be modified.

I think this is an especially sensible approach. It doesn't offer all the same features as objects, but nonetheless improves developer experience over Dictionaries and solves the edge case on the scripting side. With that said...

nope, if you want methods you will have to use classes.

If we don't provide some kind of mechanism for allowing users to associate methods with them, then we'll likely start to see a lot of GDScript codebases containing singletons or static helpers filled with static methods that operate on/with structs.

It might be prudent to consider pre-empting this scenario using method-only traits (#6416) that can be applied to a struct declaration or independently introducing a GDScript "extension methods" feature similar to what C# offers.

Given how user-friendly structs will likely become over Resources/Dictionaries for simple data structures, I don't think we can dismiss that possibility based on structs being for edge cases. I mean, heck, we've already got people suggesting defining them purely to have named parameters for GDScript methods in this proposal after only a few hours.


From an engine perspective, structs as arrays makes sense. It's simple and direct. From the user side though, given their reference semantics, it makes less sense. It conflicts with the value semantics expected of the term "struct" in the broader dev community.

I suggest that each scripting language's implementation provide value semantics over their abstraction of these Struct instances as a compromise between the engine's internal needs and users' expectations.

For example, (not the only way it could be done) GDScript could have its own wrapper around the engine struct that provides copy-by-value semantics over the Struct instance's internal array.

struct MyStruct:
    id: int

var a = MyStruct(id: 42)
var b = a # returns `.duplicate()` of MyStruct instance.
var c = [a] # resort to typical arrays if you need a reference. (or have a `structref()` global helper method?)

Any other scripting language making use of the engine's C++ Struct code would likewise be expected to expose the type with value semantics, to keep things consistent.


Other questions:

Will these struct declarations be visible outside the scope of the declaring GDScript class?

# A.gd
class_name A
struct MyStruct:
  id: int

# B.gd
func _ready():
    var my_struct = A.MyStruct(id: 42)

If so, will there be a means of having scripts that declare themselves as a struct, eliminating the need to reference them through a pseudo-namespace?

# MyStruct.gd
@struct
var id: int

# B.gd
func _ready():
    var my_struct = MyStruct(id: 42)
willnationsdev commented 1 year ago

Could a struct also describe the content of classes? I would like to have the ability to describe methods and objects that must be implemented in a given game object. Something like interfaces in Java/Kotlin.

That's a good question. In essence, would there be a way of accessing binding/reflection data about a Struct, as one would an Object? Since the proposal mentions having the ability to generate documentation about a struct definition as a listed advantage over Dictionary usage, then it would seem to indicate that you can, however the proposal doesn't seem to have a concrete detailing of how such binding code would be exposed. Would the Struct derived class have a get_struct_property_list() method or something?

nlupugla commented 1 year ago

In light of the pass-by-reference and lack of method support in the current proposal, it might be more accurate to call these something other than "structs". Perhaps "product type", "record type", or "data transfer object", although none of these terms are as familiar and concise as just "struct".

nlupugla commented 1 year ago

In the cases where you actually want to pass by value, rather than reference, I don't think it's too much of a burden to just write my_struct.duplicate().

nlupugla commented 1 year ago

If we don't provide some kind of mechanism for allowing users to associate methods with them, then we'll likely start to see a lot of GDScript codebases containing singletons or static helpers filled with static methods that operate on/with structs.

I think the natural way to go about this would be to add static methods to whatever script defined the struct in the first place. For example MyStruct.my_func(my_struct) instead of my_struct.my_func(). It is slightly more verbose, but it also makes it clear that structs just represent data, not data + behavior.

vpellen commented 1 year ago

I believe they should be value types, not reference types.

I understand this is something of an ease-of-implementation issue, but a lightweight composite value data type is something that the language is sorely missing, and people may likely assume that structs are value types anyway. Also, while I did not read the exact details on FlatArray, I assume they either quietly erase the reference semantics (that is, assignments to a FlatArray duplicate the contents of the struct) or you lose your main caching potential (because you're working with an array of references). In addition, if value semantics ever did become desirable, a modification would break compatibility.

If it is absolutely imperative that the reference semantics be preserved, I'd strongly recommend giving these constructs an alternative name that does not have existing connotations of value semantics.

reduz commented 1 year ago

Sorry, no pass by value. Make a copy yourself if you need.

GDScript types are either immutable or shared, not both.

Even if there is disagreement in the definition, it needs to be called struct because it is the name most people expexts it to have

nlupugla commented 1 year ago

One thing to keep in mind with pass-by-value and pass-by-reference is that GDScript doesn't have any pointer syntax, but there are plenty of things with .duplicate methods. If they were pass-by-value, there would be no way in GDScript to pass by reference and avoid making a copy. On the other hand, with pass-by-reference, you can always request a duplicate when you need one.

vpellen commented 1 year ago

GDScript types are either immutable or shared, not both.

Edit: Actually, upon re-reading, I'm not even sure what this is even in response to. A copy-by-value struct is neither immutable nor shared.

Even if there is disagreement in the definition, it needs to be called struct because it is the name most people expexts it to have

People will likely also expect it to behave a certain way. Which expectation is more important?

willnationsdev commented 1 year ago

GDScript types are either immutable or shared, not both.

I wasn't suggesting that it be capable of both. Just that it follow the paradigm most commonly associated with the term.

Even if there is disagreement in the definition, it needs to be called struct because it is the name most people expexts it to have

I would argue that "the name most people [expect] it to have" would be whatever name most sensibly describes the behavior of the syntactic element. In this case, the name struct (as far as my experience goes) is used in most languages to explicitly deviate from other user-defined data types by virtue of its implicit value semantics. Re-using the name with conflicting semantics would be confusing.

What are we to do then when people start trying to export Struct properties and have them be accessed from other languages, especially C# that Godot officially supports? If scripting language implementations do not do any work to abstract away semantic differences, then things may get very confusing indeed. For example...


I merely argue for consistency. Whether that means changing the name or manually abstracting away semantic differences, I leave that for others to decide.


Edit:

To have the best breadth of options available while still keeping things relatively consistent, I think record might be the better term to go with here, if only because it's another "data structure" term that still denotes reference semantics. That keeps it consistent to some degree with other officially supported languages (C/C++ and C#). Then you have reference types on which you can call .duplicate() to produce a new instance, just as @nlupugla suggested.

If, however, we instead explicitly do not care to avoid inconsistencies with a reference-type struct, then we should make sure there is clear documentation of that difference in behavior. In that case, users would simply have to get used to the nuances of Godot's type system and its differences from other systems.

reduz commented 1 year ago

@willnationsdev As I mentioned in the OP itself, several times, this is a feature majorly meant for GDScript users. It does not have a ton of use in C#.

In C# it will work like typed arrays, it may have a special Godot.Struct to access fields by name but not more than this.

The APi dump will most likely give you the struct layout so it may even be possible to create wrappers for C# and C++, but it will not be used a lot in the Godot API itself I think.

SirLich commented 1 year ago

Since we're talking about performance of classes, I wonder whether you've considered implementing something similar to 'Slots' in Python? 'Slots' are an opt-in way of defining classes which prevents them from receiving any new attributes, reducing memory.

Essentially they start to act a bit like structs, where some of the struct fields are also functions: https://docs.python.org/3.8/reference/datamodel.html#object.__slots__

nlupugla commented 1 year ago

Will it be possible to create struct literals, const structs, and/or anonymous structs? I think it should be possible, as, again, structs are just arrays under the hood. Here's an idea I had for some potential syntax.

const DEFAULT_STATS := [&"hp" : int = 100, &"attack" : float = 10.0]

var hp : int = DEFAULT_STATS.hp
var attack : float = DEFAULT_STATS.attack
reduz commented 1 year ago

@nlupugla yes it should work since it works for arrays already

josefalanga commented 1 year ago

Even if there is disagreement in the definition, it needs to be called struct because it is the name most people expexts it to have

Most people will expect something called struct, to behave like an struct from languages they know. If I'm not wrong, here those languages are mostly C#, C++ (maybe python?). It was mentioned previously that for most, that means structs can have methods, and are passed by value, not reference. If your intention is doing the minimal work to have a typed version of a dictionary/array under the hood, probably the name struct is a bad idea.

You might benefit from other names, or even just blatantly call them typed dictionary or something similar, to just be more transparent to the actual implementation and what it means for the users of GDScript.

That said, I like this proposal a lot. We need a structure like this for handling data containers better, in a typed way. Puting names to entities, and reusing that, is just good design.