1

This question is basically the JavaScript equivalent of How do I find the nearest common superclass of two non-interface classes

TL;DR: Given two objects and an extensive inheritance setup, how can I find their most "recent" common class?


Suppose, there is a couple of related classes in the program, and their hierarchy is as follows:

enter image description here

class A {}

class B extends A {}

class C extends B {}

class D extends B {}

class E extends D {}

Given a reference to an instance of C and an instance of E, I want to get a reference to B, because this is their closest common ancestor.

function getClosestCommonAncestorClass(c: C, e: E): typeof B;

Link to TypeScript playground

How do I do that?

Parzh from Ukraine
  • 7,999
  • 3
  • 34
  • 65

3 Answers3

3

This is a tree problem (Lowest common ancestor), where edges are determined by Object.getPrototypeOf.

The idea is to get the paths from the root for the two given nodes, and then compare the entries in those two paths starting at the root. The last node that is the same is the common node. Finally get the constructor of that prototype object, which is the "class":

function getPath(o) {
    const path = [];

    while (o) {
        path.push(o);
        o = Object.getPrototypeOf(o);
    }

    return path.reverse();
}

function getClosestCommonAncestorClass(x, y) {
    const xPath = getPath(x);
    const yPath = getPath(y);
    const steps = Math.min(xPath.length, yPath.length);

    let i = 0;

    for (; i < steps; i++) {
        if (xPath[i] !== yPath[i]) break;
    }

    return xPath[i - 1]?.constructor;
}

class A {}
class B extends A {}
class C extends B {}
class D extends B {}
class E extends D {}

console.log(getClosestCommonAncestorClass(new C(), new E())); // class B
Parzh from Ukraine
  • 7,999
  • 3
  • 34
  • 65
trincot
  • 317,000
  • 35
  • 244
  • 286
  • Since with ES6 `class`es the class objects themselves inherit from each other, you don't even have to pass instances - `getClosestCommonAncestorClass(C, E)` would work as well (if you omit the `.constructor` in the `return` statement) – Bergi Jun 21 '21 at 21:15
  • 2
    Yes, that would work. OP said *"Given a reference to an instance of C and an instance of E"*, so at least the arguments would be instances. – trincot Jun 21 '21 at 21:16
  • The `while` loop is missing a `i < xPath.length && i < yPath.length` condition somewhere. With the current code, passing the same instance twice would lead to an infinite loop. (I haven't figured out the most elegant way to do this myself yet). – Bergi Jun 21 '21 at 21:20
  • 1
    Right! Corrected. – trincot Jun 21 '21 at 21:27
1
  1. Walk each of the prototypes of left and right up the chain and keep track of all visited prototypes.
  2. At each step see if you have already visited this prototype.
  3. At some point there would be a crossover hit upon a prototype that is common to both. Return it.
  4. (edge case) Where there truly are no common prototypes in the chain (most likely because at least one object is created via Object.create(null)) then return null as the common ancestor;

function getClosestCommonAncestorClass(left, right) {
  let protoLeft = Object.getPrototypeOf(left);
  let protoRight = Object.getPrototypeOf(right);
  
  const visited = new Set();
  while (protoLeft !== null || protoRight !== null) {
    if (protoLeft === protoRight)
      return protoLeft?.constructor;

    visited
      .add(protoLeft)
      .add(protoRight);
      
    protoLeft = protoLeft && Object.getPrototypeOf(protoLeft);
    protoRight = protoRight && Object.getPrototypeOf(protoRight);
    
    if (protoLeft !== null && visited.has(protoLeft))
      return protoLeft.constructor;
      
    if (protoRight !== null && visited.has(protoRight))
      return protoRight.constructor;
  }
  
  return null;
}

class A {}
class B extends A {}
class C extends B {}
class D extends C {}
class E extends B {}

const c = new C();
const e = new E();

const closestCommonAncestor = getClosestCommonAncestorClass(c, e);
console.log(closestCommonAncestor.name);
console.assert(closestCommonAncestor.name === "B");

const d = new D();

const closestCommonAncestor2 = getClosestCommonAncestorClass(d, e);
console.log(closestCommonAncestor2.name);
console.assert(closestCommonAncestor2.name === "B");

const closestCommonAncestor3 = getClosestCommonAncestorClass(d, c);
console.log(closestCommonAncestor3.name);
console.assert(closestCommonAncestor3.name === "C");

class Z {};
const z = new Z();

const closestCommonAncestor4 = getClosestCommonAncestorClass(z, d);
console.log(closestCommonAncestor4.name);
console.assert(closestCommonAncestor4 === Object);

const nullObj = Object.create(null);
const closestCommonAncestor5 = getClosestCommonAncestorClass(nullObj, d);
console.log(closestCommonAncestor5);
console.assert(closestCommonAncestor5 === null);
VLAZ
  • 26,331
  • 9
  • 49
  • 67
1

Both @trincot's and @VLAZ's answers have their pros and cons, but I think I have achieved to amalgamate them together.

The approach here would be: for each parent class (closest to furthest) of one object (doesn't matter which one), check if another object is an instance of that class; if that's true, return the class; otherwise if the list is exhausted, return null.

Note, that getParentClassesOf is defined as a generator to avoid creating unused values (further common classes are not needed), but it should be easy enough to implement it as a plain array-returning function if needed.

function * getParentClassesOf(object) {
    let current = object;
    let prototype;

    while (prototype = Object.getPrototypeOf(current)) {
        yield prototype.constructor; // or classes.push(prototype.constructor)

        current = prototype;
    }
}
function getClosestCommonAncestorClass(left, right) {
    for (const parentClass of getParentClassesOf(left))
        if (right instanceof parentClass)
            return parentClass;

    return null;
}

function * getParentClassesOf(object) {
    let current = object;
    let prototype;

    while (prototype = Object.getPrototypeOf(current)) {
        yield prototype.constructor; // or classes.push(prototype.constructor)

        current = prototype;
    }
}

function getClosestCommonAncestorClass(left, right) {
    for (const parentClass of getParentClassesOf(left))
        if (right instanceof parentClass)
            return parentClass;

    return null;
}

// *** 

class A {}
class B extends A {}
class C extends B {}
class D extends B {}
class E extends D {}

const closestCommonAncestor = getClosestCommonAncestorClass(new C(), new E());
console.log(closestCommonAncestor.name);
console.assert(closestCommonAncestor.name === "B");

See more examples

Parzh from Ukraine
  • 7,999
  • 3
  • 34
  • 65
  • 1
    If you wish to shorten the `while` loop body a bit, you can use `while (prototype = Object.getPrototypeOf(current))` which means you can remove anything until the `yield`. The assignment returns the assigned value - that means that when `Object.getPrototypeOf` returns `null` the loop ends automatically: https://tsplay.dev/N5EZdN – VLAZ Jun 22 '21 at 15:25
  • Okay, I didn’t take into account that each time `instanceof` is used, it crawls up object’s prototype chain again and again, which is not ideal – Parzh from Ukraine Jun 30 '21 at 08:29
  • Not ideal but TBH, it shouldn't be THAT expensive. Class hierarchies tend to be relatively shallow. Prototype chains might be longer on average than just regular class inheritance but still wouldn't likely be super long. Also, prototype chains might even be shorter than the average regular class inheritance. Overall, I don't think you should worry about performance here. – VLAZ Jun 30 '21 at 08:59