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

GNOME's use case for decorators #441

Open
ptomato opened this issue Feb 2, 2022 · 4 comments
Open

GNOME's use case for decorators #441

ptomato opened this issue Feb 2, 2022 · 4 comments
Labels

Comments

@ptomato
Copy link

ptomato commented Feb 2, 2022

Hi, since this proposal has gained some exciting momentum recently, I would like to give a perspective from a potential user of decorators that you probably haven't heard from yet: the GNOME desktop.

(I work for Igalia which has been involved with this proposal in the past, but I guess consider me to be wearing my volunteer GNOME hat for this post)

tl;dr: I believe the current iteration of the proposal addresses all of GNOME's needs! I'd still like to try some stuff out with the experimental transpiler to make sure there's nothing I've missed. But assuming nothing unexpected pops up, I would be overjoyed if the proposal were to move forward in this state.

Read below for a more detailed description of our use case, code examples of how we might use decorators, and for potential opportunities for tweaking to reduce friction.

Use case

GNOME has bindings in many different programming languages, to its platform libraries which are written in C. I maintain the JavaScript binding (GJS). GJS is used extensively in the UI code for the GNOME desktop itself, as well as being the language that some prominent GNOME apps are written in.

These platform libraries are written in "object-oriented C" using an object system called GObject. GObject classes can have virtual methods; typed properties with getter and setter functions; and events ("signals" in GObject terminology). GObject-based libraries are also introspectable, which allows not only creating JS wrappers for GObjects defined in C code, but also for GObjects defined in JS to be passed back into C code and interacted with from there. The C code can call virtual methods implemented in JS, read or write properties that cause JS getters and setters to be called, and emit events that trigger JS event handlers.

The canonical use case for this is writing a custom GUI widget in JavaScript, and making it available to C code by adding it as a child of a container widget that is provided by the GUI toolkit which is written in C. Roughly, class MyWidget { ... }; const w = new MyWidget(); myWindow.add(w); where myWindow is a GUI window object that we obtain from C code.

In order for GObject classes defined in JavaScript to be able to interoperate with C code, a class has to be "registered" with the GObject type system when it is created, passing information about its properties, and signals.

GJS has an API for this, which wraps a class expression with a function, but it is fairly cumbersome compared to the equivalent API in GNOME's Python bindings, which uses decorators for the properties and the signals, and nothing special at all for registration (actually a metaclass, but that's a different story). The Python decorators also allow implementing the property's default value and its notification system automatically.

