Синтаксический сахар или C++ vs. Nemerle :)

Автор: Чистяков Влад aka VladD2
The RSDN Team

Источник: RSDN Magazine #1-2006
Опубликовано: 24.05.2006
Версия текста: 1.0
Расширение синтаксиса
Заключение

Эта статья навеяна вот этим сообщением: http://rsdn.ru/Forum/Message.aspx?mid=1779897&only=1, а точнее, фразой: «Зачем вводить в язык то, что реализуется библиотекой?»

Я всегда был недоволен базовыми возможностями C++. Ну непонятно мне, почему в C++ нет делегатов и т.п. Ответы на вопрос «почему это так?» разнообразны, но, тем не менее, все их можно свести к фразам «Зачем вводить в язык то, что реализуется библиотекой?» и «Язык должен включать только базовые вещи, а весь синтаксический сахар должен реализоваться в виде библиотек».

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

ПРИМЕЧАНИЕ

Примерно те же идеи можно слышать от Вирта. Его последнее детище Оберон 2 тоже подается как язык в котором есть только базовые вещи. Но в отличии от сторонников C++ Вирт вообще не говорит о расширении языка.

В общем, я никак не мог сформулировать (даже для себя), что мне конкретно не нравится в C++. Но появление Nemerle и анализ его дизайна дали мне ответ на этот вопрос.

Именно этими соображениями и хочется поделиться.

Итак, что же я понял?

Понял я то, что в C++, при верной, в общем-то, постановке задачи «Ввести в язык базовые вещи, а остальное реализовать на нем», его разработчики поступили в точности наоборот.

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

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

Разработчики Nemerle, не декларируя принципов «Зачем вводить в язык то, что реализуется библиотекой?» и «Язык должен включать только базовые вещи, а весь синтаксический сахар должен реализоваться в виде библиотек», на самом деле воплотили их на практике намного лучше, чем разработчики C++.

Так в чем же удача Nemerle-овцев и ошибка C++-ников?

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

Ошибочны сами рассуждения о синтаксическом сахаре. Его нет!

ПРИМЕЧАНИЕ

Пользуясь случаем, передаю привет Нэо и ложке. :)

Просто одни и те же вещи можно выразить разными путями.

Возьмем простой пример. Перебор чего бы то ни было. Его можно реализовать специальными синтаксическими конструкциями вроде foreach, простейшим циклом while, хвостовой рекурсией или свирепыми конструкциями вроде С-шного for-а.

Кто-то из вас усомнился, что они равноценны? К примеру, через foreach нельзя выразить перебор простейшего перебора целых чисел? Ерунда! Вот как это может выглядеть:

      foreach (i in [0 .. 100])
  // делаем что-то с i

Кому-то непонятна эта запись? Она яснее ясного. Даже не знакомый с данным синтаксисом домыслит, что означает [0 .. 100]. А уж «foreach что-то in нечто» вообще читается как простая английская фраза.

Точно так же while можно использовать для перебора значения в коллекциях и итераторах. Не проблема написать:

      while (iterator.MoveNext())
{
  // делаем что-то с iterator
}

И точно так же все это можно выражать через хвостовую рекурсию (код на Nemerle):

      def Loop1(iterator, accumulator)
{
  if (iterator.MoveNext())
    // делаем что-то с iterator и accumulator
    Loop1(iterator, accumulator); // возвращаем значение рекурсивного вызоваelse
    accumulator // возвращаем значение аккумулятора
}

def Loop2(i, accumulator)
{
  if (i < 100)
    // делаем что-то с i и accumulator
    Loop(i + 1, accumulator);
  else
    accumulator 
}

// Запускаем «циклы».
Loop1(iterator, xxx);
Loop2(0, yyy);

Что из приведенного мной является «синтаксическим сахаром», а что – базовыми вещами? Ведь конструкции взаимозаменяемы?

