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

Allow explicitly defining interfaces in GDScript #4872

Open
nebs opened this issue Jul 14, 2022 · 33 comments
Open

Allow explicitly defining interfaces in GDScript #4872

nebs opened this issue Jul 14, 2022 · 33 comments

Comments

@nebs
Copy link

nebs commented Jul 14, 2022

Describe the project you are working on

I'm working on a game where different entities can share similar behaviors, but this proposal is specific to GDScript, so it can apply to a wide range of projects.

Describe the problem or limitation you are having in your project

Let's say we're making a simple shooter game. The player can shoot a Bullet. Bullets can hit a Ship and Building. There are also Tree entities that cannot be hit by bullets.

Here's some simple code (unrelated code omitted for simplicity).

Ship.gd

extends Node2D

class_name Ship

func die() -> void:
  print("Died")

Building.gd

extends Node2D

class_name Building

func die() -> void:
  print("Died")

Tree.gd

extends Node2D

class_name Tree

func die() -> void:
  print("Died")

Bullet.gd

extends Node2D

class_name Bullet

func on_body_entered(body: Node2D) -> void:
  if body.has_method("die"):
    body.die()

There are a few problems with this duck-typing approach:

  1. The function "die" is referenced as a string which is error-prone
  2. In Bullet.gd there is no code auto-completion or checking when calling body.die() so you need to implicitly know that that function exists.
  3. The mere presence of a function named die is not enough to determine what kind of object you're dealing with. In this example, we don't want bullets to hit Tree, even though a tree might happen to have a function named die (the tree could die of natural causes for example).
  4. If we wanted to make sure that the body we collided with implements more than just the die function we would have to do multiple has_method checks which can get ugly in code.

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

My proposal is to introduce a language feature which allows us to explicitly define interfaces. This is a common feature in many modern languages and makes it easy to deal with complex object interactions without relying on inheritance.

See below for an implementation proposal.

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

Let's take the same example code as above, and let's see what it could look like if GDScript had interface support.

Killable.gd

interface_name Killable

func die() -> void

Ship.gd

extends Node2D
implements Killable

class_name Ship

func die() -> void:
  print("Died")

Building.gd

extends Node2D
implements Killable

class_name Building

func die() -> void:
  print("Died")

Tree.gd

extends Node2D

class_name Tree

func die() -> void:
  print("Died")

Bullet.gd

class_name Bullet

func on_body_entered(body: Node2D) -> void:
  if body implements Killable:
    body.die() 

Note: Because we're nested in an implements check we should be able to auto-complete "die" and also throw an error if "die" is not part of the Killable interface.

Alternatively, you can also reference the object via its interface by casting, for example:

Bullet.gd

class_name Bullet

func on_body_entered(body: Node2D) -> void:
  var killable: Killable = body as Killable
  if killable:
    killable.die()

This has a few advantages:

  1. If you accidentally delete or rename die() from Ship or Building, the editor should show an error that you haven't fully implemented the interface.
  2. In Bullet we can now make our intentions explicit. The fact that there's a function called die is an implementation detail, but the true intent is we want to know if we're dealing with a Killable object. This way, classes like Tree which happen to have a die function won't be processed because they're not killable.
  3. If the function die is renamed in the interface, then there would be errors showing all the places where it is being called and implemented. Previously the has_method("die") would not be flagged as an error because it's referencing the function as a string.
  4. In Bullet, since we're now declared explicit intent that we want to deal with a Killable object, we should be able to auto-complete Killable functions, and also throw errors if we try to call a function that isn't defined in Killable.

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

Technically it can be worked around with the current duck-typing approach. But I think it would make the code cleaner and safer to use explicit Interfaces. It's also easier and faster to write because we would have explicit checks and auto-completion. It's safer because it's more resilient to changes too (e.g. renaming a function wouldn't be flagged if the function is referenced as a string).

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

Interfaces are built-in to other languages, I think this would make sense to be a core part of GDScript and not some external tool. It's akin to classes, functions, variables, etc. so I think it deserved first-class citizenship.

@Calinou Calinou changed the title GDScript Interfaces Allow explicitly defining interfaces in GDScript Jul 14, 2022
@Calinou
Copy link
Member

Calinou commented Jul 14, 2022

Related to #758.

@nebs
Copy link
Author

nebs commented Jul 14, 2022

Another thing I forgot to mention, but with interfaces we can also be explicit about function arguments, for example:

func kill(killable: Killable) -> void:
  killable.die()

Whereas currently we'd have to do something like this:

