Managing Instance Types with species

Suppose a method—for example, clone()—of your class creates and returns an instance of its own type. Now, suppose you inherit from this class. Automatically a user of your derived class can reuse the clone() method defined in its base class. But when called on an instance of the derived class, should clone() return an object of the base type or the derived type?

In general, when using inheritance, if a method in the base class creates an instance, should that instance be of the base type or the derived type? It’s a mark of an educated mind to say “It depends!”

Sure, it may depend on the context, the application, and maybe several other things. But at the programming level we need to have a way to manage the type of the instance that’s created. We will first take a look at how some built-in classes deal with this and then focus on creating similar behavior for our own code.

Two Built-in Classes, Different Behaviors

Let’s take a look at two classes in JavaScript that implement the same method but with two different behaviors.

 class​ MyString ​extends​ String {}
 class​ MyArray ​extends​ Array {}
 
 const​ concString = ​new​ MyString().concat(​new​ MyString());
 const​ concArray = ​new​ MyArray().concat(​new​ MyArray());
 
 console.log(​`instanceof MyString?: ​${concString ​instanceof​ MyString}​`​);
 console.log(​`instanceof MyArray?: ​${concArray ​instanceof​ MyArray}​`​);

We took two well-known built-in classes in JavaScript and extended from each one of them. The instance concString is an instance obtained by calling the concat() method on an instance of the specialized class MyString. Likewise, the instance concArray is an instance obtained by calling the same method but on the specialized class MyArray. Finally, we examine if these two instances are of the same type as the instance on which the concat() method was called. Here’s the result of that check:

 instanceof MyString?: false
 instanceof MyArray?: true

The concat() method of the String class decided to keep the instance it returns as its own type even though the method was called on a derived instance—how rude. The Array class, on the other hand, is playing nice, making the returned instance the same type as the instance on which the method concat() is called. Curious—how can we implement the behavior like Array’s in our own code? You’ll see how next.

When implementing a method in a base class, we can

  • make the returned instance the same type as the base class
  • make the returned instance the same type as the derived class
  • let the derived class tell us what the type should be

In the code we saw examples of the first two options, with String disregarding the derived type and Array creating an instance of the derived type. Although Array appears to use the second option, in reality it uses the third option. Our derived class MyArray can tell its base class Array what type of instance concat() or other methods of Array should create. You’ll soon learn how to specify the type of instance to use, but first, let’s dig in further to learn how to implement each one of these options.

Sticking to the Base Type

We’ll create a base class and a derived class and learn ways to manage the types of instances the base methods create. Let’s start with a class named Names and a derived SpecializedNames class.

 class​ Names {
 constructor​(...names) {
 this​.names = names;
  }
 }
 
 class​ SpecializedNames ​extends​ Names {
 }

The base class constructor receives a rest parameter of names and stores that into a field named names. The derived class does not have any methods, and its default constructor will faithfully pass parameters to its base class.

Let’s implement a filter1() method in the Names class that will return an instance with only selected names.

 filter1(selector) {
 return​ ​new​ Names(...​this​.names.filter(selector));
 }

The filter1() method receives a function reference, selector, as a parameter; passes that to the filter() method of the names array; and finally creates an instance of Names using the result of the call to filter(). The filter1() method hardcoded the class name in new Names(), and so the instance returned by filter1() will be an instance of Names even if the method is called on the derived instance. Let’s verify this.

 const​ specializedNames = ​new​ SpecializedNames(​'Java'​, ​'C#'​, ​'JavaScript'​);
 
 console.log(specializedNames.filter1(name => name.startsWith(​'Java'​)));

We created an instance specializedNames of SpecializedNames, invoked the filter1() method on that instance, and printed the response from that method. The method of the base class is executed in the context of the derived class. Nevertheless, the instance is of the base type since filter1() hardcoded the type. Let’s verify that’s true from the output:

 Names { names: [ 'Java', 'JavaScript' ] }

The output reveals the type of the instance along with the data it contains. The filter1() method behaves like the concat() method of String—rather inconsiderate of the derived class. It decided to create only an object of the base type, regardless of the runtime type of the instance on which the method is called.

Choosing Based on the Runtime Type

Let’s see how we can play as a good citizen and create an instance based on the runtime type of the object on which a method is called. We’ll do that by implementing a filter2() method.

At runtime, rather than hardcoding the class name, if we want to invoke the constructor of the actual type of this, we have to obtain a reference to the constructor. Thankfully, that’s pretty easy. We already saw how to do that in Implementing a Constructor—when we examined the prototype it showed a property named constructor. To get access to the constructor of an object, fetch its prototype and query for its constructor property. Let’s do that in the filter2() method.

 filter2(selector) {
 const​ ​constructor​ = Reflect.getPrototypeOf(​this​).​constructor​;
 
 return​ ​new​ ​constructor​(...​this​.names.filter(selector));
 }

Unlike the filter1() method that hardcoded the class name, filter2() gets the constructor of this and then does a new on it, passing the filtered data. Even though filter2() is in the Names class, the instance created using this approach will be of the actual context object on which the method is called. Let’s use this method on the instance of specializedNames:

 console.log(specializedNames.filter2(name => name.startsWith(​'Java'​)));

Since the instance on which filter2() is called is of the derived type, the instance returned from filter2() is also of the derived type. Here’s the output to confirm that:

 SpecializedNames { names: [ 'Java', 'JavaScript' ] }

While filter1() assumed the instance should be of a specific type, filter2() is assuming it should be the same type as the context object. A better approach, we may argue, is where the method asks the context object what type the new instance should be. That’s what we’ll do in a filter3() method next.

Configuring the Type

Once we obtain a constructor—remember a class is actually a constructor in JavaScript—we can get static methods and properties from it. We will use a static property named kindHint, a made-up name, to give us a hint for the type of instance to create. If this kindHint property is not in a class, we’ll fall back to use the constructor.

Here’s our filter3() method, which instead of arbitrarily deciding the type of instance to create, will be highly considerate and ask the class for its choice.

 filter3(selector) {
 const​ ​constructor​ =
  Reflect.getPrototypeOf(​this​).​constructor​.kindHint ||
  Reflect.getPrototypeOf(​this​).​constructor​;
 
 return​ ​new​ ​constructor​(...​this​.names.filter(selector));
 }

The method navigates from the context object this to its prototype, then to the constructor, and asks for the kindHint property. In other words, the method is asking for a static property on the context object’s class. The constructor reference then refers to the value returned by the kindHint property if that property is found and the value is not undefined; otherwise, the constructor reference refers to the constructor of the context object. The filter3() method then uses the constructor reference to create an instance.

Let’s use the new filter3() method on the specializedNames instance:

 console.log(specializedNames.filter3(name => name.startsWith(​'Java'​)));

Since we have not implemented the kindHint property in the SpecializedNames class yet, the filter3() method returns an instance of SpecializedNames, as we see here:

 SpecializedNames { names: [ 'Java', 'JavaScript' ] }

Let’s now implement the kindHint property in SpecializedNames.

 class​ SpecializedNames ​extends​ Names {
 static​ ​get​ kindHint() {
 return​ Names;
  }
 }

The static getter for the property kindHint in SpecializedNames returns the constructor for Names; it may return any constructor, including SpecializedNames. Now let’s rerun the code and see what filter3() returns:

 Names { names: [ 'Java', 'JavaScript' ] }

The filter3() method asked the kindHint property of SpecializedNames what type of object it should create. The SpecializedName class decided to hide its details and let the instance created by filter3() be an instance of the base type Names.

That worked, but there’s a catch. The name kindHint is quite arbitrary and not unique. What if a class already has a kindHint method for some other purpose or intent and we decide to extend that class from Names? Interfaces would have solved this issue, but JavaScript doesn’t have interfaces. Recall that Symbol solves issues with a lack of interfaces and uniqueness. Let’s see how Symbol helps here.

Using species

We may create our own symbol, for example, Symbol.for("KINDHINT"), but there’s already a predefined symbol for this purpose in JavaScript—Symbol.species.

Symbol.species is used to convey the constructor to be used to create derived objects. Let’s modify the SpecializedName class to use species instead of kindHint:

 class​ SpecializedNames ​extends​ Names {
 static​ ​get​ [Symbol.species]() {
 return​ Names;
  }
 }

We replaced kindHint with [Symbol.species] and all else is the same in the class. Now, instead of looking for kindHint in the filter3() method, we should look for this special symbol.

 filter3(selector) {
 const​ ​constructor​ =
  Reflect.getPrototypeOf(​this​).​constructor​[Symbol.species] ||
  Reflect.getPrototypeOf(​this​).​constructor​;
 
 return​ ​new​ ​constructor​(...​this​.names.filter(selector));
 }

Here again, the only change we made was to modify constructor.kindHint as constructor[Symbol.species]—all else is the same as before. The output of the code, for a call to filter3() after this change is the same as when we used kindHint.

The Array class uses Symbol.species in a similar way. We saw earlier how the concat() method of Array when called on the instance of the derived class MyArray resulted in an instance of MyArray. We can change that behavior by implementing the Symbol.species property getter in MyArray, like so:

 class​ MyArray ​extends​ Array {
 static​ ​get​ [Symbol.species]() { ​return​ Array; }
 }
 
 const​ concArray = ​new​ MyArray().concat(​new​ MyArray());
 console.log(​`instanceof MyArray?: ​${concArray ​instanceof​ MyArray}​`​);

Before we implemented this property getter, the concat() method of Array when called on an instance of MyArray resulted in an instance of MyArray. Now, since the derived class MyArray has a different wish, the instance is different accordingly, as we see from the output:

 instanceof MyArray?: false

When designing a base class, if you like to decide which instance of derived class to create, then use the Symbol.species to query for that intent. When writing a derived class, if you want to control the type of instance your base class will create, refer to the base class’s documentation to see if it provides a way.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.22.68.49