18 августа 2019 г.

Типы данных: [[Class]], instanceof и утки

Материал на этой странице устарел, поэтому скрыт из оглавления сайта.

Более новая информация по этой теме находится на странице https://learn.javascript.ru/instanceof.

Время от времени бывает удобно создавать так называемые «полиморфные» функции, то есть такие, которые по-разному обрабатывают аргументы, в зависимости от их типа. Например, функция вывода может по-разному форматировать числа и даты.

Для реализации такой возможности нужен способ определить тип переменной.

Оператор typeof

Мы уже знакомы с простейшим способом – оператором typeof.

Оператор typeof надёжно работает с примитивными типами, кроме null, а также с функциями. Он возвращает для них тип в виде строки:

alert( typeof 1 );         // 'number'
alert( typeof true );      // 'boolean'
alert( typeof "Текст" );   // 'string'
alert( typeof undefined ); // 'undefined'
alert( typeof null );      // 'object' (ошибка в языке)
alert( typeof alert );     // 'function'

…Но все объекты, включая массивы и даты для typeof – на одно лицо, они имеют один тип 'object':

alert( typeof {} ); // 'object'
alert( typeof [] ); // 'object'
alert( typeof new Date ); // 'object'

Поэтому различить их при помощи typeof нельзя, и в этом его основной недостаток.

Секретное свойство [[Class]]

Для встроенных объектов есть одна «секретная» возможность узнать их тип, которая связана с методом toString.

Во всех встроенных объектах есть специальное свойство [[Class]], в котором хранится информация о его типе или конструкторе.

Оно взято в квадратные скобки, так как это свойство – внутреннее. Явно получить его нельзя, но можно прочитать его «в обход», воспользовавшись методом toString стандартного объекта Object.

Его внутренняя реализация выводит [[Class]] в небольшом обрамлении, как "[object значение]".

Например:

var toString = {}.toString;

var arr = [1, 2];
alert( toString.call(arr) ); // [object Array]

var date = new Date;
alert( toString.call(date) ); // [object Date]

var user = { name: "Вася" };
alert( toString.call(user) ); // [object Object]

В первой строке мы взяли метод toString, принадлежащий именно стандартному объекту {}. Нам пришлось это сделать, так как у Date и Array – свои собственные методы toString, которые работают иначе.

Затем мы вызываем этот toString в контексте нужного объекта obj, и он возвращает его внутреннее, невидимое другими способами, свойство [[Class]].

Для получения [[Class]] нужна именно внутренняя реализация toString стандартного объекта Object, другая не подойдёт.

К счастью, методы в JavaScript – это всего лишь функции-свойства объекта, которые можно скопировать в переменную и применить на другом объекте через call/apply. Что мы и делаем для {}.toString.

Метод также можно использовать с примитивами:

alert( {}.toString.call(123) ); // [object Number]
alert( {}.toString.call("строка") ); // [object String]
Вызов {}.toString в консоли может выдать ошибку

При тестировании кода в консоли вы можете обнаружить, что если ввести в командную строку {}.toString.call(...) – будет ошибка. С другой стороны, вызов alert( {}.toString... ) – работает.

Эта ошибка возникает потому, что фигурные скобки { } в основном потоке кода интерпретируются как блок. Интерпретатор читает {}.toString.call(...) так:

{ } // пустой блок кода
.toString.call(...) // а что это за точка в начале? не понимаю, ошибка!

Фигурные скобки считаются объектом, только если они находятся в контексте выражения. В частности, оборачивание в скобки ( {}.toString... ) тоже сработает нормально.

Для большего удобства можно сделать функцию getClass, которая будет возвращать только сам [[Class]]:

function getClass(obj) {
  return {}.toString.call(obj).slice(8, -1);
}

alert( getClass(new Date) ); // Date
alert( getClass([1, 2, 3]) ); // Array

Заметим, что свойство [[Class]] есть и доступно для чтения указанным способом – у всех встроенных объектов. Но его нет у объектов, которые создают наши функции. Точнее, оно есть, но равно всегда "Object".

Например:

function User() {}

var user = new User();

alert( {}.toString.call(user) ); // [object Object], не [object User]

Поэтому узнать тип таким образом можно только для встроенных объектов.

Метод Array.isArray()

Для проверки типа на массив есть специальный метод: Array.isArray(arr). Он возвращает true только если arr – массив:

alert( Array.isArray([1,2,3]) ); // true
alert( Array.isArray("not array")); // false

Но этот метод – единственный в своём роде.

Других аналогичных, типа Object.isObject, Date.isDate – нет.

Оператор instanceof

Оператор instanceof позволяет проверить, создан ли объект данной функцией, причём работает для любых функций – как встроенных, так и наших.

function User() {}

var user = new User();

alert( user instanceof User ); // true

Таким образом, instanceof, в отличие от [[Class]] и typeof может помочь выяснить тип для новых объектов, созданных нашими конструкторами.

Заметим, что оператор instanceof – сложнее, чем кажется. Он учитывает наследование, которое мы пока не проходили, но скоро изучим и затем вернёмся к instanceof в главе Проверка класса: "instanceof".

Утиная типизация

Альтернативный подход к типу – «утиная типизация», которая основана на одной известной пословице: «If it looks like a duck, swims like a duck and quacks like a duck, then it probably is a duck (who cares what it really is)».

В переводе: «Если это выглядит как утка, плавает как утка и крякает как утка, то, вероятно, это утка (какая разница, что это на самом деле)».

Смысл утиной типизации – в проверке необходимых методов и свойств.

Например, мы можем проверить, что объект – массив, не вызывая Array.isArray, а просто уточнив наличие важного для нас метода, например splice:

var something = [1, 2, 3];

if (something.splice) {
  alert( 'Это утка! То есть, массив!' );
}

Обратите внимание – в if мы не вызываем метод something.splice(), а пробуем получить само свойство something.splice. Для массивов оно всегда есть и является функцией, т.е. даст в логическом контексте true.

Проверить на дату можно, определив наличие метода getTime:

var x = new Date();

if (x.getTime) {
  alert( 'Дата!' );
  alert( x.getTime() ); // работаем с датой
}

С виду такая проверка хрупка, её можно «сломать», передав похожий объект с тем же методом.

Но как раз в этом и есть смысл утиной типизации: если объект похож на дату, у него есть методы даты, то будем работать с ним как с датой (какая разница, что это на самом деле).

То есть мы намеренно позволяем передать в код нечто менее конкретное, чем определённый тип, чтобы сделать его более универсальным.

Проверка интерфейса

Если говорить словами «классического программирования», то «duck typing» – это проверка реализации объектом требуемого интерфейса. Если реализует – ок, используем его. Если нет – значит это что-то другое.

Пример полиморфной функции

Пример полиморфной функции – sayHi(who), которая будет говорить «Привет» своему аргументу, причём если передан массив – то «Привет» каждому:

function sayHi(who) {

  if (Array.isArray(who)) {
    who.forEach(sayHi);
  } else {
    alert( 'Привет, ' + who );
  }
}

// Вызов с примитивным аргументом
sayHi("Вася"); // Привет, Вася

// Вызов с массивом
sayHi(["Саша", "Петя"]); // Привет, Саша... Петя

// Вызов с вложенными массивами - тоже работает!
sayHi(["Саша", "Петя", ["Маша", "Юля"]]); // Привет Саша..Петя..Маша..Юля

Проверку на массив в этом примере можно заменить на «утиную» – нам ведь нужен только метод forEach:

function sayHi(who) {

  if (who.forEach) {  // если есть forEach
    who.forEach(sayHi); // предполагаем, что он ведёт себя "как надо"
  } else {
    alert( 'Привет, ' + who );
  }
}

Итого

Для написания полиморфных (это удобно!) функций нам нужна проверка типов.

  • Для примитивов с ней отлично справляется оператор typeof.

    У него две особенности:

    • Он считает null объектом, это внутренняя ошибка в языке.
    • Для функций он возвращает function, по стандарту функция не считается базовым типом, но на практике это удобно и полезно.
  • Для встроенных объектов мы можем получить тип из скрытого свойства [[Class]], при помощи вызова {}.toString.call(obj).slice(8, -1). Для конструкторов, которые объявлены нами, [[Class]] всегда равно "Object".

  • Оператор obj instanceof Func проверяет, создан ли объект obj функцией Func, работает для любых конструкторов. Более подробно мы разберём его в главе Проверка класса: "instanceof".

  • И, наконец, зачастую достаточно проверить не сам тип, а просто наличие нужных свойств или методов. Это называется «утиная типизация».

Задачи

важность: 5

Напишите функцию formatDate(date), которая возвращает дату в формате dd.mm.yy.

Её первый аргумент должен содержать дату в одном из видов:

  1. Как объект Date.
  2. Как строку, например yyyy-mm-dd или другую в стандартном формате даты.
  3. Как число секунд с 01.01.1970.
  4. Как массив [гггг, мм, дд], месяц начинается с нуля

Для этого вам понадобится определить тип данных аргумента и, при необходимости, преобразовать входные данные в нужный формат.

Пример работы:

function formatDate(date) { /* ваш код */ }

alert( formatDate('2011-10-02') ); // 02.10.11
alert( formatDate(1234567890) ); // 14.02.09
alert( formatDate([2014, 0, 1]) ); // 01.01.14
alert( formatDate(new Date(2014, 0, 1)) ); // 01.01.14

Открыть песочницу с тестами для задачи.

Для определения примитивного типа строка/число подойдёт оператор typeof.

Примеры его работы:

alert( typeof 123 ); // "number"
alert( typeof "строка" ); // "string"
alert( typeof new Date() ); // "object"
alert( typeof [] ); // "object"

Оператор typeof не умеет различать разные типы объектов, они для него все на одно лицо: "object". Поэтому он не сможет отличить Date от Array.

Для отличия Array используем вызов Array.isArray. Если он неверен, значит у нас дата.

function formatDate(date) {
  if (typeof date == 'number') {
    // перевести секунды в миллисекунды и преобразовать к Date
    date = new Date(date * 1000);
  } else if (typeof date == 'string') {
    // строка в стандартном формате автоматически будет разобрана в дату
    date = new Date(date);
  } else if (Array.isArray(date)) {
    date = new Date(date[0], date[1], date[2]);
  }
  // преобразования для поддержки полиморфизма завершены,
  // теперь мы работаем с датой (форматируем её)

  return date.toLocaleString("ru", {day: '2-digit', month: '2-digit', year: '2-digit'});

  /*
  // можно и вручную, если лень добавлять в старый IE поддержку локализации
  var day = date.getDate();
  if (day < 10) day = '0' + day;

  var month = date.getMonth() + 1;
  if (month < 10) month = '0' + month;

  // взять 2 последние цифры года
  var year = date.getFullYear() % 100;
  if (year < 10) year = '0' + year;

  var formattedDate = day + '.' + month + '.' + year;

  return formattedDate;
  */
}

Открыть решение с тестами в песочнице.

Карта учебника