UNPKG

trusktr-dummy-test-pkg

Version:

JavaScript/TypeScript class inheritance tools.

606 lines 29.8 kB
// TODO // [x] remove the now-unnecessary modes (leave just what was 'es5' mode) // [x] link helpers to each other, making it possible to destructure the arguments to definer functions // [x] let access helper prototype objects extend from Object, otherwise common tools are not available. // [x] accept a function as return value of function definer, to be treated as a class to derive the definition from, so that it can have access to Protected and Private helpers // [x] let the returned class define protected and private getters which return the protected and private definitions. // [x] migrate to builder-js-package so tests can run in the browser, and we can test custom elements // [ ] protected and private static members // [ ] no `any` types // [ ] other TODOs in the code import { copyDescriptors, setDefaultStaticDescriptors, setDefaultPrototypeDescriptors, hasPrototype, } from './utils.js'; import { getFunctionBody, setDescriptor, propertyIsAccessor, getInheritedDescriptor, getInheritedPropertyNames, WeakTwoWayMap, } from './utils.js'; export const staticBlacklist = ['subclass', 'extends', ...Object.getOwnPropertyNames(new Function())]; const publicProtoToProtectedProto = new WeakMap(); const publicProtoToPrivateProto = new WeakMap(); // A two-way map to associate public instances with protected instances. // There is one protected instance per public instance const publicToProtected = new WeakTwoWayMap(); // so we can get the class scope associated with a private instance const privateInstanceToClassScope = new WeakMap(); const brandToPublicPrototypes = new WeakMap(); const brandToProtectedPrototypes = new WeakMap(); const brandToPrivatePrototypes = new WeakMap(); const brandToPublicsPrivates = new WeakMap(); const defaultOptions = { // es5 class inheritance is simple, nice, easy, and robust // There was another mode, but it has been removed mode: 'es5', // false is better for performance, but true will use Function (similar to // eval) to name your class functions in the most accurate way. nativeNaming: false, // similar to ES6 classes: prototypeWritable: false, defaultClassDescriptor: { writable: true, enumerable: false, configurable: true, }, setClassDescriptors: true, }; export class InvalidSuperAccessError extends Error { } export class InvalidAccessError extends Error { } export const Class = createClassHelper(); export function createClassHelper(options) { options = options ? { ...defaultOptions, ...options } : defaultOptions; options.defaultClassDescriptor = { ...defaultOptions.defaultClassDescriptor, ...options.defaultClassDescriptor, }; const { mode, prototypeWritable, setClassDescriptors, nativeNaming } = options; function Class(...args) { let usingStaticSubclassMethod = false; // if called as SomeConstructor.subclass, or bound to SomeConstructor if (typeof this === 'function') usingStaticSubclassMethod = true; // f.e. `Class()`, `Class('Foo')`, `Class('Foo', {...})` , `Class('Foo', // {...}, Brand)`, similar to `class {}`, `class Foo {}`, class Foo // {...}, and class Foo {...} with branding (see comments on classBrand // below regarding positional privacy) if (args.length <= 3) { let name = ''; let definer = null; let classBrand = null; // f.e. `Class('Foo')` if (typeof args[0] === 'string') name = args[0]; // f.e. `Class((pub, prot, priv) => ({ ... }))` else if (typeof args[0] === 'function' || typeof args[0] === 'object') { definer = args[0]; classBrand = args[1]; } // f.e. `Class('Foo', (pub, prot, priv) => ({ ... }))` if (typeof args[1] === 'function' || typeof args[1] === 'object') { definer = args[1]; classBrand = args[2]; } // Make a class in case we wanted to do just `Class()` or // `Class('Foo')`... const Ctor = usingStaticSubclassMethod ? createClass.call(this, name, definer, classBrand) : createClass(name, definer, classBrand); // ...but add the extends helper in case we wanted to do like: // Class().extends(OtherClass, (Public, Protected, Private) => ({ // ... // })) Ctor.extends = function (ParentClass, def, brand) { def = def || definer; brand = brand || classBrand; return createClass.call(ParentClass, name, def, brand); }; return Ctor; } throw new TypeError('invalid args'); } return Class; /** * @param {string} className The name that the class being defined should * have. * @param {Function} definer A function or object for defining the class. * If definer a function, it is passed the Public, Protected, Private, and * Super helpers. Methods and properties can be defined on the helpers * directly. An object containing methods and properties can also be * returned from the function. If definer is an object, the object should * be in the same format as the one returned if definer were a function. */ function createClass(className, definer, classBrand) { 'use strict'; // f.e. ParentClass.subclass((Public, Protected, Private) => {...}) let ParentClass = this; if (typeof className !== 'string') { throw new TypeError(` You must specify a string for the 'className' argument. `); } let definition = null; // f.e. Class('Foo', { ... }) if (definer && typeof definer === 'object') { definition = definer; } // Return early if there's no definition or parent class, just a simple // extension of Object. f.e. when doing just `Class()` or // `Class('Foo')` else if (!ParentClass && (!definer || (typeof definer !== 'function' && typeof definer !== 'object'))) { let Ctor; if (nativeNaming && className) Ctor = new Function(`return function ${className}() {}`)(); else { // force anonymous even in ES6+ Ctor = (() => function () { })(); if (className) setDescriptor(Ctor, 'name', { value: className }); } Ctor.prototype = { __proto__: Object.prototype, constructor: Ctor }; // no static inheritance here, just like with `class Foo {}` setDescriptor(Ctor, 'subclass', { value: Class, writable: true, enumerable: false, configurable: false, }); return Ctor; } // A two-way map to associate public instances with private instances. // Unlike publicToProtected, this is inside here because there is one // private instance per class scope per instance (or to say it another // way, each instance has as many private instances as the number of // classes that the given instance has in its inheritance chain, one // private instance per class) const scopedPublicsToPrivates = classBrand ? void undefined : new WeakTwoWayMap(); if (classBrand) { if (!brandToPublicsPrivates.get(classBrand)) brandToPublicsPrivates.set(classBrand, new WeakTwoWayMap()); } // if no brand provided, then we use the most fine-grained lexical // privacy. Lexical privacy is described at // https://github.com/tc39/proposal-class-fields/issues/60 // // TODO make prototypes non-configurable so that the clasds-brand system // can't be tricked. For now, it's good enough, most people aren't going // to go out of their way to mangle with the prototypes in order to // force invalid private access. classBrand = classBrand || { brand: 'lexical' }; // the class "scope" that we will bind to the helper functions const scope = { className, get publicToPrivate() { return scopedPublicsToPrivates ? scopedPublicsToPrivates : brandToPublicsPrivates.get(classBrand); }, classBrand, // we use these to memoize the Public/Protected/Private access // helper results, to make subsequent accessses faster. cachedPublicAccesses: new WeakMap(), cachedProtectedAccesses: new WeakMap(), cachedPrivateAccesses: new WeakMap(), }; // create the super helper for this class scope const supers = new WeakMap(); const Super = superHelper.bind(null, supers, scope); // bind this class' scope to the helper functions const Public = getPublicMembers.bind(null, scope); const Protected = getProtectedMembers.bind(null, scope); const Private = getPrivateMembers.bind(null, scope); Public.prototype = {}; Protected.prototype = {}; Private.prototype = {}; // alows the user to destructure arguments to definer functions Public.Public = Public; Public.Protected = Protected; Public.Private = Private; Public.Super = Super; Protected.Public = Public; Protected.Protected = Protected; Protected.Private = Private; Protected.Super = Super; // Private and Super are never passed as first argument // pass the helper functions to the user's class definition function definition = definition || (definer && definer(Public, Protected, Private, Super)); // the user has the option of returning an object that defines which // properties are public/protected/private. if (definition && typeof definition !== 'object' && typeof definition !== 'function') { throw new TypeError(` The return value of a class definer function, if any, should be an object, or a class constructor. `); } // if a function was returned, we assume it is a class from which we // get the public definition from. let customClass = null; if (typeof definition === 'function') { customClass = definition; definition = definition.prototype; ParentClass = customClass.prototype.__proto__.constructor; } let staticMembers; // if functions were provided for the public/protected/private // properties of the definition object, execute them with their // respective access helpers, and use the objects returned from them. if (definition) { staticMembers = definition.static; delete definition.static; if (typeof definition.public === 'function') { definition.public = definition.public(Protected, Private); } if (typeof definition.protected === 'function') { definition.protected = definition.protected(Public, Private); } if (typeof definition.private === 'function') { definition.private = definition.private(Public, Protected); } } ParentClass = ParentClass || Object; // extend the parent class const parentPublicPrototype = ParentClass.prototype; const publicPrototype = (definition && definition.public) || definition || Object.create(parentPublicPrototype); if (publicPrototype.__proto__ !== parentPublicPrototype) publicPrototype.__proto__ = parentPublicPrototype; // extend the parent protected prototype const parentProtectedPrototype = getParentProtectedPrototype(parentPublicPrototype); const protectedPrototype = (definition && definition.protected) || Object.create(parentProtectedPrototype); if (protectedPrototype.__proto__ !== parentProtectedPrototype) protectedPrototype.__proto__ = parentProtectedPrototype; publicProtoToProtectedProto.set(publicPrototype, protectedPrototype); // private prototype inherits from parent, but each private instance is // private only for the class of this scope const parentPrivatePrototype = getParentPrivatePrototype(parentPublicPrototype); const privatePrototype = (definition && definition.private) || Object.create(parentPrivatePrototype); if (privatePrototype.__proto__ !== parentPrivatePrototype) privatePrototype.__proto__ = parentPrivatePrototype; publicProtoToPrivateProto.set(publicPrototype, privatePrototype); if (!brandToPublicPrototypes.get(classBrand)) brandToPublicPrototypes.set(classBrand, new Set()); if (!brandToProtectedPrototypes.get(classBrand)) brandToProtectedPrototypes.set(classBrand, new Set()); if (!brandToPrivatePrototypes.get(classBrand)) brandToPrivatePrototypes.set(classBrand, new Set()); brandToPublicPrototypes.get(classBrand).add(publicPrototype); brandToProtectedPrototypes.get(classBrand).add(protectedPrototype); brandToPrivatePrototypes.get(classBrand).add(privatePrototype); scope.publicPrototype = publicPrototype; scope.privatePrototype = privatePrototype; scope.protectedPrototype = protectedPrototype; scope.parentPublicPrototype = parentPublicPrototype; scope.parentProtectedPrototype = parentProtectedPrototype; scope.parentPrivatePrototype = parentPrivatePrototype; // the user has the option of assigning methods and properties to the // helpers that we passed in, to let us know which methods and // properties are public/protected/private so we can assign them onto // the respective prototypes. copyDescriptors(Public.prototype, publicPrototype); copyDescriptors(Protected.prototype, protectedPrototype); copyDescriptors(Private.prototype, privatePrototype); if (definition) { // delete these so we don't expose them on the class' public // prototype delete definition.public; delete definition.protected; delete definition.private; // if a `public` object was also supplied, we treat that as the public // prototype instead of the base definition object, so we copy the // definition's props to the `public` object // // TODO For now we copy from the definition object to the 'public' // object (publicPrototype), but this won't work with native `super`. // Maybe later, we can use a Proxy to read props from both the root // object and the public object, so that `super` works from both. // Another option is to not allow a `public` object, only protected // and private if (definition !== publicPrototype) { // copy whatever remains copyDescriptors(definition, publicPrototype); } } if (customClass) { if (staticMembers) copyDescriptors(staticMembers, customClass); return customClass; } const userConstructor = publicPrototype.hasOwnProperty('constructor') ? publicPrototype.constructor : null; let NewClass = null; let newPrototype = null; // ES5 version (which seems to be so much better) if (mode === 'es5') { NewClass = (() => function () { let ret = null; let constructor = null; if (userConstructor) constructor = userConstructor; else constructor = ParentClass; // Object is a special case because otherwise // `Object.apply(this)` returns a different object and we don't // want to deal with return value in that case if (constructor !== Object) ret = constructor.apply(this, arguments); if (ret && (typeof ret === 'object' || typeof ret === 'function')) { // XXX should we set ret.__proto__ = constructor.prototype // here? Or let the user deal with that? return ret; } return this; })(); newPrototype = publicPrototype; } else { throw new TypeError(` The lowclass "mode" option can only be 'es5' for now. `); } if (className) { if (nativeNaming) { const code = getFunctionBody(NewClass); const proto = NewClass.prototype; NewClass = new Function(` userConstructor, ParentClass `, ` return function ${className}() { ${code} } `)(userConstructor, ParentClass); NewClass.prototype = proto; } else { setDescriptor(NewClass, 'name', { value: className }); } } if (userConstructor && userConstructor.length) { // length is not writable, only configurable, therefore the value // has to be set with a descriptor update setDescriptor(NewClass, 'length', { value: userConstructor.length, }); } // static stuff { // static inheritance NewClass.__proto__ = ParentClass; if (staticMembers) copyDescriptors(staticMembers, NewClass); // allow users to make subclasses. When subclass is called on a // constructor, it defines `this` which is assigned to ParentClass // above. setDescriptor(NewClass, 'subclass', { value: Class, writable: true, enumerable: false, configurable: false, }); // } // prototype stuff { NewClass.prototype = newPrototype; NewClass.prototype.constructor = NewClass; // } if (setClassDescriptors) { setDefaultStaticDescriptors(NewClass, options, staticBlacklist); setDescriptor(NewClass, 'prototype', { writable: prototypeWritable }); setDefaultPrototypeDescriptors(NewClass.prototype, options); setDefaultPrototypeDescriptors(protectedPrototype, options); setDefaultPrototypeDescriptors(privatePrototype, options); } scope.constructor = NewClass; // convenient for debugging return NewClass; } } // XXX PERFORMANCE: instead of doing multiple prototype traversals with // hasPrototype in the following access helpers, maybe we can do a single // traversal and check along the way? // // Worst case examples: // // currently: // If class hierarchy has 20 classes // If we detect which instance we have in order of public, protected, private // If the instance we're checking is the private instance of the middle class (f.e. class 10) // We'll traverse 20 public prototypes with 20 conditional checks // We'll traverse 20 protected prototypes with 20 conditional checks // And finally we'll traverse 10 private prototypes with 10 conditional checks // TOTAL: We traverse over 50 prototypes with 50 conditional checks // // proposed: // If class hierarchy has 20 classes // If we detect which instance we have in order of public, protected, private // If the instance we're checking is the private instance of the middle class (f.e. class 10) // We'll traverse 10 public prototypes with 3 conditional checks at each prototype // TOTAL: We traverse over 10 prototypes with 30 conditional checks // BUT: The conditional checking will involve reading WeakMaps instead of // checking just reference equality. If we can optimize how this part // works, it might be worth it. // // Can the tradeoff (less traversal and conditional checks) outweigh the // heavier conditional checks? // // XXX PERFORMANCE: We can also cache the access-helper results, which requires more memory, // but will make use of access helpers much faster, especially important for // animations. function getParentProtectedPrototype(parentPublicPrototype) { // look up the prototype chain until we find a parent protected prototype, if any. let parentProtectedProto; let currentPublicProto = parentPublicPrototype; while (currentPublicProto && !parentProtectedProto) { parentProtectedProto = publicProtoToProtectedProto.get(currentPublicProto); currentPublicProto = currentPublicProto.__proto__; } // TODO, now that we're finding the nearest parent protected proto, // we might not need to create an empty object for each class if we // don't find one, to avoid prototype lookup depth, as we'll connect // to the nearest one we find, if any. return parentProtectedProto || {}; } function getParentPrivatePrototype(parentPublicPrototype) { // look up the prototype chain until we find a parent protected prototype, if any. let parentPrivateProto; let currentPublicProto = parentPublicPrototype; while (currentPublicProto && !parentPrivateProto) { parentPrivateProto = publicProtoToPrivateProto.get(currentPublicProto); currentPublicProto = currentPublicProto.__proto__; } // TODO, now that we're finding the nearest parent protected proto, // we might not need to create an empty object for each class if we // don't find one, to avoid prototype lookup depth, as we'll connect // to the nearest one we find, if any. return parentPrivateProto || {}; } function getPublicMembers(scope, instance) { let result = scope.cachedPublicAccesses.get(instance); if (result) return result; // check only for the private instance of this class scope if (isPrivateInstance(scope, instance)) scope.cachedPublicAccesses.set(instance, (result = getSubclassScope(instance).publicToPrivate.get(instance))); // check for an instance of the class (or its subclasses) of this scope else if (isProtectedInstance(scope, instance)) scope.cachedPublicAccesses.set(instance, (result = publicToProtected.get(instance))); // otherwise just return whatever was passed in, it's public already! else scope.cachedPublicAccesses.set(instance, (result = instance)); return result; } function getProtectedMembers(scope, instance) { let result = scope.cachedProtectedAccesses.get(instance); if (result) return result; // check for an instance of the class (or its subclasses) of this scope // This allows for example an instance of an Animal base class to access // protected members of an instance of a Dog child class. if (isPublicInstance(scope, instance)) scope.cachedProtectedAccesses.set(instance, (result = publicToProtected.get(instance) || createProtectedInstance(instance))); // check for a private instance inheriting from this class scope else if (isPrivateInstance(scope, instance)) { const publicInstance = getSubclassScope(instance).publicToPrivate.get(instance); scope.cachedProtectedAccesses.set(instance, (result = publicToProtected.get(publicInstance) || createProtectedInstance(publicInstance))); } // return the protected instance if it was passed in else if (isProtectedInstance(scope, instance)) scope.cachedProtectedAccesses.set(instance, (result = instance)); if (!result) throw new InvalidAccessError('invalid access of protected member'); return result; } function getSubclassScope(privateInstance) { return privateInstanceToClassScope.get(privateInstance); } function createProtectedInstance(publicInstance) { // traverse instance proto chain, find first protected prototype const protectedPrototype = findLeafmostProtectedPrototype(publicInstance); // make the protected instance from the found protected prototype const protectedInstance = Object.create(protectedPrototype); publicToProtected.set(publicInstance, protectedInstance); return protectedInstance; } function findLeafmostProtectedPrototype(publicInstance) { let result = null; let currentProto = publicInstance.__proto__; while (currentProto) { result = publicProtoToProtectedProto.get(currentProto); if (result) return result; currentProto = currentProto.__proto__; } return result; } function getPrivateMembers(scope, instance) { let result = scope.cachedPrivateAccesses.get(instance); if (result) return result; // check for a public instance that is or inherits from this class if (isPublicInstance(scope, instance)) scope.cachedPrivateAccesses.set(instance, (result = scope.publicToPrivate.get(instance) || createPrivateInstance(scope, instance))); // check for a protected instance that is or inherits from this class' // protectedPrototype else if (isProtectedInstance(scope, instance)) { const publicInstance = publicToProtected.get(instance); scope.cachedPrivateAccesses.set(instance, (result = scope.publicToPrivate.get(publicInstance) || createPrivateInstance(scope, publicInstance))); } // return the private instance if it was passed in else if (isPrivateInstance(scope, instance)) scope.cachedPrivateAccesses.set(instance, (result = instance)); if (!result) throw new InvalidAccessError('invalid access of private member'); return result; } function createPrivateInstance(scope, publicInstance) { const privateInstance = Object.create(scope.privatePrototype); scope.publicToPrivate.set(publicInstance, privateInstance); privateInstanceToClassScope.set(privateInstance, scope); // TODO use WeakTwoWayMap return privateInstance; } function isPublicInstance(scope, instance, brandedCheck = true) { if (!brandedCheck) return hasPrototype(instance, scope.publicPrototype); for (const proto of Array.from(brandToPublicPrototypes.get(scope.classBrand))) { if (hasPrototype(instance, proto)) return true; } return false; } function isProtectedInstance(scope, instance, brandedCheck = true) { if (!brandedCheck) return hasPrototype(instance, scope.protectedPrototype); for (const proto of Array.from(brandToProtectedPrototypes.get(scope.classBrand))) { if (hasPrototype(instance, proto)) return true; } return false; } function isPrivateInstance(scope, instance, brandedCheck = true) { if (!brandedCheck) return hasPrototype(instance, scope.privatePrototype); for (const proto of Array.from(brandToPrivatePrototypes.get(scope.classBrand))) { if (hasPrototype(instance, proto)) return true; } return false; } function superHelper(supers, scope, instance) { const { parentPublicPrototype, parentProtectedPrototype, parentPrivatePrototype } = scope; if (isPublicInstance(scope, instance, false)) return getSuperHelperObject(instance, parentPublicPrototype, supers); if (isProtectedInstance(scope, instance, false)) return getSuperHelperObject(instance, parentProtectedPrototype, supers); if (isPrivateInstance(scope, instance, false)) return getSuperHelperObject(instance, parentPrivatePrototype, supers); throw new InvalidSuperAccessError('invalid super access'); } function getSuperHelperObject(instance, parentPrototype, supers) { let _super = supers.get(instance); // XXX PERFORMANCE: there's probably some ways to improve speed here using caching if (!_super) { supers.set(instance, (_super = Object.create(parentPrototype))); const keys = getInheritedPropertyNames(parentPrototype); let i = keys.length; while (i--) { const key = keys[i]; setDescriptor(_super, key, { get: function () { let value = void undefined; const descriptor = getInheritedDescriptor(parentPrototype, key); if (descriptor && propertyIsAccessor(descriptor)) { const getter = descriptor.get; if (getter) value = getter.call(instance); } else { value = parentPrototype[key]; } if (value && value.call && typeof value === 'function') { value = value.bind(instance); } return value; }, // like native `super`, setting a super property does nothing. set: function (value) { const descriptor = getInheritedDescriptor(parentPrototype, key); if (descriptor && propertyIsAccessor(descriptor)) { const setter = descriptor.set; if (setter) value = setter.call(instance, value); } else { // just like native `super` instance[key] = value; } }, }, true); } } return _super; } export default Class; //# sourceMappingURL=Class.js.map