Объектно-ориентированный JavaScript: примитивы и объекты

Скорее всего, вы читали или слышали, что всё в JavaScript является объектом. Ещё чаще можно увидеть опровержения этого утверждения. Подобные споры возникают регулярно и обусловлены в большей степени недопониманием концепций JavaScript. И, на самом деле, причина считать примитивы объектами есть — у примитивов можно запрашивать свойства и методы, поведение которых во многом схоже со свойствами и методами объектов. Например, строки и массивы содержат одинаковые свойство length:

const arr = ['this', 'is', 'an', 'array'];
const str = 'this is string';
console.log(arr.length); // 4
console.log(str.length); // 14

На первый взгляд, может показаться, что свойство length работает одинаково для строк и массивов. Тем не менее, разница есть. Чтобы понять в чём она заключается достаточно использовать оператор typeof для каждого типа данных:

typeof str; // "string"
typeof arr; // "object"

И что? Это не отменяет того, что мы можем использовать полноценные методы и свойства у примитивов. Всё верно. Но следует учитывать то, что происходит “за сценой”. У каждого примитивного типа данных есть своя функция конструктор, например, для строк — String, для чисел — Number. Когда вы хотите получить какое-либо свойство или метод для примитивов, вы обращаетесь не к самому примитиву, а к объекту, созданному функцией-конструктором данного примитива. Другими словами, примитивы не имеют свойств и методов и не являются объектами. Чтобы разобраться в том, что происходит “за сценой”, можно инициализировать создание новой строки с помощью функции конструктора String:

const str = new String('Hello, World!');
typeof str; // "object"

Подобное обращение к конструктору String происходит каждый раз, когда вы запрашиваете у строки свойство или метод. Создаётся новый объект с помощью функции конструктора, выполняется определённое действие (получение свойства или выполнение метода), а затем созданный ранее объект уничтожается, оставляя после себя только результат. Представить для себя подобное выполнение кода можно следующим образом:

// Получение свойства length строки
let length = 'this is string'.length;
// За сценой
// После получения значения созданный объект уничтожится
let length = (new String('this is string')).length;

Почему важно понимать, что не всё в JavaScript является объектом? Всё просто. Для любого объекта можно задать значение свойства вручную. Например, то же свойство length для массивов и строк:

// Присваивание значение свойтву length у объектов
const arr = ['this', 'is', 'an', 'array'];
arr.length = 10;

// Присваивание значение свойтву length у примитивов
const str = 'this is string';
str.length = 10;

arr.length; // 10 (свойство установлено)
str.length; // 14 (свойство не установлено)

Почему свойство length у строки не изменилось? Когда вы пытаетесь установить свойство у любого примитива, то происходит следующее:

  1. Создаётся новый объект: new String('this is string')
  2. У нового объекта устанавливается свойство length со значением 10
  3. Созданный объект уничтожается

В результате подобной схемы становится очевидно, что данные, находящиеся в исходной переменной не изменяются, а все изменения происходят с новым объектом, который в будущем будет просто уничтожен.

То же самое произойдёт и в случае, если вы захотите присвоить новое свойство или метод примитиву:

const str = 'str';
str.newProp = 'my new property';
str.newMethod = function() {
  return 'my new method';
};

str.newProp; // undefined
str.newMethod(); // Uncaught TypeError: str.newMethod is not a function

Схема работы такая же, как и в прошлом примере:

  1. Создаётся новый объект: new String('str'), которому присваивается свойство newProp со значением 'my new property'
  2. Созданный объект уничтожается
  3. Создаётся ещё один новый объект new String('str'), которому присваивается метод newMethod
  4. Объект уничтожается
  5. Мы пытаемся получить значение несуществующего свойства newProp, в результате чего получаем undefined
  6. Мы пытаемся выполнить несуществующий метод newMethod, что эквивалентно undefined(), и получаем ошибку
Примитивные данные

В JavaScript все примитивные данные разделяются на 6 типов:

  1. строки: 'str', "str", `str`
  2. числа: 2, 0, 100.34
  3. boolean: true и false
  4. undefined
  5. null
  6. символы Symbol()

У строк, чисел и boolean есть функции конструкторы, с помощью, которых можно их инициализировать:

const str = new String('str');
const num = new Number(20);
const bool = new Boolean(true);

Но несмотря на то, что у вас есть возможность создавать примитивы с помощью функций конструкторов, делать это в реальных проектах не надо ни при каких обстоятельствах. Всегда используйте литералы для создания примитивов (за исключением символов, так как для них не предусмотрено формы литерала, их нужно создавать при помощи функции Symbol()). Почему не стоит создавать примитивы с помощью функций конструкторов? Всё, что вам нужно запомнить — при подобном создании примитива вы не получаете само значение, а только его объект-обвертку. В некоторых ситуациях подобное поведение может быть опасным и привести к проблемам. Например, при создании значения типа boolean через конструктор:

const val = new Boolean(false);
if (val) { console.log('Hello, World!'); }

В результате выполнения данного кода в консоль выведется сообщение. Код внутри конструкции if выполняется, так как значение, находящееся в переменной val, является объектом с содержимым {[[PrimitiveValue]]: false}, а любой объект, даже пустой, в JavaScript является правдивым значением.

Вы можете использовать функции String, Number и Boolean без ключевого слова new. При подобном использовании всё, что они будут делать — приводить типы. Несколько примеров:

// Приведение строки к числу
let num = Number('20');
typeof num; // "number"

// Приведение числа к строке 
let str = String(20);
typeof str; // "string"

// Приведение любого значения к типу boolean
let bool1 = Boolean('str'); // true
let bool2 = Boolean(0); // false

typeof bool1; // "boolean"
typeof bool2; // "boolean"

Иногда подобные решения бывают полезными и позволяют сократить объем необходимого кода, например, если нужно отфильтровать все ложные значения из массива, то можно воспользоваться функцией Boolean:

const falsyArr = ['', 0, null, 10, 'string', undefined, true, {a: 1}, false];
const truthyArr = falsyArr.filter(Boolean);

// Аналогично
const truthyArr = falsyArr.filter(function (item) {
  return !!item; 
});

// С использование стрелочных функций из ES6
const truthyArr = falsyArr.filter(item => !!item);
null является объектом?

Если вы попробуете использовать оператор typeof для всех примитивов, то, в целом, вы не обнаружите ничего нового для себя:

typeof 'str';     // "string"
typeof 100;       // "number"
typeof true;      // "boolean"
typeof undefined; // "undefined"
typeof Symbol();  // "symbol" 

Всё складывается хорошо, до тех пор, пока вы не опробуете данный оператор на null

typeof null; // "object"

Если вы подумали, что что-то тут неправильно и нелогично, то вы абсолютно правы. Подобное поведение является не более, чем багом языка. Но за столько лет можно же было бы это исправить? Конечно, можно, но следует понимать, что подобное поведение typeof null используется практически в каждом приложении или библиотеке. Таким образом, исправить подобный баг невозможно, так как это сломает весь существующий на данный момент JavaScript код. Ввести подобное нововведение в новый стандарт ECMAScript тоже не получится, так как будет потеряна обратная совместимость.

Итого, null не является объектом, несмотря на то, что оператор typeof утверждает обратное. Это значит, что в null не могут быть записаны никакие свойства или методы, то есть действуют все правила примитивов.

Объекты

Выше были перечислены все типы данных, которые являются примитивами. Всё остальное в JavaScript является объектом: массивы, функции, сами объекты. Любому объекту можно присвоить свойства и методы. Например, для любой функции:

const log = function(message) { console.log(log.messages); };
log.messages = ['first', 'second', 'third'];
log(); // ["first","second","third"]

В данном примере к свойству messages функции log можно обратиться сразу при её инициализации. Разумеется, подобным образом можно записывать и методы:

const log = function(message) { console.log(log.transform()); };
log.messages = ['first', 'second', 'third'];
log.transform = function () {
  return log.messages.map(function(message) {
    return message + ' message';
  });
};
log(); // ["first message","second message","third message"]

Подобным образом можно присвоить свойства и методы любому другому объекту. Но всегда стоит понимать, зачем вы это делаете и как это работает. Если раздавать методы и свойства всем видам объектов подряд, то, скорее всего, вы очень быстро заметите странное поведение. Например, если задать свойство newProp у массива, то вы обнаружите, что в результате выполнения любого перебирающего метода (например, forEach) с именованным свойством newProp не была вызвана callback функция, а также оно не влияет на длину самого массива:

const arr = [1, 2, 3, 4, 5];
console.log(arr.length); // 5

arr.newProp = 'awesome property';
console.log(arr.length); // 5

// свойство newProp не выводится в консоль
arr.forEach(function(num) {
  console.log(num); // 1 2 3 4 5 
});
Итого

Все типы данных в JavaScript разделяются на примитивы и объекты. Примитивы не могут иметь собственных свойств и методов, объекты могут. Утверждение, что всё в JavaScript является объектом неверное. Более правильно говорить, что всё в JavaScript ведёт себя, как объект, но необязательно им является.

Комментарии