Определение из Wikipedia (http://en.wikipedia.org/wiki/Syntactic_sugar) говорит, что синтаксический сахар, не меняя «выразительности», делает синтаксис более удобным для использования человеком.

Однако в определенных случаях некоторые конструкции более выразительны. Иногда самым выразительным циклом является while. Иногда – foreach. А иногда – хвостовая рекурсия (например, в алгоритмах обхода дерева).

Так вот, правильный ответ, на мой взгляд, таков – ничто из этого не является синтаксическим сахаром. Это просто взаимозаменяемые конструкции.

Теперь вспомним еще раз о постулатах, которыми так гордится C++. «Язык должен включать только базовые вещи».

Раз так, то логично оставить в языке одну из конструкций, а остальные выразить через нее.

Остается ответить на два вопроса.

  1. Какую именно возможность оставить?
  2. Как ввести в язык остальные синтаксические конструкции?

Сосредоточимся на первом вопросе.

Чтобы понять, «Какую именно возможность оставить?», нужно понять, какая из возможностей больше похожа на базовую.

Ясно, что циклы практически равнозначны. И выбор между ними является выбором наименее навороченной конструкции, требующей меньше других возможностей языка. Думаю, что не ошибусь, если скажу, что while – идеальный претендент на роль базового типа цикла.

Однако если создатели языка не хотят нарваться на проблемы вроде тех, что поимел в свое время г-н Вирт, любой цикл обязан быть снабжен такими конструкциями, как break и continue.

Уже что-то не то. Не правда ли?

Теперь вспомним об извечной проблеме выхода из вложенных циклов и получаем распухание дизайна.

Задумаемся. Если мы введем замечательный while, break и continue, то приведет ли это к тому, что мы вообще сможем отказаться от функций? Вряд ли. Мы сможем отказаться от локальных (вложенных в методы или глобальные функции) функций. Но методы и глобальные функции все равно придется вводить. Между тем, локальные функции – ни что иное, как подвид функции, то есть одна из самых базовых вещей, которые только можно представить в языке, претендующем на звание процедурного или функционального (или на то и другое сразу).

А нельзя ли обойтись только рекурсией и функциями?

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

Оператор break вообще не нужен, так как он аналогичен выходу из функции (return в C/C++/C# или последнему выражению в блоке выражения в Nemerle).

Оператор continue заменяется рекурсией. Например, такой код:

      using System;

class Program
{
  staticbool Predicate(int value) { return value % 3 == 0; }

  staticvoid Main()
  {
    int result = 0;

    for (int i = 0; i < 100; i++)
    {
      if (Predicate(i))
        continue;

      result++;
    }

    Console.WriteLine("result = {0}", result);
  }
}

легко переписывается следующим образом:

      using System;

def Predicate(value) { value % 3 == 0 }

def Loop(i, accumulator)
{
  if (i < 100)
    if (Predicate(i))
      Loop(i + 1, accumulator)
    else
      Loop(i + 1, accumulator + 1);
  else
    accumulator
}

def result = Loop(0, 0); // Запускаем цикл.

Console.WriteLine($"result = $result \n");
ПРЕДУПРЕЖДЕНИЕ

Все примеры кода на Nemerle в этой статье для краткости используют возможность Nemerle располагать код в глобальном пространстве имен. Если в приложении отсутствует явное определение функции Main(), то весть код размещенный в глобальном пространстве имен считается кодом подразумеваемой функции Main(). Это позволяет сократить код примеров и маленьких утилит. Так что не стоит удивляться встречая определения локальных фукнций «def Func(...) { ... }» прямо в после директив «using».

Это же работает и в более сложных случаях, причем решается проблема continue во вложенных циклах. Вот усложненный пример на C#:

      using System;

class Program
{
  staticbool Predicate(int value) { return value % 3 == 0; }

  staticvoid Main()
  {
    int result = 0;

    for (int j = 0; j < 100; j++)
    {for (int i = 0; i < 100; i++)
      {
        if (Predicate(i))
          continue;

        if (Predicate(i + j))
          gotocontinue_iner_loop;

        result++;
      }
    continue_iner_loop: ;
}

    Console.WriteLine("result = {0}", result);
  }
}

Красным выделено использование оператора goto. Его использование по праву считается плохим стилем, но в данном случае оно четко передает суть проблемы. А вот как то же самое можно записать в функциональном стиле:

      using System;

def Predicate(value) { value % 3 == 0 }

def Loop(j, accumulator)
{
  if (j < 100)
  {
    def NestedLoop(i, accumulator)
    {
      if (i < 100)
      {
        if (Predicate(i))
          NestedLoop(i + 1, accumulator);
        elseif (Predicate(i + j))
          accumulator;
else
          NestedLoop(i + 1, accumulator + 1);
      }
      else
        accumulator
    }

    Loop(j + 1, NestedLoop(0, accumulator));
  }
  else 
    accumulator
}


def result = Loop(0, 0); // Запускаем цикл.

Console.WriteLine($"result = $result \n");

Оба примера выводят на консоль:

result = 2277

Заметьте, goto не понадобился! Не понадобился и continue.

ПРИМЕЧАНИЕ

Кстати, функций Loop() и NestedLoop() компилятор Nemerle в коде не оставит. Он распознает хвостовую рекурсию и заменит ее переходами внутри функции с модификацией значений параметров.

Таким образом, потери в производительности у этого подхода при использовании Nemerle нет.

Выходит, что функциональная запись является более общей?

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

ПРИМЕЧАНИЕ

Вследствие оптимизаций компилятора копирования может и не быть. Но на логическом уровне оно обязано быть.

Большинство современных ИЯ (императивных языков) не допускают возврата из функций множества значений. C# и C++ не являются исключением.

Циклы же позволяют задействовать локальные переменные. Их модификация заменяет необходимость передачи и возврата множества значений на каждой итерации цикла.

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

Таким средством в современных функциональных языках (ФЯ) являются кортежи (tuples).

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

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

      using System;

// Формирование кортежа и присвоение ему имени.deftuple1 = (1, "Test", DateTime.Now);
Console.WriteLine($"tuple1 = $tuple1");

// Вычленение значений из кортежа. def (i, str, date) = tuple1;
// Теперь можно использовать значения i, str и date как // обычные переменные.
Console.WriteLine($"i = '$i'; str = '$str'; date = '$date';");

// Функция, принимающая три параметра, и возвращающая кортеж, // составленный из их значений.def Function1(i, str, date : DateTime)
{
  (i + 1, str + "!", date + TimeSpan.FromDays(2))
}

// Передача одного кортежа в качестве замены параметров метода с// множеством параметров.deftuple2 = Function1(tuple1);
Console.WriteLine($"tuple2 = $tuple2");

// Вложенный вызов функции, возвращающей кортеж. Возвращаемый кортеж// используется как список параметров для следующего вызоваdeftuple2 = Function1(Function1(Function1(tuple1)));
Console.WriteLine($"tuple2 = $tuple2");

Этот код выводит:

tuple1 = (1, Test, 14.03.2006 11:11:45)
i = '1'; str = 'Test'; date = '14.03.2006 11:11:45';
tuple2 = (2, Test!, 16.03.2006 11:11:45)
tuple2 = (4, Test!!!, 20.03.2006 11:11:45)

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

Поясню этот термин. Любой код пишется в каком-то контексте. Так, методы описываются в контексте класса, а локальные переменные – в контексте метода. Метод потому и называется методом, что позволяет использовать другие элементы, определенные в контексте класса (другие методы, свойства, поля, вложенные классы…). Точно так же вложенная функция может использовать контекст, в котором она определена (все, что видно в этой точке).

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

      using System;

mutable value = 0;

def IncrementValue() { value++; }

IncrementValue();

Console.WriteLine($"value = $value \n");

выведет на консоль «value = 1», так как вызов функции IncrementValue() использует захваченную ею переменную value. Это и называется замыканием.

ПРИМЕЧАНИЕ

В C# 2.0 анонимные методы так же могут замыкаться на контекст методов в которые они вложены, но их применение усложняется тем, что их нельзя использовать без делегата-посредника.

Захваченная переменная не обязана изменяться. Ее можно использовать в режиме «только для чтения»:

      using System;

mutable value = 0;

def IncrementValue() { value+ 1; }

// Эти вызовы ничего не делают, так как их возвращаемое // значение игнорируется.
IncrementValue();
IncrementValue();
IncrementValue();

Console.WriteLine($"IncrementValue() = $(IncrementValue()) \n");

Этот пример выводит «IncrementValue() = 1» и порождает три предупреждения, так как компилятор Nemerle, в отличие от C# и C++, считает игнорирование возвращаемого значения выражений ошибкой. Думаю, многие в свое время долго не могли понять, почему выражение:

      string str = "Test";
str.Replace("Test", ":)");

не приводит к изменению строки «str». Подобные ошибки – не редкость в C#/C++.

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

_ = IncrementValue();
ПРИМЕЧАНИЕ

Любопытно, что замыкания родились в ФЯ, а в ФЯ побочных эффектов (таких, как изменение значения переменной) стараются не допускать. Однако реально замыкание не обязано не порождать побочных эффектов, что я и продемонстрировал выше.

Итак, теперь можно осознанно ответить на вопрос, заданный в начале статьи: «Какую именно возможность оставить?»

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

Чтобы вложенные функции было удобно использовать в языке в качестве базовой единицы, нужно ввести кортежи и замыкания.

Как вы понимаете, именно так и сделано в Nemerle.

Расширение синтаксиса

Однако не всегда удобно писать весь код в функциональном стиле. Иногда while, for или foreach могут оказаться куда более выразительными. Ведь если вы представляете себе алгоритм в виде цикла, то и записать его проще с использованием цикла (если, конечно, при этом не появляется проблем вроде использования оператора goto).

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

Разные хитрые извороты позволяют изменить семантику имеющегося синтаксиса в этом языке, но вы не в силах изменить сам синтаксис.

Конечно, можно реализовать цикл как-то так:

      #define while(condition, body) ля-ля-ля

Но тогда и использовать его придется довольно странно:

      int i = 0;

while (i < 100, 
  ...
  i++;
)

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

Что же делать?

Или все же вводить в язык операторы вроде while, нарушая тем самым самими же сформулированные принципы дизайна языка. Или ввести вместо этого в язык полноценные средства расширения синтаксиса.

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

Вот как выглядит реализация цикла while на Nemerle:

      macro @while (cond, body)
syntax ("while", "(", cond, ")", body) 
{
  def loop = Nemerle.Macros.Symbol(Util.tmpname("while_"));

  <[ 
    $(PT.Name.Global("_N_break") : name) :
    {
      def $(loop : name)() : void
      {
        when ($cond)
        {
          $(PT.Name.Global("_N_continue") : name) :
          {
            $body 
          }

          $(loop : name)()
        }
      }

      $(loop : name)(); 
    }
  ]>
}

Этот простой макрос позволяет использовать в коде цикл while, полностью аналогичный таковому в C# и C++.

Интересно, что в Nemerle с помощью макросов реализованы return, break, continue, unchecked, checked, yield, if/else, while, repeat (вид цикла), when (замена if без else), for, unless (по сути, when(!...)), оператор using, lock, do/while, foreach, lambda (укороченный синтаксис для анонимных функций), а также операторы &&, ||, %|| (битовое «или» с проверкой на != 0), %&& (битовое «и» с проверкой на != 0), %^^ (XOR с проверкой на != 0), ++, --, +=, -=, *=, /=, <<=, >>=, %=, |=, &=, ^=, <-> (обмен значений переменных), ::= (модифицирующее добавление элемента в начало связанного списка).

Да, кстати! Конструкция, которой я всю эту статью пользовался для форматированного вывода значений переменных в строку (строка, предваряемая символом «$»), тоже является макросом. :) Этот макрос вычленяет из строки значения, помеченные символом $, и преобразует их в выражения, значение которых вычисляется во время исполнения и конкатенируется в результирующую строку.

Заключение

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

А пока что хочется подытожить. C++ проигрывает это сравнение молодому языку Nemerle.

Приведенные в начале статьи лозунги апологетов C++ не реализуются в C++ полноценно. Напротив, Nemerle полностью отвечает этим критериям.

Этот язык не только обладает мощными возможностями расширения синтаксиса, но и построен по указанным принципам.


Эта статья опубликована в журнале RSDN Magazine #1-2006. Информацию о журнале можно найти здесь