func kill(node: Node2D) -> void:
  if node.has_method("die"):
    node.die()

But I guess this is slightly less useful because you'd have to know ahead of time that the object implements the interface. But I like that it makes the code cleaner and easier to reason about because the intent is clear.

@me2beats
Copy link

Can this be used in cases when Godot throws
cyclic dependency

@nsrosenqvist
Copy link

Yes please, in my own projects I have implemented a system for interfaces in GDScript for such cases where inheritance doesn't make sense, but I'd much rather have it be natively available

@sairam4123
Copy link

Yes please, in my own projects I have implemented a system for interfaces in GDScript for such cases where inheritance doesn't make sense, but I'd much rather have it be natively available

It would be awesome if you could tell me how you did it, since I need this the most for my own projects.

@nsrosenqvist
Copy link

@sairam4123 It's not properly implemented at a language level, but it has worked well enough for my current project. I'll upload it and send you a link when I get home

@bitbrain

This comment was marked as off-topic.

@Calinou
Copy link
Member

Calinou commented Jul 19, 2022

@bitbrain Please don't bump issues without contributing significant new information. Use the 👍 reaction button on the first post instead.

@nsrosenqvist
Copy link

@sairam4123 @bitbrain sorry for the delay. I decided to clean it up a bit when I extracted the code from my utility library. You can now find the project as an addon here: https://github.com/nsrosenqvist/gdscript-interfaces/

@trommlbomml
Copy link

Would it make sense that the interface is just available before JIT kinda similiar to interfaces in typescript? This would have the benefit that interfaces have zero runtime overhead but fulfills this use case - with the trade off that at runtime you have no information about the interfaces any more. but you can argue that you have no real architectural improvement if you need to ask a class if it implements an interface and it may be easier at least as a step zero to get this feature.

@just-like-that
Copy link

I like your proposal.
I prefer to name it just interface and NOT interface_name coz the purpose is to define an interface (well with a specific name ofc).
2nd I'm opting for support of multiple interfaces of implementing classes.
Maybe like so:
class A implements Interface_A, Interface_B, ...

@nsrosenqvist
Copy link

@just-like-that I agree, otherwise they too get too bound up with inheritance and don't enable the flexibility sought after. Ideally should traits also be added at the same time as interfaces are implemented. See #758

@nebs
Copy link
Author

nebs commented Jul 21, 2022

I like your proposal. I prefer to name it just interface and NOT interface_name coz the purpose is to define an interface (well with a specific name ofc). 2nd I'm opting for support of multiple interfaces of implementing classes. Maybe like so: class A implements Interface_A, Interface_B, ...

So I agree with you in general that interface would read better than interface_name, but in my proposal I was trying to stay as close as possible to the current gdscript standards so as to minimize the friction for implementing this feature.

For example currently we set class names like this:
class_name Foo
So the closest analog for interfaces would be:
interface_name Bar

Similarly, we declare inheritance like this:
extends Foo
So the closest analog is:
implements Bar

I agree that the ability to implement multiple interfaces should be allowed. That was sort of implied but I guess I should have made that more clear in my original post.

I like your idea of having a new syntax like class A implement IA, IB etc but I feel like that would require a first unrelated change of adding the class keyword to gdscript, so I didn't want to add additional requirements for this particular issue.

In my original proposal, perhaps multiple interfaces can be implemented as so:

implements IA
implements IB

Which is a bit wordy, but I think it's okay as a first implementation because I can't imagine a class having to implement more than a handful of interfaces at a time. But perhaps we can add a comma system to this too.

implements IA, IB

Anyway, I suppose that's getting into the weeds a bit. I'd be happy with any of the implementations mentioned above. Whichever approach has the least friction is most likely to get implemented. I suppose we first need to get philosophical buy-in from the decision-makers.

@samdze
Copy link

samdze commented Jul 22, 2022

What about the ability to also add property declarations in addition to methods as part of interfaces?
Signal declarations maybe? That would be nice.

@nsrosenqvist
Copy link

nsrosenqvist commented Jul 22, 2022

What about the ability to also add property declarations in addition to methods as part of interfaces? Signal declarations maybe? That would be nice.

