Как не облажаться на интервью

Тема, вроде бы, изъезженная. Кто-то реально на практике применяет понимая что это и зачем. Кто-то применяет, но не знает что это известный шаблон проектирования и он так называется. У кого-то спрашивают про это на собеседованиях.

С учетом особенностей JS различные варианты из ООП в нем могут быть очень даже нестандартными. Я сам иногда на собеседовании прошу написать Singleton у кандидата, только в случае если он совершил следующие действия:

  1. сказал что знает ООП и шаблоны проектирования
  2. сказал, что из всех паттернов знает Singleton

Не хочу поднимать сейчас вопрос о необходимости такого паттерна в JavaScript. Но как это не удивительно, Singleton’ом в JavaScript мы оперируем повседневно.

Обычно я спрашиваю вопрос таким образом:

Какие есть способы получать один и тот же экземпляр объекта?

И тут я жду рассуждений. Каждый из нас пользовался и не раз Singleton объектами в JS даже не думая ни о каких шаблонах объектно ориентированного программирования. Как ни странно, но задача для многих оказывается нетривиальной. Даже если человек до этого писал на PHP или C#, к примеру. Даже если человек давно знаком с JS, почему-то мало кто отвечает, что самый простой способ создать Singleton объект в JS — это создать глобальную переменную с присвоением объекта, ведь в JS для создания экземпляра объекта не обязательно создавать класс. В JS все есть объект…

В результате решил агрегировать все свои знания и оформить в виде сборника решений одной задачи на все случаи жизни. Причем покажу варианты на ES5, ES6+ и TypeScript. TypeScript в данном случае выступает в роли правильного ООП языка. И так, поехали…

Singleton — одиночка

Немного занудства, можно пропустить, если все это знаете.

Одиночка (англ. Singleton) — порождающий шаблон проектирования, гарантирующий что в однопоточном приложении будет единственный экземпляр класса с глобальной точкой доступа.

Порождающие шаблоны (англ. Creational patterns) — шаблоны проектирования, которые абстрагируют процесс инстанцирования. Они позволяют сделать систему независимой от способа создания, композиции и представления объектов.

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

Цель

Гарантирует, что у класса есть только один экземпляр, и предоставляет к нему глобальную точку доступа. Существенно то, что можно пользоваться именно экземпляром класса, так как при этом во многих случаях становится доступной более широкая функциональность. Например, к описанным компонентам класса можно обращаться через интерфейс, если такая возможность поддерживается языком.

Плюсы

  1. Контролируемый доступ к единственному экземпляру.

Минусы

  1. Глобальные объекты могут быть вредны для объектного программирования, в некоторых случаях приводя к созданию немасштабируемого проекта;
  2. Усложняет написание модульных тестов и следование TDD.

Примеры использования

  • Объект-дебаггер для отладки web-приложения
  • Объект сбора ошибок
  • Класс доступа к браузерным хранилищам и cookies
  • Реализация шаблона Реестр (Registry). Например реестр объектов, для контроля за используемыми объектами на странице
  • Реализация паттерна Медиатор или Application controller

Условия выполнения

Мы воспользуемся подходом TDD и прежде чем писать реализации запишем минимальные тесты, которые должны быть выполнены. Так как это JS и этот язык сильно отличается от более “правильных” языков типа Java, C++ и прочих, в которых создание объекта реализуется через класс, а конструктор не может ничего возвращать, то мы будем так же рассматривать варианты реализации без конструктора и классического определения класса. Ведь в JS это можно делать.

И так, минимальные условия:

var errorMessage = 'Instantiation failed: use Singleton.getInstance() instead of new.';
// Test constructor
try { var obj0 = new Singleton } catch(c) { console.log(c == errorMessage) }
// Create and get object
let obj1 = Singleton.getInstance();
let obj2 = Singleton.getInstance();
obj1.foo = 456;
console.log( obj1 === obj2 );
try { var obj3 = new Singleton } catch(c) { console.log(c == errorMessage) }
console.log(obj0 === void 0);
console.log(obj3 === void 0);

Но эти тесты будут модифицированы в процессе разбора решений, так как у нас будут реализации, возвращающие ссылку на объект из конструктора.

Реализации Singleton в TypeScript

Классическая реализация на TypeScript

class Singleton {

  protected static _instance :Singleton;
  
  protected foo :number = 123;
  
  constructor() {
    if (Singleton._instance) {
        throw new Error("Instantiation failed: "+
                        "use Singleton.getInstance() instead of new.");
    }
    Singleton._instance = this;
  }

  public static getInstance() :Singleton {
    if (Singleton._instance) {
      return Singleton._instance;
    }
    return Singleton._instance = new Singleton;
  }
}

Все бы хорошо, но в TypeScript 1.8 нет возможности задать приватный конструктор, что приводит к добавлению логики в него. Минусы такой реализации — при первом вызове new Singleton конструктор вернет объект. Да им можно пользоваться, но это нарушает классическую реализацию. Мы можем модифицировать код и получить вот такую версию:

class Singleton {

  protected static _instance :Singleton = new Singleton;
  
  protected foo :number = 123;
	
  constructor() {
    if (Singleton._instance) {
        throw new Error("Instantiation failed: "+
                        "use Singleton.getInstance() instead of new.");
    }
  }

  public static getInstance() :Singleton {
    return Singleton._instance;
  }
}

В такой реализации и кода меньше, и нельзя получить экземпляр объекта через new, так как первая иницализация происходит “автоматически” при объявлении класса.

Снова повторюсь: так как у нас необычный язык, то реализовать задачу можно совершенно необычными способами. Например мы можем реализовать одиночку через пространство имен (namespace):

namespace Singleton {
	
  interface Instance {
    foo: number;
  }
  
  const instance :Instance = {
    foo: 123
  };
  
  export function getInstance() :Instance {
    return instance;
  }
}

Продолжая развивать тему, мы можем реализовать паттерн через модуль. Я покажу вариант модуля:

module Singleton {
	
  class Instance {
	  constructor(public foo: number = 123) {}
  }
  
  let instance = new Instance;
  
  export function getInstance() :Instance {
     return instance;
  }
}

Вы так же можете реализовать модуль в отдельном файле:

class Instance {
  constructor(public foo: number = 123) {}
}

let instance = new Instance;

export function getInstance() :Instance {
  return instance;
}

// ...

import * as Singleton from "singleton.ts";

let obj1 = Singleton.getInstance();
let obj2 = Singleton.getInstance();

obj1.foo = 456;

console.log( obj1 === obj2 );

В такой реализации у нас не то чтобы нет доступа к объекту через вызов new Singleton. У нас вообще нет возможности достучаться до конструктора (ну мы сейчас не рассматриваем цепочку прототипов и прочие возможности JS).

Анонимный класс

Так можНо, если нужно 😉

const Singleton = new (class {
public foo :number = 123;
getInstance() :this { return this }
})();

Реализации Singleton в JavaScript

А теперь вернемся к нашему любимому JavaScript со всеми его возможностями. И так, помните я говорил, что мы каждый день пользуемся объектами одиночками? Так как у нас JS , то нам вовсе не нужно создавать класс для получения объекта. Мы можем создать объект, который будет проходить наши тесты:

const Singleton = {
foo: 123,
getInstance() { return this }
};
let obj1 = Singleton.getInstance();
let obj2 = Singleton.getInstance();
obj1.foo = 456;
console.log( obj1 === obj2 );

В данном примере метод getInstance создан только для того, чтобы не менять наши тесты. Ведь у нас есть полный доступ ко всему объекту и мы можем делать с ним что угодно. Но если вспоминать классическое определение шаблона, то там сказано что Одиночка порождающий шаблон, гарантирующий единственный экземпляр класса с глобальной точкой доступа. Пример выше выполняет эти условия, разве что у нас не реализован механизм порождения. Хотя можно считать что он встроен в язык программирования.

А теперь рассмотрим более сложные примеры на ES5+, позволяющие создавать именно классы, которые будут порождать Singleton.

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

Используем arguments

function Singleton() {
if (arguments.callee.instance) return arguments.callee.instance;
this.foo = 123;
return arguments.callee.instance = this;
}
let obj1 = new Singleton;
let obj2 = new Singleton;
obj1 === obj2 // true

Метод лаконичен и просто в реализации, но у него есть недостаток. В режиме “use strict” этот код не будет работать, а JSLint/JSHint в (Php|Web)Storm будет показывать ошибку.

Тогда этот же пример можно переписать так:

function Singleton() {
if (Singleton.instance) return Singleton.instance;
this.foo = 123;
return Singleton.instance = this;
}

Скрываем доступ к instance

Пример на ES5 c приватными (локальными) переменными:

(function(g){
    /** @type {SingletonConstructor} */
    var instance;
    g.Singleton = function() {
        if (instance !== void 0) return instance;
        return instance = this;
    };
    g.Singleton.prototype.foo = 123;
})(window||global);

var obj1 = new Singleton;
var obj2 = new Singleton;

console.log(obj2.foo === 123);
obj1.foo = 456;
console.log(obj2.foo === 456);
console.log(obj1 === obj2 );

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

Краткая запись

var Singleton = new function(){
var instance = this;
// Код конструктора
return function(){ return instance }
}
console.log( new Singleton === new Singleton ); // true

ECMAScript 2015

let singleton = Symbol();
let singletonEnforcer = Symbol();

class Singleton {

  constructor(enforcer) {
    if (enforcer !== singletonEnforcer)
       throw "Instantiation failed: use Singleton.getInstance() instead of new.";
    // код конструктора
  }

  static get instance() {
    if (!this[singleton])
        this[singleton] = new Singleton(singletonEnforcer);
    return this[singleton];
  }
  
  static set instance(v) { throw "Can't change constant property!" }
}

export default Singleton;
    
// ...

import Singleton from 'singleton';

// Test constructor
try { var obj0 = new Singleton() } catch(c) { console.log(c) }
console.log('obj0', obj0 );


// Create and get object
let obj1 = Singleton.instance;
let obj2 = Singleton.instance;



console.log(obj2.foo === 123 );
obj1.foo = 456;
console.log('obj2', obj2 );
console.log('obj1 === obj2',  obj1 === obj2 );

try { var obj3 = new Singleton() } catch(c) { console.log(c) }
console.log('obj3', obj3);

Эпилог

Как видите способов и разнообразия для реализации логики порождающей единственный экземпляр объекта хватает в нашем любимом JavaScript.