Aaditya Sharma
Aaditya Sharma

Reputation: 3850

Why modifying super.method() in JavaScript fails?

I tried modifying a method of the parent class by accessing it as a property of super. Here I've two questions:

  1. Why modifying super.getTaskCount didn't update the method referenced in parent class?
  2. Why JavaScript didn't give any error while modifying super.getTaskCount? What really happened during the code execution?

Let's Look at the example:

// Parent Class
class Project {
  getTaskCount() {
    return 50;
  }
}

// Child class
class SoftwareProject extends Project {
  getTaskCount() {
    // Let's try to modify "getTaskCount" method of parent class
    super.getTaskCount = function() {
      return 90;
    };
    return super.getTaskCount() + 6;
  }
}

let p = new SoftwareProject();
console.log(p.getTaskCount()); // prints 56. Why not 96?
// Why did super.getTaskCount method remain unchanged?

PS: I know we could use getters and setters for such cases, but I'm trying to learn more about super and it's proper use & limitations.

Upvotes: 5

Views: 850

Answers (2)

Semicolon
Semicolon

Reputation: 7413

On the surface, super seems a lot like this. But it’s a good deal different, and the details are not exactly intuitive. The first hint about its real nature is that the keyword super floating on its own is not valid syntactically.

console.log(this);  // works; `this` refers to a value
console.log(super); // throws a SyntaxError

Instead, SuperCall — super() — is special syntax available in some constructors, and SuperProperty — super.foo or super[foo] — is special syntax available in methods. In neither case can the expression be reduced further to a super part that is independent of its right hand side.

Before we can get into what happens when a SuperProperty is the left hand side of an assignment, we need to look at what evaluating a SuperProperty itself really does.

In ECMA-262, § 12.3.5, the first two cases described correspond to the SuperProperty production and are very similar. You’ll see that the algorithms in both cases begin by retrieving the current this value and end by continuing on to the MakeSuperPropertyReference operation, which we should look at next.

(I’ll be eliding what some of the steps do since we’d be here all day if we walked through everything; instead I want to call attention to the parts that are interesting specifically in relation to your question.)

In MakeSuperPropertyReference, the third step is to retrieve the ‘baseValue’ with env.GetSuperBase(). The ‘env’ here refers to the nearest environment record which has its own ‘this’ binding. An environment record is a spec concept modeling a closure, or scope — it’s not quite the same thing, but close enough to say that for now.

In env.GetSuperBase, there’s a reference to the [[HomeObject]] of the environment record. The double brackets here indicate data stored in association with the spec model. The HomeObject of an environment record is same as the [[HomeObject]] of the corresponding function being called, if one exists (it wouldn’t at the global scope).

What is a function’s HomeObject? When a method is created syntactically (using foo() {} syntax in either an object literal or a class body), the method is associated with the object ‘on’ which it is created — that’s its ‘home object’. For a method in a class body, that means the prototype for normal methods and the constructor for static methods. Unlike this, which is usually totally ‘portable,’ the HomeObject of a method is permanently fixed to a particular value.

The HomeObject is not itself the ‘super object’. Rather, it’s a fixed reference to the object from which to derive the ‘super object’ (base). The actual ‘super object,’ or base, is whatever is the current [[Prototype]] of the HomeObject. Thus, even though [[HomeObject]] is static, the object referred to by super may not be:

class Foo { qux() { return 0; } }
class Baz { qux() { return 1; } }
class Bar extends Foo { qux() { return super.qux(); } }

console.log(new Bar().qux());
// 0

console.log(Bar.prototype.qux.call({}));
// also 0! the [[HomeObject]] is still Bar.prototype

// However ...

Object.setPrototypeOf(Bar.prototype, Baz.prototype);

console.log(new Bar().qux());
// 1 — Bar.prototype[[Prototype]] changed, so GetSuperBase resolved a different base

So now we have a little extra insight into what the ‘super’ in ‘super.getTaskCount’ is all about, but it’s still not clear why assigning to it fails. If we peek back at MakeSuperPropertyReference now, we’ll get our next clue from the last step:

“Return a value of type Reference that is a Super Reference whose base value component is bv [ed. the base value], whose referenced name component is propertyKey, whose thisValue component is actualThis [ed. the current this], and whose strict reference flag is strict.”

There are two interesting things here. One is that it indicates ‘Super Reference’ is a special kind of reference, and the other is ... that ‘Reference’ can be a return type at all! JavaScript has no reified ‘references’, only values, so what gives?

References do exist as a spec concept, but they are only a spec concept. The reference is never a reified value which is ‘touchable’ from JavaScript, it is instead a transient part of evaluating something else. To understand why these sorts of reference values exist within the spec, consider the following statement:

var foo = 2;
delete foo;

In the delete expression, which ‘undeclares’ the variable ‘foo’, it’s pretty apparent that the right hand side (foo) is acting as a reference to the binding itself rather than as the value 2. Compare console.log(foo), where, as always as observed from JS code, foo ‘is’ 2. Similarly, when we perform assignment, the left hand side of bar.baz = 3 is a reference to the property baz of the value bar, and in bar = 3, the LHS is a reference to the binding (variable name) bar of the current environment record (scope).

I said I was gonna try to avoid going too deep on any single rabbit hole here, but I’m failing! ... my point is mainly that the SuperReference is not a final return value ­- it can never be directly observed by ES code.

Our Super Reference would look something like this, if modeled in JS:

const superRef = {
  base: Object.getPrototypeOf(SoftwareProject.prototype),
  referencedName: 'getTaskCount',
  thisValue: p
};

So, can we assign to it? Let’s look at what happens when evaluating a normal assignment to find out.

In this operation, we satisfy the first condition (the SuperProperty is not an ObjectLiteral or ArrayLiteral), so we proceed to the substeps that follow. The SuperProperty is evaluated, so lref is now a Reference of type Super Reference. Knowing that rval is the evaluated value of the right hand side, we can skip to step 1.e.: PutValue(lref, rval).

PutValue begins by exiting early if an error occurred, and exiting early also if the lref value (here called V) is not a Reference (think e.g. 2 = 7 — ReferenceError). In step 4, base is set to GetBase(V), which, because this is a Super Reference, is once again the [[Prototype]] of the prototype corresponding to the class body within which the method was created. We can skip step 5; the reference is resolvable (e.g. it is not an undeclared variable name). A SuperProperty does satisfy HasPropertyReference, so we continue into the substeps of step 6. The base is an object, not a primitive, so we skip 6.a. And then it happens! 6.b — the assignment.

b. Let succeeded be ? base.[[Set]](GetReferencedName(V), W, GetThisValue(V)).

Well, sorta anyway. The journey is not complete.

We can translate that now for super.getTaskCount = function() {} in your example. The base will be Project.prototype. GetReferenceName(V) will evaluate to the string “getTaskCount”. W will evaluate to the function on the righthand side. GetThisValue(V) will be the same as this, the current instance of SoftwareProject. That just leaves knowing what base[[Set]]() does.

When we see a ‘method call’ in brackets like that, it’s a reference to a well-known internal operation whose implementation varies depending on the nature of the object (but is usually the same). In our case, base is an Ordinary Object, so it’s Ordinary Object [[set]]. This in turn calls OrdinarySet which calls OrdinarySetWithOwnDescriptor. In here we’d hit step 3.d.iv and our journey ends ... with a ... successful assignment!?

Remember that this being passed down? That’s the target for the assignment, not the super base. This is not unique to SuperProperty though; it’s also true for, for example, accessors:

const foo = {
  set bar(value) {
    console.log(this, value);
  }
};

const descendent = Object.create(foo);

descendent.baz = 7;
descendent.bar = 8;

// console logs { baz: 7 }, 8

The accessor there is invoked with the descendent instance as its receiver, and super properties are just like that. Let’s make one small tweak to your example and see:

// Parent Class
class Project {
  getTaskCount() {
    return 50;
  }
}

// Child class
class SoftwareProject extends Project {
  getTaskCount() {
    super.getTaskCount = function() {
      return 90;
    };
    return this.getTaskCount() + 6;
  }
}

let p = new SoftwareProject();
console.log(p.getTaskCount());

// 96 — because we actually assigned the new function on `this`

This is a fantastic question — stay curious.

tl;dr: super in a SuperProperty ‘is’ this, but with all property lookups starting from the prototype of the prototype of the class on which the method was originally defined (or the prototype of the constructor, if the method is static). But assignment isn’t looking up a value, it’s setting one, and in this particular example, super.getTaskCount = x is interchangeable with this.getTaskCount = x.

Upvotes: 8

Gabriel Tong
Gabriel Tong

Reputation: 206

override super method is not good design, but if you really want to change, you can do it this way

class Project {
      getTaskCount() {
        return 50;
      }
    }
    
    // Child class
    class SoftwareProject extends Project {
      getTaskCount() {
        // Let's try to modify "getTaskCount" method of parent class
        let getTaskCount = Project.prototype;
        Project.prototype.getTaskCount = function() {
          return 90;
        };
        let count = super.getTaskCount() + 6;
        Project.prototype.getTaskCount = getTaskCount;
        return count;
      }
    }
    
    let p = new SoftwareProject();
    console.log(p.getTaskCount());

Upvotes: 2

Related Questions