preface

Recently, as I was designing an SDK, I wanted to use the way of building blocks to split modules with different functions, so that a class can obtain different functions through the way of mixing when using, so I started the research on JS multiple inheritance.

After the implementation of multiple inheritance, and because of the use of typescript code, found a variety of syntax errors, so step by step in the practice process, found a lot of interesting knowledge about multiple inheritance, so I want to talk about my understanding of multiple inheritance here.


implementation

In javascript, there is no such thing as multiple inheritance, essentially merging the classes that need to be inherited into one, and neither es syntax nor typescirpt has yet implemented it. In order to implement multiple inheritance, we can implement it through chain inheritance or Mixin.

To facilitate class declaration, we need to define a type representing the constructor for subsequent use:

type Constructor<T = Record<string.any> > =new(... args:any[]) => T;
Copy the code
  • Chain inheritance

The logic of chain inheritance is to start from the base class, according to the sequence to inherit one by one, and finally combine back into a class, this class is determined by the outermost layer is the last call of the inheritance function, the source code is as follows:

  class Base  {
    constructor(. args) {
      console.log('base')}baseFn() {
      console.log('base')}}const AExtends = (SuperClass: T)<T extends Constructor> =>
    class extends SuperClass {
      constructor(. args) {
        super(... args)console.log('a ctor')}aFn() {
        console.log('a'); }};const BExtends = (SuperClass: T)<T extends Constructor> =>
    class extends SuperClass {
      constructor(. args) {
        super(... args)console.log('b ctor')}bFn() {
        console.log('b'); }};class Test extends BExtends(AExtends(Base)) {
    constructor() {
      super(a);console.log('test ctor');
      this.aFn();
      this.bFn();
      
      this.test(); The // method does not exist, but typescript does not report errors}}const test = new Test();
  // base
  // a ctor
  // b ctor
  // test ctor
  // a
  // b
Copy the code

As you can see from the output of the above code, the constructor call order is executed from the inside out in a nested order, which looks perfect, doesn’t it?

No, this approach actually complicates the hierarchy of the class’s prototype chain, making it a disaster to trace and change the order of inheritance when there are too many classes to mix in.

At the same time, with typescript, if we accidentally use a nonexistent method, we return an anonymous class with no knowledge of the methods that exist, so no syntax error is triggered and the type is no longer safe.

The solution is to declare the class’s interface on each inherited function

const AExtends = <T extends Constructor>(SuperClass: T): T & Constructor<{ aFn: ()=> void}> => // ...
Copy the code

But then the work we need to do becomes repetitive and tedious, so we can try to do it the following way

  • Mixins hybrid

This approach is a reference to TypeScript mixins. The implementation logic is to copy multiple class prototype methods onto an empty class and return them. The source code is as follows:

  function Mixin<T extends Constructor[] > (. mixins: T) {
    class Mix {}
    function copyProperties(target, source) {
      for (let key of Reflect.ownKeys(source)) {
        // These attributes will affect the inherited base class
        if(key ! = ='constructor'&& key ! = ='prototype'&& key ! = ='name') {
          let desc = Object.getOwnPropertyDescriptor(source, key);
          Object.defineProperty(target, key, desc); }}}for (let mixin of mixins) {
      copyProperties(Mix, mixin); // Copy static properties

      copyProperties(Mix.prototype, mixin.prototype); // Copy the stereotype properties
    }
    return Mix;
  }

  class Base {
    baseFn() {
      console.log('base')}}class A {
    aFn() {
      console.log('a')}}class B {
    bFn() {
      console.log('b')}}class Test extends Mixin(Base.A.B) {
    constructor() {
      super(a);this.aFn();
      this.bFn();
      this.baseFn(); }}const test = new Test();

  // a
  // b
  // base
Copy the code

The resulting inheritance chain is much shorter because there is only a layer of Mix, but this method throws away the constructor of all inherited classes. Also, since Mix is returned, typescript does not recognize properties on the prototype chain. Syntax errors will be raised when using inherited methods. To solve this problem, we need to declare a return type for this function:

  type UnionToIntersection<U> = (U extends any ? (k: U) = > void : never) extends (
    k: infer I
  ) => void
    ? I
    : never;

  function Mixin<T extends Constructor[] > (. mixins: T) :Constructor<UnionToIntersection<InstanceType<T[number] > > >;

  function Mixin<T extends Constructor[] > (. mixins: T) {
    / /...
    return Mix;
  }
Copy the code

First we define an intersection type, combining all classes that need to be inherited from InstanceType to get the InstanceType of the constructor.

The important thing to note here is that the input parameter is of type T, not T[], and T is inherited from Contructor[], so that we can derive each parameter by T[number].

At this point, we can already get a base version mixed in, but if we need to inherit from a class that also inherits from another parent class, as in the following case, we can’t find methods on the parent class

  class Parent {
    pFn() {
      console.log('parent')}}class A extends Parent {
    / /...
  }

  class B extends Parent {
    / /...
  }
  / /...
  class Test extends Mixin(Base.A.B) {
  constructor() {
      super(a);this.aFn();
      this.bFn();
      this.baseFn();

      this.pFn(); // this.pFn is not a function}}Copy the code

To solve this problem, we also need to take an extra step of copying the inherited class when copying the properties,

  function Mixin<T extends Constructor[] > (. mixins: T) {
    / /...
    for (let mixin of mixins) {

      / /...

      copyProperties(Mix.prototype, mixin.prototype.__proto__); // Copy inherited stereotype attributes
    }
    / /...
  }
Copy the code

A b base parent = a b base parent

At this point it looks like we have a more perfect multiple inheritance solution than the first approach looks like, right?

But…

In fact, there are two disadvantages of this scheme:

1. Instance attribute missing

As we can see from the following code, we cannot directly get instance attributes written to the class as equality

class A {
  aProps = 123;
  aFn(){}}Reflect.ownKeys(A) // [ 'constructor', 'aFn' ]
Copy the code

In order to iterate over instance properties, we must define the properties to class’s Prototype before we can copy them to the inherited subclass, and the subclass will fetch only the values defined in Prototype.

The problem is solved, but it means that our properties need to be defined externally, which is not elegant and intuitive.

class B {
  bProps;
  bFn() {}
}
B.prototype.bProps = 123;
Reflect.ownKeys(B) // [ 'constructor', 'bFn', 'bProps' ]
Copy the code

2. Duplicate method override

What does the following code output?

class Base {
  same() {
    console.log('base same')}}class A {
  same() {
    console.log('a same')}}class B {
  same() {
    console.log('b same')}}class Test extends Mixin(Base.A.B) {
  constructor() {
    super(a);this.same(); }}Copy the code

The answer is B same, the same method on the last copy of class overrides the previous one.

We can solve this problem by combining methods with the same name into a single method in the combined order, and then executing each method in turn when the combined method is called. In addition, the execution context also needs to be rewritten with care

function Mixin<T extends Constructor[] > (. mixins: T) {
  / /...
  const mergeDesc = {};
  const allowMergeKeys = ['init'.'same'];
  function copyProperties(target, source) {
    for (let key of Reflect.ownKeys(source)) {
      if(key ! = ='constructor'&& key ! = ='prototype'&& key ! = ='name') {
        let desc = Object.getOwnPropertyDescriptor(source, key);
        if (allowMergeKeys.includes(key as string)) {
          mergeDesc[key] = mergeDesc[key] || [];
          mergeDesc[key].push(desc.value);
        } else {
          Object.defineProperty(target, key, desc); }}}}/ /...
  for (const key in mergeDesc) {
    const fns = mergeDesc[key];
    Object.defineProperty(Mix.prototype, key, {
      configurable: true.enumerable: true.writable: true.value(. args) {
        const context = this;
        fns.forEach(function (fn) { fn.call(context, ... args); }); }}); }/ /...
}
Copy the code

conclusion

Although multiple inheritance and type inference have been implemented, both approaches have their own disadvantages. The first approach resolves constructor and repeated calls, but is unfriendly to typescript type inference. The second approach is more typescript-friendly, but less convenient for multi-level inheritance and extensibility of identical attributes; Therefore, it is hoped that there will be better implementation support for multiple inheritance in future ES.

At the same time, if you have a better way to implement or have questions or suggestions on the above code, welcome to comment, thank you