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

n-api: add napi_define_subclass #36148

Closed

Conversation

gabrielschulhof
Copy link
Contributor

@gabrielschulhof gabrielschulhof commented Nov 17, 2020

Add the ability to define a subclass of a JS class by generating a JS
snippet that uses the extends syntax and calls the native bindings
which are passed in as parameters to the generated function responsible
for defining the subclass.

An example of a generated JS snippet might be:

(function(parent, ctor, getSuperParams, prop0, prop1) {
  'use strict';
  class NativeSubclass extends parent {
    constructor() {
      super(...getSuperParams.apply(null, arguments));
      ctor.apply(this, arguments);
    }
    subMethod() {
      if (!(this instanceof NativeSubclass))
        throw new Error('Illegal invocation');
      return prop0.apply(this, arguments);
    }
    chainableMethod() {
      if (!(this instanceof NativeSubclass))
        throw new Error('Illegal invocation');
      return prop1.apply(this, arguments);
    }
  }

  return NativeSubclass;
})

where ctor, getSuperParams, prop0, and prop1 are native
functions supplied to napi_define_subclass.

Signed-off-by: @gabrielschulhof

Checklist
  • make -j4 test (UNIX), or vcbuild test (Windows) passes
  • tests and/or benchmarks are included
  • documentation is changed or added
  • commit message follows commit guidelines

@gabrielschulhof gabrielschulhof added c++ Issues and PRs that require attention from people who are familiar with C++. node-api Issues and PRs related to the Node-API. labels Nov 17, 2020
@nodejs-github-bot
Copy link
Collaborator

Review requested:

  • @nodejs/n-api

@gabrielschulhof
Copy link
Contributor Author

Recommend reviewing while ignoring whitespace, because at least one large blocks has ended up inside an if-statement.

@gabrielschulhof gabrielschulhof force-pushed the napi-define-subclass branch 4 times, most recently from 9ea6e84 to bbd1bef Compare November 17, 2020 09:28
assert.strictEqual(superItem.chainableMethod('something'), 'something-1');
assert.strictEqual(subItem.chainableMethod('something'), '1-something-1');
assert.strictEqual(superItem.value, 5);
assert.strictEqual(subItem.value, 5);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do any other subclass/superclass interactions come to mind that I should be testing here?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It’s worth verifying that the prototype chains are set up correctly, I think

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@addaleax I added some tests below.

Copy link
Member

@addaleax addaleax left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds a really large amount of complexity for something that is not a hugely common pattern in native code, with lots of implementation details (e.g. the brand checks in the JS code generation) that different use cases may or may not want to have.

This is a really good example of something that should go into userland, imo.

@joyeecheung
Copy link
Member

The idea of having this in N-API sounds good to me, but I'll leave it to others in the N-API team to decide whether the complexity of this particular implementation is worth it in core (or whether we should wait for the engine to implement this instead of working around it ourselves in the N-API)

Add the ability to define a subclass of a JS class by generating a JS
snippet that uses the `extends` syntax and calls the native bindings
which are passed in as parameters to the generated function responsible
for defining the subclass.

An example of a generated JS snippet might be:

```js
(function(parent, ctor, getSuperParams, prop0, prop1) {
  'use strict';
  class NativeSubclass extends parent {
    constructor() {
      super(...getSuperParams.apply(null, arguments));
      ctor.apply(this, arguments);
    }
    subMethod() {
      if (!(this instanceof NativeSubclass))
        throw new Error('Illegal invocation');
      return prop0.apply(this, arguments);
    }
    chainableMethod() {
      if (!(this instanceof NativeSubclass))
        throw new Error('Illegal invocation');
      return prop1.apply(this, arguments);
    }
  }

  return NativeSubclass;
})
```

where `ctor`, `getSuperParams`, `prop0`, and `prop1` are native
functions supplied to `napi_define_subclass`.

Signed-off-by: Gabriel Schulhof <[email protected]>
Refs: nodejs/node-addon-api#229
@gabrielschulhof
Copy link
Contributor Author

@addaleax now with things like EventTarget present in core, this feature may become more sought after: https://gpuweb.github.io/gpuweb/#gpu-device

We also have a precedent for doing things without the engine's assistance at first. In the case of napi_instanceof we used calls into JS and prototype chain diving until, in c81fd7c (v9.0.0), @hashseed re-implemented it using the (then) newly available V8 API.

There's also the aspect of doing inheritance correctly, and that, for the sake of that, it should be done once and (hopefully) well.

Additionally, we've had requests for inheritance before: nodejs/node-addon-api#229.

Don't get me wrong though. I'm also ambivalent about landing this because it doesn't use any internals, and discussion is exactly what I'm hoping for.

@addaleax
Copy link
Member

There's also the aspect of doing inheritance correctly, and that, for the sake of that, it should be done once and (hopefully) well.

Right, I agree with that – and I would argue that for answering specific questions like this, userland modules don’t generally do a worse job than Node.js core.

I’m not saying this shouldn’t exist, I’m just wondering why we wouldn’t want to implement this as a userland library instead. I’ll still try to give this a good review, regardless of where it ends up. :)

@jasnell
Copy link
Member

jasnell commented Nov 17, 2020

@addaleax:

I’m not saying this shouldn’t exist, I’m just wondering why we wouldn’t want to implement this as a userland library instead.

That's a good point. Could this be added as utility in https://github.com/nodejs/node-addon-api instead? I'm all for having this functionality, especially given that I have several scenarios right now that I'm working through that could definitely use this, but it might not need to go into core -- at least not yet.

