0

I have the following class 'Grid' extending from Array, with the purpose of implementing a few methods for bidimentional arrays, it currently has no 'constructor' function. For the sake of brevity I'm only showing the offending function: Grid.getPlane which returns a subgrid constructed with parameters.

class Grid extends Array {
//...
    getPlane(width, height, x, y) {
    let myPlane = new Grid; 
    // calculations...
    return myPlane;
    }
//...
}

I have then another class 'Terrain' that extends from this one. This one intended to have some more specific functionality for topography data. The intended functionality would be that whenever I call the 'getPlane' function of an instance of the class 'Terrain', the returned object is of the 'Terrain' class as well (so I can use functions particular to this class). But as you can predict I either use the inherited function declaration from 'Grid' and get a Grid (instead of a Terrain), or overwrite the function, leaving me with ugly duplicated code:

class Terrain extends Grid {
//...
    getPlane(width, height, x, y) {
    let myPlane = new Terrain; 
    // calculations...
    return myPlane;
    }
//...
}

I attempted to use Object.create in but:

let myPlane = Object.create(this.prototype)

Returns undefined. And

let myPlane = Object.create(this.constructor.prototype)

Gives me an object named 'Terrain' that does not behaves like an Array. Is there any way for Object.create to get me an object of the same class as the 'this' object? Or any other way to generate objects with the same class?

Peter Seliger
  • 11,747
  • 3
  • 28
  • 37
apostate
  • 11
  • 2
  • how about ... `getPlane(width, height, x, y) { let myPlane = new this.constructor; /* calculations... */ return myPlane; }`? – Peter Seliger Dec 02 '22 at 11:55

2 Answers2

1

How about making use of an instance's constructor property?

getPlane(width, height, x, y) {
  let myPlane = new this.constructor; 
  // calculations...
  return myPlane;
}

class Grid extends Array {
  // ...
  getPlane(width, height, x, y) {
    let myPlane = new this.constructor; 
    // calculations...
    return myPlane;
  }
  // ...
}

class Terrain extends Grid {
  // ...
}

const myGrid = new Grid;
const myGridTypePlane = myGrid.getPlane();

const myTerrain = new Terrain;
const myTerrainTypePlane = myTerrain.getPlane();

console.log(
  '(myGrid instanceof Grid) ?..',
    (myGrid instanceof Grid),
);
console.log(
  '(myGrid instanceof Terrain) ?..',
    (myGrid instanceof Terrain),
);
console.log(
  '(myGridTypePlane instanceof Grid) ?..',
    (myGridTypePlane instanceof Grid),
);
console.log(
  '(myGridTypePlane instanceof Terrain) ?..',
    (myGridTypePlane instanceof Terrain),
);

console.log(
  '(myTerrain instanceof Grid) ?..',
    (myTerrain instanceof Grid),
);
console.log(
  '(myTerrain instanceof Terrain) ?..',
    (myTerrain instanceof Terrain),
);
console.log(
  '(myTerrainTypePlane instanceof Grid) ?..',
    (myTerrainTypePlane instanceof Grid),
);
console.log(
  '(myTerrainTypePlane instanceof Terrain) ?..',
    (myTerrainTypePlane instanceof Terrain),
);
.as-console-wrapper { min-height: 100%!important; top: 0; }
Peter Seliger
  • 11,747
  • 3
  • 28
  • 37
0

This is a case of:

  1. Methods that do way too much
  2. A lack of calling out to methods of super
  3. Deeply-nested hierarchies being a painful way of writing maintainable systems; multiplied by 4000x in JS

First:

// assume
class A extends Array {
  doX (x) {
    console.log(`A.doX(${x})`);
    return new A();
  }
}

class B extends A {
  doX (x, y) {
    super.doX(x);
    console.log(`B.doX(${x}, ${y})`);
    return new B();
  }
}

class C extends B {
  doX (x, y, z) {
    super.doX(x, y);
    console.log(`C.doX(${x}, ${y}, ${z})`);
    return new C();
  }
}


const c = new C();
c.doX(1, 2, 3);
// "A.doX(1)"
// "B.doX(1, 2)"
// "C.doX(1, 2, 3)"

Now, I should expect to have no problems whatsoever running this code.
I will have a hell of a time actually making use of it, though.

Why? Because I made the decision to overload the method to worry about construction and logic at the same time.

The A class will always return an A, the B class will always return a B, despite the fact that the super call actually returns a completely different object (a new A). The C returns a new C, despite the fact that it's already created an A and a B (but has no reference to A).

So what do you do?

A couple of thoughts:

  • Move the construction of the object way the heck out of the object, and pass it in, instead
  • Separate your data object from your library

So let's try construction separation

class Person {
  static make () { return new Person(); }
  setDetails (name, age) {
    this.name = name;
    this.age = age;
  }
  clone (cfg = this) {
    const person = Person.make();
    person.setDetails(cfg.name, cfg.age);
    return person;
  }
}

class Employee extends Person {
  static make () { return new Employee(); }
  setDetails (name, age, id) {
    super.setDetails(name, age);
    this.id = id;
  }
  clone (cfg = this) {
    const employee = Employee.make();
    employee.setDetails(cfg.name, cfg.age, cfg.id);
    return employee;
  }
}

You'll note that the clone methods are not inherited. They are focused on instantiation. They are basically factories (which is really the realm of a separate object, or a static method, like make|from|of|empty|unit).

Then they are calling setDetails which is an instance method, which is basically doing what the constructor should have done, or what a factory should do, and does have inherited behaviour.

Speaking of DRY, inheritance is sort of a terrible way of staying that way. Just from what I've written, how many lines have been dedicated either to overriding constructors (clone, make), or calling out to parents (super) or even just worrying about extension, just because?

This brings me to a different pattern: libraries, pure functions/methods, and decoration.

If you don't care about the actual "type" (and in raw JS, while helpful in the console, you ought not, because it's useless elsewhere), then you can make happily data-centric objects.
Structs, just like you would see in C, or Go, or Python, or the like.

Then you would be free to write all of the reusable calculations that you could possibly desire on libraries/services, which you use upon those structs (or ideally copies thereof).

class Vector {
  static make2D (x = 0, y = 0) {
    return { x, y };
  }
  static make3D (x = 0, y = 0, z = 0) {
    return { ...Vector.make2D(x, y), z };
  }
  static add2D (v1, v2) {
    return Vector.make2D(v1.x + v2.x, v1.y + v2.y);
  }
}


const vectors = [
  { x:  0, y:  1 },
  { x: 32, y:  8 },
  { x: 10, y: 12 },
  { x:  0, y:  0 },
];

const vectorSum = vectors.reduce(Vector.add2D, Vector.make2D());
vectorSum; // { x: 42, y: 21 }

If you really needed them to be typed, then you could do something like:

class Vector {
  add2D (
    {x: x1, y: y1},
    {x: x2, y: y2}
  ) {
    return Vector2D.of(x1 + x2, y1 + y2);
  }
}

class Vector2D {
  constructor (x, y) {
    return Object.assign(this, { x, y });
  }
  static of (x, y) { return new Vector2D(x, y); }
  static from ({ x, y }) { return Vector2D.of(x, y); }
  static empty () { return Vector2D.of(0, 0); }
}

const vector = vectors
  .map(Vector2D.from)
  .reduce(Vector.add2D, Vector2D.empty());

// Vector2D { x: 42, y: 21 }

You'd have a hard time complaining about your code not being DRY at that point. You could even put 5D vectors into the entry data, and you would get correct 2D vectors out...

Norguard
  • 26,167
  • 5
  • 41
  • 49