πŸ“œ ⬆️ ⬇️

Expressive JavaScript: The Secret Life of Objects

Content




The problem with object-oriented languages ​​is that they carry with them all their implicit surroundings. You needed a banana - and you get a gorilla with a banana, and the whole jungle in addition.

Joe Armstrong, in an interview with Coders at Work
')
The term "object" in programming is heavily overloaded with values. In my profession, objects are lifestyle, the theme of holy wars and a favorite spell that does not lose its magical power.

To a stranger, all this is not clear. Let's start with a brief history of objects as a concept in programming.

Story


This story, like most programming stories, begins with a problem of complexity. One of the ideas says that complexity can be made manageable by dividing it into small parts isolated from each other. These parts began to be called objects.

The object is a hard shell that hides the sticky complexity inside, and instead offers us several setting knobs and contacts (like methods) representing the interface through which the object should be used. The idea is that the interface is relatively simple, and when working with it allows you to ignore all the complex processes occurring inside the object.

A simple interface can hide a lot of things.

For example, imagine an object that provides an interface to a screen section. With it, you can draw shapes or display text on this area, but all the details regarding the transformation of the text or shapes into pixels are hidden. You have a set of methods, for example, drawCircle, and that's all you need to know to use such an object.

Such ideas were developed in the 70-80s, and in the 90s they were brought to the surface by an advertising wave - the revolution of object-oriented programming. Suddenly, a large clan of people declared that objects were the right way to program. And everything that has no objects, is an outdated nonsense.

Such fanaticism always leads to a pile of useless nonsense, and since then something like a counter-revolution has been going on. In some circles, objects generally have an extremely bad reputation.

I prefer to consider them from a practical rather than an ideological point of view. There are several useful ideas, in particular, encapsulation (the difference between internal complexity and external simplicity) that have been popularized by object-oriented culture. They are worth studying.

This chapter describes a rather eccentric approach to JavaScript objects, and how they relate to the classical object-oriented techniques.

Methods


Methods - properties containing functions. Simple method:

var rabbit = {}; rabbit.speak = function(line) { console.log("  '" + line + "'"); }; rabbit.speak(" ."); // β†’   ' .' 


Usually the method has to do something with the object through which it was called. When a function is called as a method β€” as a property of an object, for example object.method () β€”a special variable in its body will indicate the object that called it.

 function speak(line) { console.log(" " + this.type + "   '" + line + "'"); } var whiteRabbit = {type: "", speak: speak}; var fatRabbit = {type: "", speak: speak}; whiteRabbit.speak("   , " + "   !"); // β†’     '    ,    !' fatRabbit.speak("   ."); // β†’     '    .' 


The code uses the keyword this to display the type of talking rabbit.

Recall that the apply and bind methods take the first argument that can be used to emulate a method call. This first argument just gives the value of the variable this.

There is a method similar to apply called call. It also calls a function whose method it is, only accepts arguments as usual, and not as an array. Like apply and bind, in the call you can pass the value of this.

 speak.apply(fatRabbit, ["!"]); // β†’     ' !' speak.call({type: ""}, " , ."); // β†’     ', .' 


Prototypes


Watch your hands.

 var empty = {}; console.log(empty.toString); // β†’ function toString(){…} console.log(empty.toString()); // β†’ [object Object] 


I got the property of an empty object. Magic!

Well, not magic, of course. I simply did not tell everything about how objects work in JavaScript. In addition to the property set, almost everyone also has a prototype. A prototype is another object that is used as a spare source of properties. When an object receives a request for a property that it does not have, this property is searched for in its prototype, then in the prototype prototype, and so on.

Well, who is the prototype of an empty object? This is the great ancestor of all objects, Object.prototype.

 console.log(Object.getPrototypeOf({}) == Object.prototype); // β†’ true console.log(Object.getPrototypeOf(Object.prototype)); // β†’ null 


As you might expect, the Object.getPrototypeOf function returns the prototype of the object.

Prototypical relationships in JavaScript look like a tree, and at its root is the Object.prototype. It provides several methods that appear on all objects, such as toString, which converts an object into a string view.

The prototype of many objects is not the Object.prototype itself, but some other object that provides its default properties. Functions derive from Function.prototype, arrays from Array.prototype.

 console.log(Object.getPrototypeOf(isNaN) == Function.prototype); // β†’ true console.log(Object.getPrototypeOf([]) == Array.prototype); // β†’ true 


Such prototypes will have their own prototype - often an Object.prototype, so it still, though not directly, provides them with methods of the type toString.

The Object.getPrototypeOf function returns a prototype object. You can use Object.create to create objects with a given prototype.

 var protoRabbit = { speak: function(line) { console.log(" " + this.type + "   '" + line + "'"); } }; var killerRabbit = Object.create(protoRabbit); killerRabbit.type = ""; killerRabbit.speak("!"); // β†’     ' !' 


The proto-rabbit works as a container of properties that all rabbits have. A specific rabbit object, for example, deadly, contains properties that are applicable only to it β€” for example, its type β€” and inherits properties from the prototype that are shared with others.

Constructors


A more convenient way to create objects inherited from a certain prototype is the constructor. In JavaScript, calling a function with the preceding new keyword causes the function to act as a constructor. The constructor will have the variable this attached to the newly created object, and if it does not directly return another value containing the object, this new object will be returned instead.

An object created with new is said to be an instance of a constructor.

Here is a simple designer of rabbits. The names of the designers decided to start with a capital letter to distinguish them from other functions.

 function Rabbit(type) { this.type = type; } var killerRabbit = new Rabbit(""); var blackRabbit = new Rabbit(""); console.log(blackRabbit.type); // β†’  


Constructors (and, in fact, all functions) automatically receive a property called prototype, which by default contains a simple and empty object, derived from Object.prototype. Each instance created by this constructor will have this object as a prototype. Therefore, to add the speak method to rabbits created by the Rabbit constructor, we can simply do this:

 Rabbit.prototype.speak = function(line) { console.log(" " + this.type + "   '" + line + "'"); }; blackRabbit.speak(" ..."); // β†’     '  ...' 


It is important to note the difference between how a prototype is associated with a constructor (through the prototype property) and how objects have a prototype (which can be obtained through Object.getPrototypeOf). In fact, the prototype of the constructor is Function.prototype, since constructors are functions. His prototype property will be a prototype of instances created by it, but not its prototype.

Reloading Inherited Properties


When you add a property to an object, whether it is in the prototype or not, it is added directly to the object itself. Now it is his property. If in the prototype there is a property of the same name, it no longer affects the object. The prototype itself does not change.

 Rabbit.prototype.teeth = ""; console.log(killerRabbit.teeth); // β†’  killerRabbit.teeth = ",    "; console.log(killerRabbit.teeth); // β†’ ,    console.log(blackRabbit.teeth); // β†’  console.log(Rabbit.prototype.teeth); // β†’  


The diagram shows the situation after the code is run. The Rabbit and Object prototypes are behind the killerRabbit in the manner of the background, and they can be queried for properties that the object itself does not have.

Reloading the properties that exist in a prototype often benefits. As in the example with the rabbit's teeth, it can be used to express some exceptional characteristics of more general properties, while ordinary objects simply use standard values ​​taken from prototypes.

It is also used to assign different toString methods to functions and arrays.

 console.log(Array.prototype.toString == Object.prototype.toString); // β†’ false console.log([1, 2].toString()); // β†’ 1,2 


Calling the toString of the array displays a result similar to .join (",") - the list is separated by commas. Calling Object.prototype.toString directly for an array results in a different result. This function does not know anything about arrays:

 console.log(Object.prototype.toString.call([1, 2])); // β†’ [object Array] 


Unwanted prototype interaction


The prototype helps to add new properties and methods to all objects that are based on it at any time. For example, our rabbits may need a dance.

 Rabbit.prototype.dance = function() { console.log(" " + this.type + "   ."); }; killerRabbit.dance(); // β†’     . 


It's comfortable. But in some cases this leads to problems. In previous chapters, we used an object as a way to associate values ​​with names β€” we created properties for these names, and gave them the appropriate values. Here is an example from chapter 4:

 var map = {}; function storePhi(event, phi) { map[event] = phi; } storePhi("", 0.069); storePhi(" ", -0.081); 


We can iterate all the phi values ​​in an object through a for / in loop, and check for the presence in it of a name through the in operator. Unfortunately, we are hampered by the prototype object.

 Object.prototype.nonsense = ""; for (var name in map) console.log(name); // β†’  // β†’   // β†’ nonsense console.log("nonsense" in map); // β†’ true console.log("toString" in map); // β†’ true //    delete Object.prototype.nonsense; 


This is wrong. There is no event called β€œnonsense”. And the more so there is no event called β€œtoString”.

It's interesting that toString did not get out in the for / in loop, although the in operator returns true to its account. This is because JavaScript distinguishes between countable and uncountable properties.

All properties that we create by assigning them a value are countable. All standard properties in Object.prototype are uncountable, so they do not crawl out in for / in loops.

We can declare our uncountable properties through the Object.defineProperty function, which allows us to specify the type of property being created.

 Object.defineProperty(Object.prototype, "hiddenNonsense", {enumerable: false, value: ""}); for (var name in map) console.log(name); // β†’  // β†’   console.log(map.hiddenNonsense); // β†’  


Now the property is there, but in the cycle it does not crawl out. Good. But we are still hampered by the problem with the in operator, which states that the Object.prototype properties are present in our object. To do this, we need the hasOwnProperty method.

 console.log(map.hasOwnProperty("toString")); // β†’ false 


He says whether a property is a property of an object, without looking at the prototypes. This is often more useful information than the in operator gives.