* `[in] env`: The environment that the API is invoked under.
* `[in] parent_constructor`: Constructor of the parent class.
* `[in] utf8name`: Name of the JavaScript constructor function; this is
not required to be the same as the C++ class name, though it is recommended
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is talking about C++ everywhere, but C is what we mean, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I copied the doc mostly from napi_define_class, updating for the different functionality. I suppose it might be worth updating the doc for napi_define_class to not assume folks write C++ classes. I'd like to place that beyond the scope of this PR though.

#36150

PostProcess(const std::string& key, const napi_property_descriptor* prop) {
if (prop->attributes == napi_default && prop->value == nullptr) return;

post_process += " " + key + ": {\n";
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if key contains characters that are not valid identifiers?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

napi_run_script will return non-napi_ok if compiling the script fails. The caller can deal with that.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but that’s still a bug?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right. We should never concat user-supplied strings into the JS snippet directly, because they can contain weird characters. I changed the code so that key is always [propn], and, if the prop descriptor provides a utf8name instead of a name, it napi_create_string_utf8() first and then passes that as a parameter into the resulting function. So, the JS snippet never actually contains literal strings supplied by the user.

(method == desc->getter) ? "get " + key + "()" :
"set " + key + "(x)") + " {\n"
" if (!(this instanceof " + classname + "))\n"
" throw new Error('Illegal invocation');\n"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we adding this brand check? JS classes don’t do it, and for C++ classes, it’s not necessarily the right thing to do in the presence of prototype manipulation

Copy link
Contributor Author

@gabrielschulhof gabrielschulhof Nov 17, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Classes created using napi_define_class have such a brand check built-in to ensure that internal field counts line up. The engine does this. Check the assert.throws() I added to test_constructor. It also throws for a class created using napi_define_class, which I have not modified (other than to factor out the code that adds static members for reuse with napi_define_subclass).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, the point is that this does not actually ensure that the internal fields are present as expected.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@addaleax in that case I think I need to back it up with type tags and throw the exception if the tag is absent. This also means that I have to likely subclass v8impl::CallbackWrapper to do the check before calling into the add-on.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@addaleax ... or, rather, replace the instanceof check in the JS snippet with a type-tag check on the native side.

Copy link
Contributor Author

@gabrielschulhof gabrielschulhof Nov 17, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@addaleax ... which also means that, for efficiency's sake, it might be best located in core 😉🙂

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think if we’re talking efficiency then generating a new script per subclass is already throwing that out of the window ;)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🙂

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@addaleax actually, unless one generates subclasses in a tight loop, only the startup performance is hit by the need to compile JS code. I guess I was talking about the price for a call.

JS -> v8impl::CallbackWrapper ----> (napi_env env, napi_callback_info info) -> (napi_env env, napi_callback_info info)
                                    type tag check happens here

as would be an external implementation, is more expensive than

JS -> v8impl::CallbackWrapper ----> (napi_env env, napi_callback_info info)
      type tag check happens here

which is how it would be done internally.

assert.strictEqual(superItem.chainableMethod('something'), 'something-1');
assert.strictEqual(subItem.chainableMethod('something'), '1-something-1');
assert.strictEqual(superItem.value, 5);
assert.strictEqual(subItem.value, 5);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It’s worth verifying that the prototype chains are set up correctly, I think

@gabrielschulhof
Copy link
Contributor Author

@jasnell in those scenarios, are you using node-addon-api? The reason I ask is that if we land this in node-addon-api and you want to use plain C, we'd have to land it both as-is, and make available a template <typename T> class Napi::ObjectWrap-like class that would bundle napi_define_subclass, napi_wrap, and napi_unwrap, and would thunk bindings from a signature of napi_value (*) (napi_env, napi_callback_info) to one of Napi::Value (*)(const Napi::CallbackInfo&). This is the abstraction currently surrounding napi_define_class.

In effect, this would re-animate the concept that parts of N-API ship with node-addon-api, whereas the rest is in core. We had a very similar setup (where we ship a complete copy of N-API) in the beginning, when not all maintained versions of Node.js contained N-API and so, when building an addon against those versions, we'd build N-API as a static library and link against that rather than core.

@mhdawson
Copy link
Member

I'll need more time to better understand the balance between keeping N-API lean to avoid problems with keeping it stable versus the benefit. @gabrielschulhof could you give the team an overview in the next meeting?

Since you are "ambivalent" about landing it, I'd initially lean towards waiting until we have "pull/demand" since it does add significant complexity.

@gabrielschulhof
Copy link
Contributor Author

gabrielschulhof commented Nov 18, 2020

@mhdawson the one thing that has changed since nodejs/node-addon-api#229 (comment) is that there is no doubt that any and all engines can implement this functionality, because they can just do what this implementation does, meaning compile a JS snippet to generate a function that then performs the subclassing and plugs in the native bindings. The N-API implementation would need only provide

  • napi_create_function
  • napi_create_string_utf8
  • napi_define_properties
  • napi_get_undefined
  • napi_run_script

in order to implement napi_define_subclass.

There is AFAICT "pull/demand" to have this, both from @jasnell above and from nodejs/node-addon-api#229, the initial comment of which has received 5 👍s, and all of them (AFAICT) from folks outside of the core collaborators. The OP of nodejs/node-addon-api#661 also stumbled upon nodejs/node-addon-api#229 and closed their own issue as a duplicate, so I'd say that adds a 👍 because the OP is not among the 5 who have 👍-ed nodejs/node-addon-api#229.

I expressed ambivalence because I am aware that this is a big addition and so I am prepared to abandon this change, or relocate it to another repo. I wanted to collect responses while having concrete code to look at. I still need to implement the brand check on the native side because, as @addaleax pointed out, instanceof is not immune to prototype manipulation.

@gabrielschulhof
Copy link
Contributor Author

@mhdawson found another person who needs this: nodejs/node-addon-api#246

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
c++ Issues and PRs that require attention from people who are familiar with C++. node-api Issues and PRs related to the Node-API.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants