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

Automatically create trait dependencies (trait dependency injection) #5

Merged
merged 7 commits into from
Jan 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 30 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ Given that Godot Engine lacks an official interface system, many developers reso
## 🗺️ Roadmap

- [x] Core trait system
- [ ] Automatic multi trait dependencies injection
- [ ] Automatic dependent trait declaration and creation
- [x] Automatic multi trait dependencies injection
- [x] Automatic dependent trait declaration and creation
- [x] Generation of an helper script to provide strong typed features and code completion in editor
- [ ] Inline traits into scripts by using the `@inline_trait(TheTraitName)` annotation
- [x] Helper methods to invoke code if object _is a [something]_ or else invoke a _fallback method_
Expand Down Expand Up @@ -278,11 +278,11 @@ func _process(delta:float) -> void:
GTraits.unset_damageable(crate)
```

#### 🔑 Automatic trait dependency injection
#### 🔑 Automatic trait dependencies injection

Traits may depend on each other to function, or they may require a _receiver object_ (the trait carrier) to implement a specific behavior. For instance, a _Damageable_ trait would likely need a _Healthable_ object to deduct health from when damage is taken.
Traits may depend on each other to function, or they may require a _receiver object_ (the trait carrier) to implement a specific behavior. For instance, a _Damageable_ trait would likely need a _Healthable_ object to deduct health from when damage is taken. It may also requires a _Loggable_ trait to do some debug prints.

_Godot Traits_ provides automatic injection of trait dependencies into trait constructors. If a trait constructor requests an object of the same type as the _trait receiver_ (or no specific type), the _receiver_ is automatically injected into the trait.
_Godot Traits_ provides automatic injection of trait dependencies into trait constructors. If a trait constructor requests an object of the same type as the _trait receiver_ (or no specific type), the _receiver_ is automatically injected into the trait. If the trait constructor requires other traits, thoses traits will be retrieved from the _trait receiver_ itself. If some traits can not be resolved in the _receiver_, they are automatically (recursively) instantiated, registered into the _trait receiver_ for future usage, and injected into the instantiating trait.

```gdscript
#####
Expand Down Expand Up @@ -324,15 +324,20 @@ If the trait constructor requests an object of a different type than the _receiv
class_name Damageable

var _healthable:Healthable

# This trait needs a Healthable to work (an object to remove health from)
# Healthable is also a trait. GTraits will check if the receiver object owns this traits, and automatically
# inject the Healthable trait into this trait constructor
func _init(the_healthable:Healthable) -> void:
var _loggable:Loggable

# This trait needs both Healthable (an object to remove health from) and Loggable (an object that is
# able to print debug logs) to work. Healthable is also a trait. GTraits will check if the receiver
# object owns those traits, and automatically inject them intothis trait constructor.
# If the receiver does not have the required traits, they are automatically instantiated, registered into
# the receiver and injected into this trait.
func _init(the_healthable:Healthable, the_loggable:Loggable) -> void:
_healthable = the_healthable
_loggable = the_loggable

func take_damage(damage:int) -> void:
_healthable.health -= damage
_loggable.log("Took %d damage!" % damage)

#####
# File world.gd
Expand All @@ -341,21 +346,27 @@ extends Node2D

func _init() -> void:
var crate:Node2D = preload("crate.tscn").instantiate()
GTraits.set_healthable(crate)
# This will automatically make Damageable trait to be construct using the Healthable trait declared above.
# Order is important here, since Damageable trait needs the Healthable trait, so Healthable trait should be
# declared BEFORE the Damageable trait.
# Only the Damageable trait is set initially
# Now, when the Damageable trait is constructed, it automatically declares, creates,
# and injects the required Healthable and Loggable traits into the crate
GTraits.set_damageable(crate)
assert(GTraits.is_damageable(crate), "It is Damageable !")
assert(GTraits.is_loggable(crate), "It is Loggable too !")
assert(GTraits.is_healthable(crate), "It is Healthable too !")
```

##### 📜 Automatic trait dependency injection rules
##### 📜 Automatic trait dependencies injection rules

- When automatically instantiating traits, developers need to be mindful of cyclic dependencies—cases where traits depend on each other. _Godot Traits_ cannot construct such traits due to the inherent cyclic structure. If encountered, an assertion error is raised, providing details about the cyclic dependency,

![image](addons/godot-traits/documentation/assets/gtraits_cyclic_dep_detection.png)

- The _auto-instantiation_ feature in _Godot Traits_ is limited to handling trait instances. If a trait's constructor demands an instance of a type that is not a trait, an assertion error will be raised. This limitation is essential as generic types may not adhere to trait rules and cannot be treated as such,

- ⚠️ For now, only constructor with zero or one argument are handled by _Godot Traits_. See ___Roadmap___ for evolution,
- If `GTraits` can not find the required trait to inject into another trait constructor, an assertion error is raised,
![image](addons/godot-traits/documentation/assets/gtraits_not_a_trait_error.png)

![image](addons/godot-traits/documentation/assets/gtraits_assertion_error_can_not_instantiate.png)
- Default arguments in trait constructors are not considered.

- Trait declaration order is important since a trait must exist into an object to be injectable into another trait constructor.

#### 🔑 Traits inheritance

Expand Down
148 changes: 148 additions & 0 deletions addons/godot-traits/core/builder/gtraits_trait_builder.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
extends RefCounted
class_name GTraitsTraitBuilder

##
## Trait builder for [GTraits].
##
## [color=red]This is an internal API.[/color]
#------------------------------------------
# Constants
#------------------------------------------

#------------------------------------------
# Signals
#------------------------------------------

#------------------------------------------
# Exports
#------------------------------------------

#------------------------------------------
# Public variables
#------------------------------------------

#------------------------------------------
# Private variables
#------------------------------------------

# All known traits, for dep injection. As a dictionary to check trait in o(1)
var _known_traits:Dictionary
var _traits_storage:GTraitsStorage = GTraitsStorage.new()
var _type_oracle:GTraitsTypeOracle = GTraitsTypeOracle.new()

#------------------------------------------
# Godot override functions
#------------------------------------------

#------------------------------------------
# Public functions
#------------------------------------------

## Declare a class as a trait, making it available for dependency injection
func register_trait(a_trait:Script) -> void:
_known_traits[a_trait] = true

## Retuns the trait for the given receiver. If the trait already exists, it is just returned. Otherwise,
## it is instantiated, registered into the receiver and returned.
func instantiate_trait(a_trait:Script, receiver:Object) -> Object:
return _instantiate_trait(a_trait, receiver)

#------------------------------------------
# Private functions
#------------------------------------------

func _instantiate_trait(a_trait:Script, receiver:Object, encoutered_traits:Array[Script] = []) -> Object:
# Check if this is an actual trait
if not _known_traits.get(a_trait, false):
assert(false, "Type '%s' is not a trait and can not be automatically instantiated" % _type_oracle.get_script_class_name(a_trait))
return null

# Check there is no cyclic dependencies in progress
if encoutered_traits.has(a_trait):
var cyclic_dependency_string:String = encoutered_traits \
.map(_type_oracle.get_script_class_name) \
.reduce(func(accum, name): return "%s -> %s" % [accum, name])
cyclic_dependency_string = "%s -> %s" % [cyclic_dependency_string, _type_oracle.get_script_class_name(a_trait)]
assert(false, "⚠️ Cyclic dependency detected during trait instantiation: %s" % cyclic_dependency_string)
return null
# Register this trait to be encountered
encoutered_traits.append(a_trait)

# If receiver already has the given trait, return it immediatly, else try to instantiate it
var trait_instance:Object = _traits_storage.get_trait_instance(receiver, a_trait)
if not is_instance_valid(trait_instance):
trait_instance = _instantiate_trait_for_receiver(a_trait, receiver, encoutered_traits)

# This trait has been handled, so we can pop it out
encoutered_traits.pop_back()

return trait_instance

func _instantiate_trait_for_receiver(a_trait:Script, receiver:Object, encoutered_traits:Array[Script]) -> Object:
assert(a_trait.can_instantiate(), "Trait '%s' can not be instantiated" % _traits_storage.get_trait_class_name(a_trait))

# Trait constructor ('_init' method) can take 0 or multiple parameters.
# If it takes parameters, it can either be:
# - the object itself, since trait may need contextual usage to work
# - a trait of the object itself
var constructor_parameters:Array[Object] = []
# To print a warning if the receiver is injected multiple times in the same constructor
# Maybe something is wrong in that case...
var receiver_object_already_injected:bool = false
# Tells if there was a fatal error during trait instantiation
var error_encountered:bool = false

# Look for _init method to check if it takes parameters or not
for method in a_trait.get_script_method_list():
if method.name == "_init":
# Find/construct required arguments
for arg in method.args:
# Is it the receiver itself, or a trait ?
var constructor_argument_class_name:String = arg.class_name
if constructor_argument_class_name.is_empty():
# Argument is not strongly typed. Just pass the receiver itself as parameter
# Hope for the best !
if receiver_object_already_injected:
printerr("⚠️ Injecting at least twice the trait receiver into trait '%s' constructor" % _traits_storage.get_trait_class_name(a_trait))
receiver_object_already_injected = true
constructor_parameters.append(receiver)
else:
# Two possibilities :
# - parameter is an instance of the receiver itself : the receiver is the expected parameter
# - else, parameter is an instance of a trait, so try to get it or instantiate it
if _type_oracle.is_object_instance_of(constructor_argument_class_name, receiver):
constructor_parameters.append(receiver)
else:
var needed_trait:Script = _type_oracle.get_script_from_class_name(constructor_argument_class_name)
if not is_instance_valid(needed_trait):
assert(false, "Trait '%s' can not be found in project." % constructor_argument_class_name)
error_encountered = true
break

var trait_instance:Object = _instantiate_trait(needed_trait, receiver, encoutered_traits)
if not is_instance_valid(trait_instance):
assert(false, "Unable to instantiate trait '%s'." % constructor_argument_class_name)
error_encountered = true
break

constructor_parameters.append(trait_instance)

# Ugly but efficient: there is only one _init method in a script !
break

# Something went wrong, can not instantiate objects...
if error_encountered:
return null

# Instantiate trait and save it into the receiver trait instances storage
var trait_instance:Object = a_trait.new.callv(constructor_parameters)
_traits_storage.store_trait_instance(receiver, trait_instance)

# If trait has parent classes, to prevent to create new trait instance if parent classes are asked for this
# receiver, register this trait instance has the one to be returned when a parent class is asked (POO style)
var parent_script:Script = a_trait.get_base_script()
while(parent_script != null):
_traits_storage.store_trait_instance(receiver, trait_instance, parent_script)
parent_script = parent_script.get_base_script()

return trait_instance
61 changes: 6 additions & 55 deletions addons/godot-traits/core/gtraits_core.gd
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,8 @@ class_name GTraitsCore
# Private variables
#------------------------------------------

static var _type_oracle:GTraitsTypeOracle = GTraitsTypeOracle.new()
static var _traits_storage:GTraitsStorage = GTraitsStorage.new()
static var _trait_builder:GTraitsTraitBuilder = GTraitsTraitBuilder.new()

#------------------------------------------
# Godot override functions
Expand All @@ -69,6 +69,10 @@ static var _traits_storage:GTraitsStorage = GTraitsStorage.new()
# Public functions
#------------------------------------------

## Declare a class as a trait. It then becomes available for several operations on trait.
static func register_trait(a_trait:Script) -> void:
_trait_builder.register_trait(a_trait)

## Returns [code]true[/code] if an object has a given trait, [code]false[/code] otherwise.
static func is_a(a_trait:Script, object:Object) -> bool:
if not is_instance_valid(object):
Expand All @@ -93,7 +97,7 @@ static func add_trait_to(a_trait:Script, object:Object) -> Object:
else:
# Register trait into object, and instantiate it
object_traits.push_back(a_trait)
trait_instance = _instantiate_trait_for_object(a_trait, object)
trait_instance = _trait_builder.instantiate_trait(a_trait, object)

return trait_instance

Expand Down Expand Up @@ -155,56 +159,3 @@ static func if_is_a_or_else(a_trait:Script, object:Object, if_callable:Callable,
# Private functions
#------------------------------------------

static func _instantiate_trait_for_object(a_trait:Script, object:Object) -> Object:
assert(a_trait.can_instantiate(), "Trait '%s' can not be instantiated" % _traits_storage.get_trait_class_name(a_trait))

# Trait constructor ('_init' method) can take 0 or 1 parameter.
# If it takes one parameter, it can either be:
# - the object itself, since trait may need contextual usage to work
# - a trait of the object itself
var constructor_parameter:Object = null
var constructor_has_argument:bool = false

# Look for _init method to check if it takes parameters or not
for method in a_trait.get_script_method_list():
if method.name == "_init":
if method.args.is_empty():
# No parameter in trait constructor, simplest use case !
pass
elif method.args.size() == 1:
# Trait takes one parameter for sure
constructor_has_argument = true
# But, is it the object itself, or one of its already declared traits ?
var constructor_argument_class_name:String = method.args[0].class_name
if constructor_argument_class_name.is_empty():
# Argument is not strongly typed. Just pass the object itself as parameter
# Hope for the best !
constructor_parameter = object
else:
# Two possibilities :
# - parameter is an instance of the object itself : the object is the expected parameter
# - else, parameter is an instance of the object traits, so try to get it
if _type_oracle.is_object_instance_of(constructor_argument_class_name, object):
constructor_parameter = object
else:
var needed_trait:Script = _type_oracle.get_script_from_class_name(constructor_argument_class_name)
assert(is_instance_valid(needed_trait), "Trait '%s' can not be found in project." % constructor_argument_class_name)
constructor_parameter = _traits_storage.get_trait_instance(object, needed_trait, true)
else:
assert(false, "Trait constructor can not be called")

# Ugly but efficient: there is only one _init method in a script !
break

# Instantiate trait and save it into the object trait instances storage
var trait_instance:Object = a_trait.new(constructor_parameter) if constructor_has_argument else a_trait.new()
_traits_storage.store_trait_instance(object, trait_instance)

# If trait has parent classes, to prevent to create new trait instance if parent classes are asked for this
# object, register this trait instance has the one to be returned when a parent class is asked (POO style)
var parent_script:Script = a_trait.get_base_script()
while(parent_script != null):
_traits_storage.store_trait_instance(object, trait_instance, parent_script)
parent_script = parent_script.get_base_script()

return trait_instance
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified addons/godot-traits/documentation/assets/gtraits_settings.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 12 additions & 0 deletions addons/godot-traits/examples/cyclic-dependency-detection/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Cyclic dependencies detection

This example demonstrates how cyclic dependencies are detected when instantiating traits.

Trait dependencies (constructor parameters) can be auto-instantiate. There exists a corner case where a cyclic
dependency can exist between required dependencies. As a consequence, it's not mpossible to create the object graph.
_Godot Traits_ can detect such cyclic dependencies and warn the developer about them.

## Technical elements

- `main.tscn` : scene to run. The main scene try to add `Cyclic1` trait, but this trait can not be instantiated due to
cyclic depdencies. An assertion error will be raised.
40 changes: 40 additions & 0 deletions addons/godot-traits/examples/cyclic-dependency-detection/main.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
extends Node2D

#------------------------------------------
# Constants
#------------------------------------------

#------------------------------------------
# Signals
#------------------------------------------

#------------------------------------------
# Exports
#------------------------------------------

#------------------------------------------
# Public variables
#------------------------------------------

#------------------------------------------
# Private variables
#------------------------------------------

#------------------------------------------
# Godot override functions
#------------------------------------------

func _ready() -> void:
# By setting this object as a Cyclic1, Gtraits will auto instantiate all its dependencies:
# so Cyclic2, Cyclic3, then Cyclic4. But Cyclic4 requires a Cyclic2 to be instantiate. It's a cyclic
# dependencies and it will raise an assertion error
GTraits.set_cyclic_1(self)

#------------------------------------------
# Public functions
#------------------------------------------

#------------------------------------------
# Private functions
#------------------------------------------

Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3 uid="uid://ckdj32ggju5dr"]

[ext_resource type="Script" path="res://addons/godot-traits/examples/cyclic-dependency-detection/main.gd" id="1_ayw7t"]

[node name="Main" type="Node2D"]
script = ExtResource("1_ayw7t")
Loading