The verbosity of writing GObject classes in GJS, compared to Python or other languages with GNOME platform bindings, is something that developers using GJS often complain about. (Many developers who use GJS are also familiar with other GNOME platform language bindings, so there's a bit of "if Python can do it, why can't we" going on.) Therefore, we've been eagerly anticipating the possibility to write a concise, decorator-based API for a long time.

Comparison of current API with desired decorator-based API

Currently, the function-based API for creating a GObject class looks like this, showing an example of registration, a property, and a signal:

import GObject from 'gi://GObject';

const MyClass = GObject.registerClass({
    Properties: {
        'flag': GObject.ParamSpec.boolean('flag', 'Short name', 'Long description',
            GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT, false),
    },
    Signals: {
        'event': { param_types: [Number, String] },
    },
}, class MyClass extends GObject.Object {
    #flag = false;

    get flag() {
        this.emit('event', 99, 'Message');
        return this.#flag;
    }

    set flag(value) {
        if (this.#flag === value)
            return;
        this.#flag = value;
        this.notify('flag');
    }
});
// Example usage of the class and its properties and signals:
const myInstance = new MyClass({flag: true});
myInstance.connect('event', (sender, ...args) => print(...args));
myInstance.connect('notify::flag', () => print('flag changed'));
print(myInstance.flag);  // calls the event handler too!
myInstance.flag = false;  // calls the notify handler too!

My understanding is that developers find this cumbersome because all of the metadata is at the top of the class instead of attached to the accessors and event handlers that it pertains to.

In a blog post a few years ago, I discussed some possibilities for a future API based on functions that could later be used as decorators once the proposal was standardized (of which GObject.registerClass() was the first and only one to be implemented.)

Knowing what I know now about the direction the Decorators proposal is taking, I'd say that if we were to implement a decorator-based API in GNOME it would probably look something like this:

import GObject from 'gi://GObject';

@GObject.registerClass
class MyClass extends GObject.Object {
    #flag = false;

    @(GObject.property({
        type: Boolean,
        default: false,
        flags: GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT,
    }))
    get flag() {
        this.emit('event', 99, 'Message');
        return this.#flag;
    }

    set flag(value) {
        this.#flag = value;
    }

    @(GObject.signal({ paramTypes: [Number, String] }))
    event(code, message) {
        // class's default event handler
    }
}

Potential issues

The most important thing for our use case is that the @GObject.registerClass decorator has access to the information about decorated properties and signals, because it needs to provide them to the C API at registration time. I believe this should be possible by using setMetadata and addInitializer, so I don't foresee a problem with this.

Some minor issues that might reduce friction further if they could be tweaked:

It would be ideal if the GObject.property decorator could wrap both the getter and setter for that property, otherwise we'd have to have a mostly-redundant @(GObject.setProperty('flag')) decorator on the setter. This might be able to be solved with accessor decorators, e.g. the above flag property with its customized getter could be implemented like this:

@(GObject.property({
    type: Boolean,
    default: false,
    flags: GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT,
    get(originalGet) {
        this.emit('event', 99, 'Message');
        return originalGet();
    },
})) accessor flag;

But then it's a bit cumbersome that you would have to add the customized getter as an argument to the decorator and chain up to the original getter.

Another minor issue is that the expression after a @ can only be property names chained with . or an expression in parentheses. Since we have parameterized decorators, we'd have to wrap them in parentheses, as I did in the above examples. This might be a gotcha for users used to decorators in Python, but not a big deal ultimately.

Binding of decorated class name

There is an issue open about this: proposal-decorators#211. Until recently this would have been a problem for us because registering a GObject class meant you had to replace the original JS class object with a special object. This meant that for example, trying to call a static method inside a non-static method, with MyClass.staticMethod(...), wouldn't work.

We seem to have recently solved the need to replace the class object, so this may no longer be a problem. However, it remains to be seen whether this causes performance regressions in practice. If that were to happen, GJS might have a use case for this rebinding.

Thanks!

Thanks for considering this. If you have questions about GJS's use case for decorators I'm happy to answer them or join in a decorators call if that would be helpful.

I'm really happy to see momentum on the proposal!

@pzuraq
Copy link
Collaborator

pzuraq commented Feb 3, 2022

Thank you for this detailed write up @ptomato, this is great stuff!

I did want to clarify one point real quick:

Another minor issue is that the expression after a @ can only be property names chained with . or an expression in parentheses. Since we have parameterized decorators, we'd have to wrap them in parentheses, as I did in the above examples. This might be a gotcha for users used to decorators in Python, but not a big deal ultimately.

The restriction is actually that you can have a chained property path ending in a function call, so the following would be valid:

@GObject.property({
    type: Boolean,
    default: false,
    flags: GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT,
    get(originalGet) {
        this.emit('event', 99, 'Message');
        return originalGet();
    },
}) accessor flag;

Any more complex expressions do have to be wrapped in parenthesis, but I believe that the cases for GObject should at least not have an issue here.

Re: the issue about decorating both getter and setter, I agree this is not ideal. My hope is that after this proposal is finalized, we can continue to expand the accessor keyword to allow customizing the getter and setter via the Grouped and Auto-Accessor Proposal. I think this would provide the ideal DX here, and provide a nice way to group getters and setters in general.

@ptomato
Copy link
Author

ptomato commented Feb 3, 2022

Oh, good to hear that, I didn't get from the readme that a function call at the end is allowed. Thanks for clearing that up. It looks like we would not have to use parentheses then, for the GObject decorators that I have in mind.

Looking forward to see how the auto-accessors proposal works out. If the auto-accessor syntax in this proposal goes forward, it seems like a logical next step. In the meantime, I wonder if this proposal might also allow for something like Python's

@property
def myprop(self):
    ...
@myprop.setter
def myprop(self, val):
    ...

but that seems like a detail to be worked out when we figure out exactly how GObject's decorators get implemented.

@trusktr
Copy link
Contributor

trusktr commented Mar 15, 2022

Just a thought, simplifying the GNOME decorators idea:

    //  uses default flags
    @GObjectBoolean(false)
    get flag() {
        this.emit('event', 99, 'Message');
        return this.#flag;
    }
    //...

    //  but customize flags
    @GObjectBoolean(false, GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT)
    get flag() {
        this.emit('event', 99, 'Message');
        return this.#flag;
    }
    //...
    
    @GObjectString("...", ...)
    // ...

For events I like to do the following, and also consider that we don't need to write an accessor, and therefore we don't need to write the default value in the decorator, then now we can write it all on one line:

    // the arg to @emits defaults to 'both', but in your example you emit on get (not an ideal pattern),
    // but for sake of example:
    @emits('get') @GObjectBoolean flag = false // describes the default value using standard syntax

Note that now TypeScript detects the type of flag to be boolean in a simple way. 👍 for TS users.

Also consider dependency-tracking reactivity, with reactive effects, because event patterns for property changes become soup more quickly:

    @reactive @GObjectBoolean flag = false // more on this below

@GObject.registerClass

It is better if decorators are adjectives or nouns, rather than verbs, because they decorate something. F.e.

@GObjectClass
class Foo extends GObject.Object {}

Here is the whole example, showing dep-tracking reactivity (something like Solid.js, MobX, Knockout, Vue Reactivity, Meteor Tracker, or similar known libs in JS world, but here the implementation detail/choice is left out):

@GObjectClass
class Foo extends GObject.Object {
  @reactive @GObjectBoolean flag = false
  @reactive @GObjectString foo = "foo"

  constructor(...) {
    super(...)

    this.createEffect(() => {
      // events are not recommended if dep-tracking reactivity is in place (more below),
      // but for sake of example to match the OP:
      this.emit('propchange:flag', this.flag)
    })
  }

  destroy() { // or whatever cleanup method GObject uses
    super.destroy()
    this.stopEffects()
  }
}

Events are not recommended because dependency-tracking reactivity replaces it. An end user does the following to observe any properties:

const f = new Foo()

createEffect(() => {
  // this re-runs if f.flag or f.foo change
  console.log('flag and foo:', f.flag, f.foo)
})

The event pattern requires more work to wire up, and can be done wrong more easily:

const f = new Foo()

const flagOrFooChage = () => {
  // this re-runs if f.flag or f.foo change
  console.log('flag and foo:', f.flag, f.foo)
}

f.on('propchange:flag', flagOrFooChage)
f.on('propchange:foo', flagOrFooChage)

// Besides more effort in wiring, there's a problem right at this point (more below)

If dep-tracking reactivity were to be a core part of the system, the @reacive decorator could be extended from by the other decorators:

@GObjectClass
class Foo extends GObject.Object {
  @GObjectBoolean flag = false // reactive
  @GObjectString foo = "foo" // reactive

but we might want to make it optional to avoid (the small) overhead (although I've seen that people will want to use the feature for every prop anyway because it is so convenient and perf is usually not a problem, even in 3D games with reactive animation, which is where my specialty is), and in a similar way property events can be optional too (but discouraged):

@GObjectClass
class Foo extends GObject.Object {
  @emits @GObjectBoolean flag = false // emits "propchange:flag"
  @emits @GObjectString foo = "foo" // emits "propchange:foo"

I have much experience with event-emitting prop decorators, and I can say they get messy, and that dep-tracking reactivity makes things easy and clean (happy to answer any questions about it).

One main problem with event-emitting anything is that, if we miss an event, our logic may not fire as expected, especially when code refactoring causes a code order change. With dep-tracking reactivity, we read an initial value (the effect always runs initially), and if it ever changes, it re-runs. It is actually equivalent to this more verbose event code for the end user:

const f = new Foo()

const flagOrFooChage = () => {
  // this re-runs if f.flag or f.foo change
  console.log('flag and foo:', f.flag, f.foo)
  // ...business logic...
}

f.on('propchange:flag', flagOrFooChage)
f.on('propchange:foo', flagOrFooChage)

// Make sure to run it initially too, in case initial state depends on values. A program
// may have worked before adding this line, but a refactor could lead to a bug hunting
// quest that would end with having to add this line to fix a problem:
flagOrFooChage()

We can already see here how event wiring gets more complicated, because if we want to add/remove a prop, we have to remember to add/remove an event registration and handle initial values, whereas with reactive effects code is more terse and succinct.


Anywho, I hope this helps to spark some ideas on making GNOME decorators terse and succinct, however that turns out. This is where decorators truly shine.

@mlundblad
Copy link

I was just thinking about this the other day.
Great to see your proposal already.

I was also thinking about the virtual methods.

Currently implementing a virtual method "foo" in GJS, one would add a method vfunc_foo().

It would be cool if was possible to do something like:

`
@GObject.VirtualMethod
foo() {

}
`

Not sure if it would be feasable to have the decorator rewrite calls to chain up to super's vfunc in this case, so one could do:

@Gobject.VirtualMethod foo() { super.foo(); ... }

Instead of

vfunc_foo() { super.vfunc_foo(); ...

Or maybe it should decorate the call to the super implementation (would that be possible?)

@Gobject.VirtualMethod foo() { @GObject.VirtualCall super.foo(); .... }

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

No branches or pull requests

4 participants