If you are worried that someone else whose code you have loaded into your program has spoiled the main prototype of objects, I recommend writing for / in loops like this:

 for (var name in map) { if (map.hasOwnProperty(name)) { // ...     } } 


Objects without prototypes


But the rabbit hole does not end there. And if someone registered the name hasOwnProperty in the map object and assigned the value 42 to it? Now the call to map.hasOwnProperty refers to a local property that contains a number, not a function.

In this case, the prototypes only interfere, and we would like to have objects without any prototypes at all. We have seen the Object.create function that allows you to create an object with a given prototype. We can pass null for the prototype to create a fresh object without a prototype. This is what we need for objects of type map, where there can be any properties.

 var map = Object.create(null); map[""] = 0.069; console.log("toString" in map); // β†’ false console.log("" in map); // β†’ true 


That's better! We no longer need the hasOwnProperty pribluda, because all the properties of the object are set by us personally. We calmly use for / in loops without regard to what people did with Object.prototype

Polymorphism


When you call a String function that converts a value to a string, for an object, it will call the toString method to create a meaningful string. I mentioned that some standard prototypes declare their versions of toString to create strings more useful than just "[object Object]".

This is a simple example of a powerful idea. When a piece of code is written to work with objects through a specific interface β€” in our case, through the toString method β€” any object that supports this interface can be connected to the code β€” and everything will just work.

This technique is called polymorphism - although no one changes its shape. Polymorphic code can work with values ​​of various forms, as long as they support the same interface.

Formatting the table


Let's look at an example in order to understand how polymorphism looks, and object-oriented programming in general. The project is as follows: we will write a program that receives an array of arrays from table cells, and builds a row containing a beautifully formatted table. That is, the columns and rows are aligned. Like this:

 name height country ------------ ------ ------------- Kilimanjaro 5895 Tanzania Everest 8848 Nepal Mount Fuji 3776 Japan Mont Blanc 4808 Italy/France Vaalserberg 323 Netherlands Denali 6168 United States Popocatepetl 5465 Mexico 


It will work like this: the main function will ask each cell how wide and height it is, and then it uses this information to determine the width of the columns and the height of the rows. Then she asks the cell to draw herself, and collects the results in one row.

The program will communicate with the objects of the cells through a well-defined interface. Cell types will not be hard coded. We will be able to add new cell styles - for example, underlined cells in the header. And if they support our interface, they will just work, without any changes in the program.
Interface:

minHeight () returns a number indicating the minimum height the cell requires (expressed in lines)

minWidth () returns a number indicating the minimum width that the cell requires (expressed in characters)

draw (width, height) returns an array of length height, containing sets of strings, each with a width of characters. This is the contents of the cell.

I will use higher order functions because they are very relevant here.

The first part of the program calculates arrays of minimum widths of columns and row heights for the matrix of cells. The rows variable will contain an array of arrays, where each internal array is a row of cells.

 function rowHeights(rows) { return rows.map(function(row) { return row.reduce(function(max, cell) { return Math.max(max, cell.minHeight()); }, 0); }); } function colWidths(rows) { return rows[0].map(function(_, i) { return rows.reduce(function(max, row) { return Math.max(max, row[i].minWidth()); }, 0); }); } 


Using a variable whose name begins with (or consists entirely of) underscores (_), we show the person who will read the code that this argument will not be used.

The rowHeights function should be straightforward. It uses reduce to count the maximum height of the array of cells, and wraps it in map to pass through all the rows in the rows array.

The situation with colWidths is more complicated, because the external array is an array of rows, not columns. I forgot to mention that map (like forEach, filter and similar array methods) passes the second argument to the specified function β€” the index of the current element. Passing with the map elements of the first row and using only the second argument of the function, colWidths builds an array with one element for each index of the column. The call to reduce goes through the external rows array for each index, and selects the width of the widest cell in that index.

The code for the output table:

 function drawTable(rows) { var heights = rowHeights(rows); var widths = colWidths(rows); function drawLine(blocks, lineNo) { return blocks.map(function(block) { return block[lineNo]; }).join(" "); } function drawRow(row, rowNum) { var blocks = row.map(function(cell, colNum) { return cell.draw(widths[colNum], heights[rowNum]); }); return blocks[0].map(function(_, lineNo) { return drawLine(blocks, lineNo); }).join("\n"); } return rows.map(drawRow).join("\n"); } 


The drawTable function uses the internal drawRow function to draw all the rows, and connects them via newline characters.

The drawRow function first turns the row cell objects into blocks, which are arrays of strings representing the contents of the cells, separated by lines. A single cell containing the number 3776 can be represented by an array of one element [β€œ3776”], and an underlined cell can take two lines and look like an array of [β€œname”, β€œ----”].

Blocks for rows that have the same height should be displayed next to each other. The second map call in drawRow builds this line of output line by line, starting with the lines of the leftmost block, and then for each of them, complementing the line to the full width of the table. These lines are then connected via a newline character, creating a whole row that drawRow returns.

The drawLine function fetches lines that should appear next to each other from an array of blocks, and connects them separated by spaces to create a one character gap between the columns of the table.

Let's write a constructor for cells that contain text that provides an interface for cells. It breaks a string into an array of strings using the split method, which cuts the string every time it finds its argument, and returns an array of these chunks. The minWidth method finds the maximum line width in the array.

 function repeat(string, times) { var result = ""; for (var i = 0; i < times; i++) result += string; return result; } function TextCell(text) { this.text = text.split("\n"); } TextCell.prototype.minWidth = function() { return this.text.reduce(function(width, line) { return Math.max(width, line.length); }, 0); }; TextCell.prototype.minHeight = function() { return this.text.length; }; TextCell.prototype.draw = function(width, height) { var result = []; for (var i = 0; i < height; i++) { var line = this.text[i] || ""; result.push(line + repeat(" ", width - line.length)); } return result; }; 


The auxiliary function is used repeat, which builds a line with a given value, repeated a specified number of times. The draw method uses it to indent lines so that they all have the required length.

Let's draw for experience a 5x5 chessboard.

 var rows = []; for (var i = 0; i < 5; i++) { var row = []; for (var j = 0; j < 5; j++) { if ((j + i) % 2 == 0) row.push(new TextCell("##")); else row.push(new TextCell(" ")); } rows.push(row); } console.log(drawTable(rows)); // β†’ ## ## ## // ## ## // ## ## ## // ## ## // ## ## ## 


Works! But since all cells have the same size, the table formatting code does not do anything interesting.

The source data for the table of mountains that we are building is contained in the MOUNTAINS variable, you can download it here .

We need to select the top row containing the column names with an underscore. No problem - we just set the type of cell that does this.

 function UnderlinedCell(inner) { this.inner = inner; }; UnderlinedCell.prototype.minWidth = function() { return this.inner.minWidth(); }; UnderlinedCell.prototype.minHeight = function() { return this.inner.minHeight() + 1; }; UnderlinedCell.prototype.draw = function(width, height) { return this.inner.draw(width, height - 1) .concat([repeat("-", width)]); }; 


The underlined cell contains another cell. It returns the same size as the inner cell (via calls to its minWidth and minHeight methods), but adds one to the height because of the space occupied by the dashes.

Drawing it is simple - we take the contents of the inner cell and add one line filled with dashes.

Now that we have the main engine, we can write a function that builds a grid of cells from our data set.

 function dataTable(data) { var keys = Object.keys(data[0]); var headers = keys.map(function(name) { return new UnderlinedCell(new TextCell(name)); }); var body = data.map(function(row) { return keys.map(function(name) { return new TextCell(String(row[name])); }); }); return [headers].concat(body); } console.log(drawTable(dataTable(MOUNTAINS))); // β†’ name height country // ------------ ------ ------------- // Kilimanjaro 5895 Tanzania // …    


The standard Object.keys function returns an array of object property names. The top row of the table should contain underlined cells with column names. The values ​​of all objects from the dataset look like normal cells under the heading - we extract them by passing the map function through the keys array to be sure that the same cell order is stored in each row.

The summary table resembles the table from the example, only the numbers are not aligned to the right. We will deal with this later.

Getters and Setters


When creating an interface, you can enter properties that are not methods. We could just define minHeight and minWidth as variables for storing numbers. But this would require us to write code to calculate their values ​​in the constructor β€” which is bad, since the construction of the object is not directly related to them. There could be problems when, for example, an internal cell or an underlined cell changes - and then their size must also change.

These considerations have led to the fact that properties that are not methods, many do not include in the interface. Instead of directly accessing property values, methods like getSomething and setSomething are used to read and write property values. But there is also a minus - you have to write (and read) a lot of additional methods.

Fortunately, JavaScript gives us a technique that uses the best of both approaches. We can set properties that look ordinary from the outside, but secretly have methods associated with them.

 var pile = { elements: ["", "", ""], get height() { return this.elements.length; }, set height(value) { console.log("    ", value); } }; console.log(pile.height); // β†’ 3 pile.height = 100; // β†’     100 


In the declaration of an object, a get or set entry allows you to specify a function that will be called when a property is read or written. You can also add such a property to an existing object, for example, to prototype, using the Object.defineProperty function (we used it before, creating uncountable properties).

 Object.defineProperty(TextCell.prototype, "heightProp", { get: function() { return this.text.length; } }); var cell = new TextCell("\n"); console.log(cell.heightProp); // β†’ 2 cell.heightProp = 100; console.log(cell.heightProp); // β†’ 2 


You can also set the property set in the object passed to defineProperty to set the setter method. When there is a getter, and there is no setter, the attempt to write to the property is simply ignored.

Inheritance


But we have not finished our table formatting exercise. It would be more convenient to read it if the numeric column were aligned to the right. We need to create another type of cell like TextCell, but instead of adding text with spaces on the right, it complements it on the left to align it with the right edge.

We could write a new constructor with all three methods in the prototype. But prototypes themselves can have prototypes, and therefore we can do smarter.

 function RTextCell(text) { TextCell.call(this, text); } RTextCell.prototype = Object.create(TextCell.prototype); RTextCell.prototype.draw = function(width, height) { var result = []; for (var i = 0; i < height; i++) { var line = this.text[i] || ""; result.push(repeat(" ", width - line.length) + line); } return result; }; 


minHeight minWidth TextCell. RTextCell TextCell, , draw .

. - , . ( call, ). , , , . , . , , .

If we slightly edit the dataTable function so that it uses RTextCells for the numerical cells, we will get the table we need.

 function dataTable(data) { var keys = Object.keys(data[0]); var headers = keys.map(function(name) { return new UnderlinedCell(new TextCell(name)); }); var body = data.map(function(row) { return keys.map(function(name) { var value = row[name]; //  : if (typeof value == "number") return new RTextCell(String(value)); else return new TextCell(String(value)); }); }); return [headers].concat(body); } console.log(drawTable(dataTable(MOUNTAINS))); // β†’ …    


Inheritance is the main part of the object-oriented tradition, along with encapsulation and polymorphism. But, while the last two are perceived as great ideas, the first is controversial.

Basically, because it is usually confused with polymorphism, they represent a more powerful tool than it actually is, and are misused. While encapsulation and polymorphism are used to separate parts of the code and reduce the coherence of the program, inheritance binds the types together and creates greater coherence.

. – . , – . – UnderlinedCell . .

instanceof


, . JavaScript instanceof.

 console.log(new RTextCell("A") instanceof RTextCell); // β†’ true console.log(new RTextCell("A") instanceof TextCell); // β†’ true console.log(new TextCell("A") instanceof RTextCell); // β†’ false console.log([1] instanceof Array); // β†’ true 


The operator passes through inherited types. RTextCell is an instance of TextCell, because RTextCell.prototype is derived from TextCell.prototype. The operator can also be applied to standard Array constructors. Almost all objects are Object instances.

Total


, , . – , , , , . Object.prototype/

,– , ,- new . , prototype . , , , . instanceof, , , .

For objects, you can make an interface and tell everyone to communicate with an object only through this interface. The remaining details of the implementation of the object are now encapsulated, hidden behind the interface.

And after that, no one forbade the use of different objects using the same interfaces. If different objects have the same interfaces, then the code working with them can work with different objects in the same way. This is called polymorphism, and it is a very useful thing.

Defining several types that differ only in small details, it is convenient to simply inherit the prototype of the new type from the prototype of the old type, so that the new constructor calls the old one. This gives you an object type similar to the old one, but you can add properties to it or override old ones.

Exercises


Vector type

Vector, . x y (), .

Vector , plus minus, , , x y ( this, β€” )

length , – (0, 0) (x, y).

 //   console.log(new Vector(1, 2).plus(new Vector(2, 3))); // β†’ Vector{x: 3, y: 5} console.log(new Vector(1, 2).minus(new Vector(2, 3))); // β†’ Vector{x: -1, y: -1} console.log(new Vector(3, 4).length); // β†’ 5 



StretchCell(inner, width, height), . ( UnderlinedCell), , , .

 //  . var sc = new StretchCell(new TextCell("abc"), 1, 2); console.log(sc.minWidth()); // β†’ 3 console.log(sc.minHeight()); // β†’ 2 console.log(sc.draw(3, 2)); // β†’ ["abc", " "] 



Develop an interface that abstracts a set of values. An object with this interface is a sequence, and the interface must allow the code to go through the sequence, work with the values ​​that make it up, and somehow signal that we have reached the end of the sequence.

Having set the interface, try to make a logFive function that accepts a sequence object and calls console.log for its first five elements β€” or for a smaller number if there are less than five.

Then create an ArraySeq object type that wraps the array and allows passage through the array using the interface you developed. Create another type of object, RangeSeq, which goes through a range of numbers (its constructor must take arguments from and to).

 //  . logFive(new ArraySeq([1, 2])); // β†’ 1 // β†’ 2 logFive(new RangeSeq(100, 1000)); // β†’ 100 // β†’ 101 // β†’ 102 // β†’ 103 // β†’ 104 

Source: https://habr.com/ru/post/241587/


All Articles