I think signals makes sense, but not properties. They are usually dependent on how one chooses to implement an interface (eg. a data property that the methods operate on, or if another implementation instead fetches data from a remote source and don't operate on data locally).

@samdze
Copy link

samdze commented Jul 22, 2022

I think signals makes sense, but not properties. They are usually dependent on how one chooses to implement an interface (eg. a data property that the methods operate on, or if another implementation instead fetches data from a remote source and don't do operate on data locally).

Still possible, as long as properties implemented as part of an interface are forced to have a getter and (optional?) a setter.

@DasGandlaf
Copy link

DasGandlaf commented Jul 23, 2022

That's the biggest reason I am using c# at the moment.
I think GDScript interfaces should work exactly the way they do in c#. It's simple for the user, yet extremely helpful.
This way, my team and I could finally start automatically testing our GDScript code, while still having the benefits of static-typed GDScript.

@just-like-that
Copy link

just-like-that commented Jul 23, 2022

So I agree with you in general that interface would read better than interface_name, but in my proposal I was trying to stay as close as possible to the current gdscript standards so as to minimize the friction for implementing this feature.

From my understanding it's called class_name coz it's the name for the whole class file.
A class file contains consts, functions (also static), variables and inner classes.
Caution: A class_name has to be unique throughout the whole project.

Inner classes are named "class" only.

So interface_name would indicate it's also in its own file and they have to be unique thru out the prj.
Was this your initial idea? Binding of interfaces in its own interface files?

I see an interface more as a part of a specific class file.
As such a class file can contain more than one interface declaration.

The class file acts as a context aka name space to that interface.
This has the benefit that interfaces with the same name i.e. from plugins don't interfere with your own project's interface declarations.

By pre-loading the class file via
const <Example_Class> := preload("<example-class-path>")
it's possible to reference each interface individually without any interference like so

Example_Class.Interface_A

@dalexeev
Copy link
Member

By pre-loading the class file via const <Example_Class> := preload("<example-class-path>") it's possible to reference each interface individually without any interference like so

Example_Class.Interface_A

If you need to make the interface global, see my suggestion #4740.

@willnationsdev
Copy link
Contributor

willnationsdev commented Jul 30, 2022

If this were to be a thing in GDScript, I would suggest just having an @interface annotation which you could then apply above the class or class_name keywords to indicate that an inner class or the top-level class is an interface. That will then avoid confusion related to differentiating between interface and interface_name, remove the need for interface_name altogether, and avoid populating the global namespace with more keywords. You could then similarly define an @global annotation to generate global variables for inner interfaces throughout the GDScript language similar to what #4740 proposes.

Regardless of whether an interface system is added though, it'd be important to identify exactly how it would work. The most prevalent forms of interfaces I have observed are those in TypeScript and in C#, but the way they work in each of those languages is vastly different, so people should iron out what they actually want GDScript's interfaces to be first. These will be the "Strict" (C#), "Loose" (JavaScript-ish), and "Hybrid" (TypeScript-ish) approaches:

How is an interface evaluated against a type?

Strict

Is an interface a specific type with specific methods and a given script must explicitly implement the interface in order for it to be recognized as an implementation of the interface?

@interface
class IKillable:
    ## returns points awarded when killed. Prints `p_msg` afterward.
    func die(p_msg: String) -> int:
        pass
# parse error if no `pass`. Could also be on the next line.
# Must have name `die`.
# Must have 1 argument of type `String`. The name does not need to be `p_msg`.
# Must have `int` return type.
# GDScript parser would need to ignore improper return types on methods with static type hints.

class Ship implements IKillable:
    pass
# will not compile until a `die` method matching all the above conditions is defined.

OR

Loose

Is an interface just a way of checking multiple has_method calls implicitly?

@interface
class IKillable:
    func die(p_msg: String) -> int:
        pass
# Exact same as the Strict case

class Ship:
    func die(p_msg):
        pass
# This satisfies an `if ship implements IKillable` check b/c `has_method(...)`
# returns true for all subset of methods in IKillable.

OR...

Hybrid

Similar to "Loose" but actually supports any implicit union of types with fully evaluated static type checks, i.e. is it a union of methods with specific names, parameter count, types for each parameter, and a specific return type?

# Exact same `IKillable` as the Loose case

class Ship:
    func die(p_msg: String) -> int:
        return 3
# This satisfies an `if ship implements IKillable` check b/c all signatures of all
# methods in `IKillable` match exactly EVEN THOUGH the class never explicitly
# says it matches `IKillable` (so no static checking of interface conformity).

How does the interface handle edge cases related to method compatibility?

Strict

What about situations where a class implements two interfaces that share method names, but different arguments?

@interface
class IBag:
    func configure(p_slot_count: int = 0) -> void: pass

@interface
class IMail:
    func configure(p_owner: StringName, p_content: String) -> void: pass

class PostalDelivery implements IBag, IMail:
    # <implements methods exactly, but triggers parse error>
    pass
# GDScript does not support method overloading.
# Do we just forbid these combinations? That'd be a tough sell
# as people start relying on third-party libraries that all define global script classes.

# For "Strict" only, would we need to resort to having interface-specific
# annotations on methods to flag them?
# You'd need to make these hidden methods that aren't publicly accessible,
# likely with a generated name derived from the interface it belongs to.
# That way it doesn't actually occupy the same slot in the method HashMap
# used by the GDScript's C++ engine code.
    @interface(IBag)
    func configure(p_slot_count: int = 0) -> void: pass
    @interface(IMail)
    func configure(p_owner: StringName, p_content: String) -> void: pass
    # ^ This duplication of method name definitions would need to NOT trigger a parse error.

Loose

What about methods with an extra but optional variable that has a default value - would it still be an implementation?

@interface
class IKillable:
    func die(): pass

class Ship
    func die(p_msg := "") -> void: # does
        print(p_msg if p_msg != null else "ship died")
    # does this method count? It's POSSIBLE to call w/o args, so it WOULD satisfy duck-typing,
    # but it doesn't explicitly match the interface definition.

Hybrid

Behaves the same as "Loose".

Do interfaces support unions and/or inheritance?

Strict

Can interfaces extend each other?

@interface
class_name IShip
extends IKillable, IRevivable
# has methods `die()` and `revive()`, but no content in script.

Is it invalid for an interface to extend a class?

class Frigate implements IShip: pass

@interface
class IFleet:
    extends Frigate # would this be a parse error?

Loose

Could you define an interface that is a union of other interfaces?

@interface
class_name IShip
extends IKillable & IRevivable
# has methods `die()` and `revive()`, but no content in script.
# Because interfaces are mere combinations of methods,
# you can use bitwise operators on them (potentially).

Hybrid

Behaves the same as "Loose" in this regard too.


Also, since static typing is optional, what does static type checking on interfaces mean when a method on an interface does not use static type hints? Do you just reduce the number of required conditions an implementing class has to meet in order to conform to the interface? Or do you consider it a compiler error and demand that all methods on an interface use type hints everywhere?

I think it would be possible to go with any of the 3 versions. "Loose" would obviously be the most straightforward and simple to implement, but I doubt it would satisfy people who are looking for full static type checks to be honored by interface declarations.

Between the "Hybrid" and "Strict" versions, I think "Hybrid" more closely mimics GDScript's "scripting" context, but at the same time feel conflicted since "Strict" more closely honors GDScript's "pythonic C++" style. Ultimately though, GDScript is a scripting language and is meant to be easy to use. Regardless of which approach gets general consensus, I believe that interfaces should not be introduced in such a way that they only work when users elect to use static typing. They must also work with a complete lack of static types. Given that "duck-typing" is already the style used when a lack of static types is present, I would opt to take a "Hybrid" approach as it is the one that is most compatible with dynamic GDScript, and it makes GDScript behave like other scripting languages with type hints.

@just-like-that
Copy link

just-like-that commented Sep 5, 2022

What about this temporary solution for now?

Interface definition:

# interface_a.gd
extends Reference

## Returns a random integer between 0 and max value (inclusive).
func irnd(max_val: int) -> int:
	assert(false, "InterfaceNotCallableException.")
	# TODO: needs implementation in implementing class
	return -1
###

Implementing class:

# my_class.gd
extends Reference

const Interface_A := preload("res://scene/interfaces/interface_a.gd")

# implements Interface_A
func irnd(max_val: int) -> int:
	return randi() % (max_val + 1)
###

func is_class(clazz: String) -> bool:
	# check against own class and all its (directly) implementing interfaces
	return clazz in ["MyClass", Interface_A.get_path()] || .is_class(clazz)
###

Usage example:

# interface.gd
extends Node

const MyClass := preload("res://scene/interfaces/my_class.gd")

const Interface_A := preload("res://scene/interfaces/interface_a.gd")


func _ready() -> void:
	var implementing_class := MyClass.new()
	prints("class check:", implementing_class.is_class("MyClass"), true)
	prints("class check:", implementing_class.is_class("NotMyClass"), false)
	prints("interface check:", implementing_class.is_class(Interface_A.get_path()), true)
	prints("interface check:", implementing_class.is_class("res://scene/interfaces/interface_b.gd"), false)

	_do_action_on(implementing_class)
###


func _do_action_on(instance: Object) -> void:
	if instance.is_class(Interface_A.get_path()) :
		prints("random int value=", instance.irnd(7))
###

Explanation:
The interface defines all relevant methods of this specific interface.
The implementing class implements all or some of the interface methods.
The using class then checks against the interface via is_class(<interface_class path>).

The trick:
... is to use is_class() which is implemented already by GDScript.

By calling super.is_class() recursively it's ensured that the inheritance hierarchy is also supported.

To use the whole interface path ensures that interfaces with the same name with different paths are also possible.

@DasGandlaf
Copy link

I just wrote a 400000 paragraph comment responding to the comments here, then I realized, I just want interfaces lol.
Are there any news on this?

@Calinou
Copy link
Member

Calinou commented Dec 19, 2022

Are there any news on this?

Godot 4.0 is in feature freeze. To allow the language to stabilize, no new GDScript features will be added until after 4.0 is released.

@matiturock
Copy link

So, for now the interfaces are not going to be implemented. Is it to promote composition over inheritance?

@Ejsstiil
Copy link

Is there any news on this?

@Calinou
Copy link
Member

Calinou commented Oct 12, 2023

Is there any news on this?

To my knowledge, nobody is currently working on implementing this. 4.2 is in feature freeze, so any new features will be for 4.3 at the earliest.

@ryanabx
Copy link

ryanabx commented Oct 18, 2023

Is there any news on this?

To my knowledge, nobody is currently working on implementing this. 4.2 is in feature freeze, so any new features will be for 4.3 at the earliest.

I need this for my project, and I've already implemented abstract methods for 4.3 or beyond, in godotengine/godot#82987 , so it's time to take a stab at this

EDIT: After talking in the gdscript channel of the contributors chat, it appears that someone is working on the similarly proposed trait system for gdscript. That will appear in 4.3 or beyond as well though. Traits are a more powerful superset of interfaces, so I will hold off on developing this for now.

@Kalto-Mate

This comment was marked as off-topic.

@Calinou
Copy link
Member

Calinou commented Nov 2, 2023

@Kalto-Mate I have to remind you that we have a Code of Conduct. Please stay constructive.

@LEEROY-3
Copy link

LEEROY-3 commented Nov 6, 2023

It's a shame this feature has not been implemented initially for whatever reason. This feature missing from gdscript means C# interfaces can't be utilized to it's max potential either. See #7971, for example: A potentially powerful feature that isn't particularly hard to implement but will not be accepted as we must respect gdscript first, which does not have interfaces.

At this stage of gdscript, I imagine, it will be incredibly hard to implement such a feature as the language has been built around not having interfaces, which might explain why nobody is too eager to pick up this issue. Just a saddening situation all in all.

@ryanabx
Copy link

ryanabx commented Nov 6, 2023

At this stage of gdscript, I imagine, it will be incredibly hard to implement such a feature as the language has been built around not having interfaces, which might explain why nobody is too eager to pick up this issue. Just a saddening situation all in all.

It's actually not that hard to implement, it just requires a few building blocks first.

godotengine/godot#67777

A PR which implements an @abstract annotation for GDScript. This PR only covers classes, not methods. Potentially slated for 4.3

godotengine/godot#82987

A PR built on top of the other one which implements abstract methods through the same keyword. Also potentially slated for 4.3.

With these two PRs, it should be easy to make interfaces, just forbid any other members except abstract functions in an interface class and create an implements keyword.

I actually have a branch on my godot repo fork that has most of the implementation finished for interfaces, all that needs to be done is resolving classes that are implemented by another class.

https://github.com/ryanabx/godot/tree/gdscript/interfaces

The main reason I stopped working on it is because another godot engine contributor @vnen is going to eventually work on Trait support for GDScript, which would be a superset of the features that interfaces provide.

Believe me, I'm also anxiously waiting for interface support in GDScript, but it seems we will have to wait a little bit longer for something like it to be implemented in Godot.

@apostrophedottilde
Copy link

Has there been any progress towards this at all since the last post? I am really interested to get this feature.

@Calinou
Copy link
Member

Calinou commented Jan 5, 2024

Has there been any progress towards this at all since the last post? I am really interested to get this feature.

See the above comment:

The main reason I stopped working on it is because another godot engine contributor @vnen is going to eventually work on Trait support for GDScript, which would be a superset of the features that interfaces provide.

Work on traits hasn't started yet, but it seems traits are more likely to be implemented than interfaces.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests