Иногда JavaScript может вводить разработчика в заблуждение, а иногда — доводить до белого каления из-за своей неполной консистентности. Есть в JavaScript некоторые вещи, которые только запутывают и сбивают с толку. Самые известные из них оператор with, неявные глобальные переменные и странное поведение при операции сравнения.

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

  • DOM, который многие ошибочно считают эквивалентом самого языка JavaScript, обладает очень неудачным API.
  • Когда переходишь на JavaScript с языков С и Java, то попадаешь в ловушку синтаксиса, который устроен не так, как в императивных языках. Это очень часто приводит к багам и сильно раздражает.

В результате JavaScript обрёл довольно плохую репутацию, которой он, в общем-то, не заслуживает. И чаще всего это связано с тем, что многие разработчики переносят на JavaScript свой опыт работы на Java или С/С++. Здесь разобраны три наиболее трудных случая, демонстрирующих разницу в подходах между Java и JavaScript.

Область видимости


Большинство разработчиков переходят на JavaScript в связи с необходимостью. И почти все повторяют одну ошибку — начинают писать код, не изучив предварительно особенности языка. Очень многие хотя бы раз испытывают затруднения с областями видимости.

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

Во-первых, область видимости переменных определяется функциями, а не скобками. То есть if и for не создают новую область видимости, а объявленная в их конструкциях переменная, на самом деле, «поднимается». То есть создаётся она в начале самой первой функции, в которой она объявлена, иными словами — в глобальной области видимости.

Во-вторых, наличие оператора with делает область видимости JavaScript динамической, её нельзя определить до начала выполнения программы. Лучше вообще избегать использования with, без него JavaScript превращается в язык, использующий лексические области видимости. То есть достаточно будет прочитать код, чтобы понять для себя все области видимости.

Формально, в JavaScript существует четыре способа включения идентификатора в область видимости:

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

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

Прежде чем перейти к деталям, следует порекомендовать использовать строгий режим ('use strict';) и помещать все объявления переменных и функций в начало каждой функции. Избегайте объявления переменных и функций внутри блоков for и if.

Поднятие


Этот термин применяется для упрощённого описания того, как на самом деле осуществляется объявление. Поднимаемые переменные объявляются в самом начале содержащих их функций, а затем инициализируются как undefined. Присваивание осуществляется непосредственно в той строке, где происходит объявление.

Рассмотрим пример:

function myFunction() {
  console.log(i);
  var i = 0;
  console.log(i);
  if (true) {
    var i = 5;
    console.log(i);
  }
  console.log(i);
}

Как вы думаете, какие значения будут выведены на экран?

undefined
0
5
5

Оператор var не объявляет локальную копию переменной i внутри блока if. Вместо этого он перезаписывает уже объявленную ранее. Обратите внимание, что первый оператор console.log выводит действительное значение переменной i, инициализированной как undefined. А если перейти в строгий режим? В строгом режиме переменные должны объявляться до того, как они будут использованы, однако движок JavaScript не потребует это сделать. Кстати, имейте в виду, что от вас не потребуют и переобъявления var. Если вам нужно выловить подобные баги, то воспользуйтесь инструментами вроде JSHint или JSLint.

Давайте рассмотрим пример, демонстрирующий другой способ объявления переменных, который может привести к ошибкам:

var notNull = 1;
function test() {
  if (!notNull) {
    console.log("Null-ish, so far", notNull);
    for(var notNull = 10; notNull <= 0; notNull++){
      //..
    }
    console.log("Now it's not null", notNull);
  }
  console.log(notNull);
}

В этом примере блок if выполняется, потому что локальная копия переменной notNull объявлена внутри функции test() и поднята. Свою роль здесь играет и операция приведения типа.

Функциональные выражения и объявления функций


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

Вот пример объявления функции:

function foo() {
    // A function declaration
    function bar() {
        return 3;
    }
    return bar();

    // This function declaration will be hoisted and overwrite the previous one
    function bar() {
        return 8;
    }
}

А теперь сравните с примером функционального выражения:

function foo() {
    // A function expression
    var bar = function() {
        return 3;
    };
    return bar();

    // The variable bar already exists, and this code will never be reached
    var bar = function() {
        return 8;
    };
}

Для более глубокого понимания вопроса стоит обратиться к публикациям, указанным в конце поста.

With


В этом примере отражена ситуация, когда область видимости можно определить лишь во время выполнения:

function foo(y) {
  var x = 123;
  with(y) {
    return x;
  }
}

Если y имеет поле x, тогда функция foo() вернёт y.x, в противном случае — 123. Подобная практика может привести к возникновению ошибок на стадии выполнения, так что рекомендуется избегать использования оператора with.

Взгляд в будущее: ECMAScript 6


Спецификации ECMAScript 6 позволят внедрить пятый способ определения области видимости на уровне блоков: оператор let.

function myFunction() {
  console.log(i);
  var i = 0;
  console.log(i);
  if (false) {
    let i = 5;
    console.log(i);
  }
  console.log(i);
}

В ECMAScript 6 объявление i внутри if с помощью let позволит создавать новую локальную переменную в блоке if. В качестве нестандартной альтернативы можно объявлять блоки let:

var i = 6;
let (i = 0, j = 2) {
  /* Other code here */
}
// prints 6
console.log(i);

В этом примере переменные i и j будут существовать только внутри блока. На момент написания поста только в Chrome поддерживается использование let.

В других языках


Ниже представлена сравнительная таблица особенностей реализации областей видимости в разных языках:
Свойство Java Python JavaScript Примечание
Область видимости Лексическая (блоки) Лексическая (функции, классы или модули) Да Работает совсем не так, как в Java или С.
Блочная область видимости Да Нет В связке с let (ES6) Работает совсем не так, как в Java.
Поднимание Нет Нет Да Для объявления переменных, функций и функциональных выражений.

Функции


Ещё одним камнем преткновения в JavaScript зачастую становятся функции. Причина в том, что в императивных языках вроде Java используется совсем иная концепция. JavaScript относится к функциональным языкам программирования. Правда, он не чисто функциональный, всё-таки в нём явно прослеживается императивный стиль и поощряется мутабельность. Но как бы то ни было, JavaScript может быть использован исключительно как функциональный язык, без какого-либо внешнего воздействия на вызовы функций.

В JavaScript с функциями можно обращаться как с любыми другими типами данных, например, String или Number. Их можно хранить в переменных и массивах, передавать в качестве аргументов другим функциям и возвращать другими функциями. У них могут быть свойства, их можно динамически изменять, и всё это благодаря объектам.

Для многих новичков в JavaScript удивителен тот факт, что функции здесь являются объектами. Конструктор Function создаёт объект Function:

var func = new Function(['a', 'b', 'c'], '');

Это почти аналогично:
function func(a, b, c) { }

Почти — потому что использование конструктора менее эффективно. Он генерирует анонимную функцию и не создаёт замыкания для её контекста. Объекты Function всегда создаются в глобальной области видимости.

Function, как разновидность функций, основана на базе Object. Это хорошо видно, если разобрать любую объявляемую нами функцию:

function test() {}
//  prints  "object"
console.log(typeof test.prototype);
//  prints  function Function() { [native code] }
console.log(test.constructor);

Это значит, что у функции есть свойства. Некоторые из них назначаются при создании. Например name или length, возвращающие, соответственно, наименование и количество аргументов в определении функции.

function func(a, b, c) { }
//  prints "func"
console.log(func.name);
//  prints 3
console.log(func.length);

Любой функции можно задать и другие свойства, по своему усмотрению:

function test() {
  console.log(test.custom);
}
test.custom = 123;
//  prints 123
test();

В других языках


Сравнительная таблица реализаций функций в разных языках:
Свойство Java Python JavaScript Примечание
Функции как встроенные типы Лямбды, Java 8 Да Да
Шаблон коллбэков/команд Объекты (или лямбды для Java 8) Да Да Функции (коллбэки)
Динамическое создание Нет Нет eval (объект Function) eval вызывает вопросы с точки зрения безопасности, объекты Function могут работать непредсказуемо
Свойства Нет Нет Могут иметь свойства Доступ к свойствам функций можно ограничить

Замыкания


JavaScript был первым из основных языков программирования, в котором появились замыкания. Как вы, вероятно, знаете, в Java и Python долгое время были упрощённые версии замыканий, когда можно было только считывать некоторые значения из объемлющих областей видимости. Скажем, в Java анонимный вложенный класс обеспечивает функциональность, аналогичную замыканиям (с некоторыми ограничениями). Например, в их областях видимости могут использоваться только финальные локальные переменные. Точнее, могут быть считаны их значения.

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

Ещё интереснее то, что созданная в замыкании функция «помнит» окружение, в котором она была создана. Комбинируя замыкания и вложенность функций, можно сделать так, что внешние функции будут возвращать внутренние без их исполнения. Более того, локальные переменные внешних функций могут сохраняться в замыкании внутренней функции ещё долгое время после исполнения той, где они объявлялись последний раз. Это довольно мощный инструмент, но у него есть один недостаток: распространённая проблема утечки памяти в JavaScript-приложениях.

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

function makeCounter () {
  var i = 0;

  return function displayCounter () {
    console.log(++i);
  };
}
var counter = makeCounter();
//  prints 1
counter();
//  prints 2
counter();

Функция makeCounter() создаёт и возвращает другую функцию, которая сохраняет связь со своим родительским окружением. Хотя исполнение makeCounter() закончилось с присвоением переменной counter, локальная переменная i сохраняется в замыкании displayCounter, внутри тела которого можно получить к ней доступ.

Если снова запустить makeCounter(), то она создаст новое замыкание с другим начальным значением i:

var counterBis = makeCounter();
//  prints 1
counterBis();
//  prints 3
counter();
//  prints 2
counterBis();

Можно сделать и так, что makeCounter() примет аргумент:

function makeCounter(i) {
  return function displayCounter () {
    console.log(++i);
  };
}
var counter = makeCounter(10);
//  prints 11
counter();
//  prints 12
counter();

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

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

function Person(name) {
  return {
    setName: function(newName) {
      if (typeof newName === 'string' && newName.length > 0) {
        name = newName;
      } else {
        throw new TypeError("Not a valid name");
      }
    },
    getName: function () {
      return name;
    }
  };
}

var p = Person("Marcello");

// prints "Marcello"
a.getName();

// Uncaught TypeError: Not a valid name
a.setName();

// Uncaught TypeError: Not a valid name
a.setName(2);
a.setName("2");

// prints "2"
a.getName();

Таким образом можно создавать обёртку для имени свойства с нашими собственными сеттером и геттером. В ES 5 это стало делать гораздо проще, поскольку можно создавать объекты с сеттерами/геттерами для их свойств и тонко настраивать доступ к этим свойствам.

В других языках


Сравнительная таблица реализаций замыканий в разных языках:

Свойство Java Python JavaScript Примечание
Замыкание С ограниченными возможностями, только чтение, в анонимных вложенных классах С ограниченными возможностями, только чтение, во вложенных определениях Да Утечки памяти
Шаблон мемоизации Необходимо использовать совместно используемые объекты Возможно с использованием списков или словарей Да Лучше использовать отложенные вычисления
Шаблон пространства имён/модуля Не нужно Не нужно Да
Шаблон приватных атрибутов Не нужно Невозможно Да Может ввести в заблуждение

Заключение


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

Scoping in JavaScript
Function Declarations vs Function Expressions
Let statement and let blocks

Комментарии (36)


  1. CoolWolf
    12.05.2015 12:58

    Хорошая статья, может сэкономить много нервных клеток новичкам, которые начинают писать на js после других языков.


  1. hellman
    12.05.2015 13:19
    +4

    По поводу Python, можете пояснить подробнее, что подразумевается под динамическим созданием функций и свойствами функций, и чего нету (?) в питоне? Ну и для замыканий в 3ем питоне есть nonlocal. Какие ещё возможности ограничены?


  1. impwx
    12.05.2015 13:25
    +7

    В результате JavaScript обрёл довольно плохую репутацию, которой он, в общем-то, не заслуживает.

    Имхо, взамен неудобному DOM API существует вполне удобный jQuery, так что он к репутации JS имеет мало отношения. А вот что действительно подрывает доверие — это пируэты неявного приведения типов, неортогональность стандартной библиотеки и прочий WAT. Причина этого не в привычке к Java и C++, а исключительно в том, что у автора первой версии JS не было времени на проработку деталей, которые впоследствии стало невозможно поменять без разрушения обратной совместимости.


    1. hellman
      12.05.2015 13:39

      Хаха, жаль что переименовали


    1. CoolWolf
      12.05.2015 13:45
      +3

      Книга «JavaScript: The Good Parts» (Douglas Crockford) как раз об этом.


    1. Zenitchik
      12.05.2015 16:07
      -6

      Неявное приведение типов — источник множества приёмов сокращения кода. Просто, людям бывает лень нормально изучить язык…


      1. impwx
        12.05.2015 16:59
        +7

        Проблема в том, что у JS кардинально поменялась область применения, а инструментарий остался тем же самым. Возможность написать две магические строчки и получить результат была хороша только на том этапе, когда JS не использовался для вещей сложнее падающих снежинок. Для разработки сколько-нибудь сложного проекта куда важнее читаемость и поддерживаемость, потому что иначе рефакторинг автоматически превращается в ночной кошмар. А для уменьшения исходников есть минификаторы…


        1. Zenitchik
          12.05.2015 17:39
          -6

          Верно. Но суть в том, что строчки-то не магические. А общепринятые в JavaScript-разработке.

          //Анахронизм
          if(typeof value == 'undefined'){
          value = devaultValue;
          }
          //Стандарт
          value = value || defaultValue;
          

          Читаемость достигается за счёт знания языка всеми разработчиками.


          1. impwx
            12.05.2015 17:51
            +4

            Вы рано списали оператор typeof в утиль, поскольку приведенные вами варианты не однозначны. Если значение false или 0 допустимо, то оператор || может стать причиной неприятного труднообнаруживаемого бага:

            // отключить страницу невозможно
            page.isEnabled = viewModel.isEnabled || true;
            


            1. Zenitchik
              12.05.2015 18:51

              Верно.

              //По умолчанию - true
              page.isEnabled = viewModel.isEnabled !== false;
              


              1. KhodeN
                14.05.2015 16:19

                Лучше, думаю, более явно приводить:

                page.isEnabled = Boolean(viewModel.isEnabled);
                


  1. iiShrimp
    12.05.2015 13:44
    +7

    Статья хорошая, но:

    недовольны следующими моментами:

    • DOM, который многие ошибочно считают эквивалентом самого языка JavaScript, обладает очень неудачным API.
    • Когда переходишь на JavaScript с языков С и Java, то попадаешь в ловушку синтаксиса, который устроен не так, как в императивных языках. Это очень часто приводит к багам и сильно раздражает.


    В результате JavaScript обрёл довольно плохую репутацию, которой он, в общем-то, не заслуживает.


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

    > ',,,' == new Array(4) //true
    > {} + [] //0
    > [] + [] //"[object Object]"


    1. Scalar
      12.05.2015 14:49
      +12

      И еще вот это, мое любимое:

        {} + {}   // NaN
      ( {} + {} ) // "[object Object][object Object]"
      


      1. Eternalko
        12.05.2015 17:42
        +4

        А что вы ожидаете увидеть когда пишете такую лапшу из неявных преобразований?


        1. impwx
          12.05.2015 19:14
          +5

          TypeError, например.


          1. Eternalko
            12.05.2015 19:38

            Полностью отказаться от преобразования типов?


            1. impwx
              12.05.2015 22:59
              +2

              Увы, так не получится. Зато было бы неплохо ввести какой-нибудь super strict mode, где были бы запрещены большинство бессмысленных преобразований и остались только следующие:

              • Преобразование любого объекта к строке
              • Преобразование любого объекта к true / false
              • Преобразование bool и string в number

              Все остальные, особенно многозвенные (типа array -> string -> number) — это только лишний способ выстрелить себе в ногу.


              1. Zenitchik
                13.05.2015 15:20
                +1

                С такой формулировкой согласен, за исключением: должна остаться возможность определить valueOf и toString.
                По дефолту у объекта — да, лучше бы они эксцепшен кидали.


              1. Eternalko
                14.05.2015 16:01

                Зато было бы неплохо ввести какой-нибудь super strict mode

                Уже: "Experiments with Strengthening JavaScript".

                Учитывая обилие линтеров и чекеров типа flowtype можно писать
                нормально, а можно маразматичную лапшу.

                Как можно выстрелить себе в ногу неявными преобразованиями мне себе сложно представить.


    1. vlreshet
      12.05.2015 14:58
      +3

      Я, конечно, извиняюсь, но в чём тут проблема? Вам действительно приходилось когда-нибудь складывать объект и массив, или массив и массив? А первый пример так совсем неудачен — с точки зрения оператора == пустой массив на 4 элемента вполне можно преобразовать в строку ",,," (4 пустых значения разделённых запятой). А если надо действительно сравнить строку и массив (хотя опять таки, сомнительная надобность) — используем оператор ===, и получаем false, как и ожидалось. Не надо выдавать особенности динамической типизации за недостатки языка.


      1. iiShrimp
        12.05.2015 15:07
        -2

        Типизация говорите? Динамическая? Только я немного не об этом а скорей вот о чем:

        var a = new Array(4) //undefined
        a //[undefined ? 4]
        a[0] //undefined
        String(a[0]) //"undefined"
        String(a) //",,,"
        


        1. IncorrecTSW
          12.05.2015 15:15

          То что вы показали мало отношения к массивам имеет. String(undefined) === «undefined»


        1. vlreshet
          12.05.2015 15:32
          +3

          Тут я согласен, не до конца понимаю почему литерал пустой ячейки это строка «undefined», а литерал массива четырёх пустых ячеек это не 4-ре строки «undefined» а четыре пустых места. Тут как-то несогласованно получается, да.


        1. Aingis
          12.05.2015 15:51
          -2

          [].toString() // ""
          [1,2,3,4].toString() // «1,2,3,4»
          new Array(4).toString() // ",,,"

          Всё логично: нет значений — остаются только запятые.


      1. eugzol
        12.05.2015 15:54
        +10

        > Не надо выдавать особенности динамической типизации за недостатки языка

        Не динамической (dynamic) типизации, а слабой (weak) типизации.


    1. IncorrecTSW
      12.05.2015 15:00

      Это конечно всеми любимый довод, но если вы такой код пишите, то наверное с вами что то не так. =)
      На многих конференциях на эту тему говорят что то вроде «не пишите подобный код» с чем трудно не согласиться.
      Сами идеи сравнения строки с массивом или складывание объекта с массивом весьма сомнительны.


      1. iiShrimp
        12.05.2015 15:15

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


        1. Zenitchik
          12.05.2015 16:10

          Ошибок и дыр? Например?
          Чем Вам не угодило неявное приведение типов? Так трудно запомнить, что к чему приводится?


          1. impwx
            12.05.2015 19:19
            +2

            Вы удивитесь, но для кого-то это действительно трудно. Мне, например, логика этих правил оказалась неподвластна. К частью, в Typescript большинство таких неявных приведений типа '2' * '3' запрещены компилятором.


  1. impwx
    12.05.2015 15:15
    +4

    Кстати, респект переводчику за бережное отношение к форматированию статьи — в особенности за теги <code> и равномерные отступы у абзацев. Еще было бы очень здорово к переведенным терминам давать англоязычный вариант, например так: поднятие переменных.


  1. DenimTornado
    12.05.2015 15:57

    Если кому интересно, то всё это и значительно больше есть в «JavaScript Шаблоны» Стояна Стефанова от O'Reilly


  1. Filippok
    12.05.2015 16:24
    +6

    Статья исключительно про жабоскрипт, так почему же она находится в хабе C?


  1. verwolf85
    13.05.2015 09:36
    +1

    Отличная статья. Спасибо!
    Единственное что режет взгляд это «декларирование» переменных и функций — это актуально в английском тексте (declare), по русски их все-таки «объявляют» =)


  1. Louter
    15.05.2015 23:29

    Очень любопытно, но let в фф поддерживается, а в сводной таблице упорно пишется, что это не так.
    Можете запустить в консоли:
    let foo = 123; (foo === 123);

    let bar = 123; { let bar = 456; } bar === 123;

    let baz = 1; for(let baz = 0; false; false) {} baz === 1;

    При чём отмечу интересный факт: что пока эти тесты проходятся в табличке зияет 8/10 успешно, а в графах ФФ38 и ФФ39 5/10. Но после окончания они сразу окрашиваются в красный, и выставляется 0/10…


    1. rock
      18.05.2015 06:51

      А мне вот очень любопытно, почему так много людей не читает сноски.


      1. Louter
        18.05.2015 13:11

        Во-первых они чёрти где
        Во-вторых в колонке current нету сносок (т.е. не мешало бы и проверить)

        И, вообще, очень странно состояние флага ставить выше реальной поддержки (обычно окрашивают как-то иначе, когда есть условия неработоспособности). Кода с <script type='application/javascript;version=1.7'> я не встречал чего-то. В итоге автор (видимо так же не прочитав сноски объяявляет, что let поддерживается только в хроме, что не правда, которая сильно путает. Потом я слышу от сторонних разработчиков, что вот, let нельзя использовать ибо только хром умеет с ним работать, читал там-то. =/