Pull to refresh

Оптимизация хвостовой рекурсии в .NET и Nemerle

Reading time14 min
Views6.3K
Недавно chiaroscuro написал провокационную статью с жёлтым заголовком «Почему циклы должны умереть». Её обсуждение заняло около 180 комментариев, но сама статья ушла в минус и не попала на главную, не смотря на то, что она содержала здравую мысль об использовании рекурсии вместо циклов.

В этой статье я дополню его пост и приведу примеры реализации на одном из лучших языков под .NET — Nemerle, а так же сделаю холиварное заявление о преимуществе .NET перед Java.

Во-первых, мне хочется отметить один плюс использования рекурсии вместо циклов, которое упустил chiaroscuro — декомпозицию. Рассматривая программу на популярном императивном языке (Java, C#, Pascal, C) мы имеем дело с потоком команд. Средством декомпозиции этого потока, скажем для повторного использования кода или тестирования, является разбиение на процедуры, которые раньше назывались подпрограммами. В названии «подпрограмма» заключена их суть — мы берем основной поток команд, вырезаем кусок, заворачиваем его в некий workaround и процедура готова, но она является тем же самым потоком, только поменьше. Мы пришли к тому, что в языке одни средства служат вычислению — императивные инструкции — те, из которых состоит поток вычислений, другие же обслуживают этот поток — процедуры. В случае ФЯ, рекурсия выполняет те же задачи, что и циклы в императивных языках, а так же служит средством декомпозиции, то есть программа, написанная на ФЯ сама разваливается на отдельные логические части. Сразу стоит заметить, что возникает проблема замусоривания глобального пространства имен; но во многих ФЯ она элегантно решается — функция внутри своего тела может объявить функцию, которая видна только ей.

Следующее на что стоит обратить внимание в статье — комментарии, во многих из них хабралюди твердили как один «рекурсия это красивое эллегантное но жрущее ресурсы зло». В общем случае это так, но существует класс рекурсивных алгоритмов, которые по потреблению ресурсов сравнимы с разворотом этой рекурсии в цикл — такая рекурсия называется хвостовой рекурсией. Распознать её очень просто: если функция получила значение функции, ничего с ним не сделала и вернула его как свое значение, то мы имеем дело с хвостовой рекурсией, в случае рекурсии конечно же. Легко понять почему так происходит — если с значением не происходит действий, то можно освободить стек от локальных переменных до того, как вызвать вторую функцию, кроме того в качестве адреса возврата можно подставить не себя, а свой адрес возврата.

Это были дополнения к той статье, а теперь основное замечание: полностью отказываться от циклов глупо и, в настоящее время, имеет смысл только, если это академическое программирование целью которого исследовать оптимизацию компиляторов; когда программирование хлеб насущный, то нужно использовать те средства, которые наиболее адекватны задаче в текущий момент. Впрок учить haskell до того момента, пока его не научать автоматически параллелить на множество процессоров, я не буду. Я выберу язык, который предоставляет мне выбор инструмента, с помощью которого я могу быстро решить свою задачу. То есть язык должен быть гибридным и поддерживать как функциональную, так и объектно-ориентированную парадигму. Таким языком является Nemerle.

Внимательный читатель может спросить причем здесь Java. Дело в том, что виртуальная машина Java, в отличии от .NET не поддерживает оптимизацию хвостовой рекурсии. В прочим, для многих может оказаться новостью, что она есть в .NET, так как самый популярный язык программирования для него — C# — её не поддерживает. После того, как я узнал, что оптимизация хвостовой рекурсии поддерживается в .NET у меня появилась идея протестировать её, что и стало причиной этого поста.

Подопытным кроликом станет алгоритм нахождения последних 5 цифр n-ого числа Фибоначчи, а тестироваться он будет на языках Nemerle, C# и Perfect C#, назовем так язык, который полностью совместим с C#, но обладает свойством оптимизации хвостовой рекурсии. Что бы получить сборку скомпилированную Perfect C#'ом я скомпилировал код на C#, декомпилировал его с помощью ildasm'а добавил il инструкцию tail перед вызовом функции и скомпилировал il код ilasm'ом. Ниже представлены коды программ на этих языках:

Код на Nemerle                                 Код на PerfectC#
                                               
public module Fibonacci                        .class public auto ansi beforefieldinit 
{                                                Fibonacci extends [mscorlib]System.Object
  public Last5(number : int) : int             {
  {                                              .method private hidebysig static int32 
    def F2(counter,a,b)                            F2(int32 counter, int32 a, int32 b) 
    {                                                cil managed
      if (counter==0)                            {
        a                                          .maxstack  8
      else                                         ldarg.0
        F2(counter-1,b,(a+b)%100000)               brtrue.s   IL_0005
    }                                              ldarg.1
    when(number<0) throw Exception();              ret
    F2(number,1,1)                                 ldarg.0
  }                                                ldc.i4.1
}                                                  sub
                                                   ldarg.2
                                                   ldarg.1
Код на C#                                          ldarg.2
                                                   add
                                                   ldc.i4     0x186a0
public class Fibonacci                             rem
{                                                  tail.
  static private int F2(int counter, int a, int b) call int32 Fibonacci::F2(int32,int32,int32)
  {                                                ret
    if (counter == 0) return a;                  }
    return                                     
      F2(counter - 1, b, (a + b)%100000);        .method public hidebysig static int32 
  }                                                Last5(int32 number) cil managed
                                                 {
  static public int Last5(int number)              .maxstack  8
  {                                                ldarg.0
    if (number < 0) throw new Exception();         ldc.i4.0
    return F2(number, 1, 1);                       bge.s      IL_000a
  }                                                newobj instance 
}                                                    void [mscorlib]System.Exception::.ctor()
                                                   throw
                                                   ldarg.0
                                                   ldc.i4.1
                                                   ldc.i4.1
                                                   call int32 Fibonacci::F2(int32,int32,int32)
                                                   ret
                                                 }
                                               
                                                 .method public hidebysig specialname 
                                                   rtspecialname instance void  .ctor() 
                                                     cil managed
                                                 {
                                                   .maxstack  8
                                                   ldarg.0
                                                   call instance 
                                                     void [mscorlib]System.Object::.ctor()
                                                   ret
                                                 }


А теперь само интересное — результаты тестирования. Для разминки я решил посчитать последние 5 цифр 40000ого числа Фибоначчи: Nemerle справился за 0.58 миллисекунды, PerfectC# за 2.389 миллисекунды, а С# за 0.782 миллисекунды. После этого я решил испытать алгоритм на 100000ым числе Фибоначчи и получил следующие результаты: Nemerle — 1.421 миллисекунды, PerfectC# — 3.244 миллисекунды, С# пал с StackOverflowException.

Результаты таковы, что Nemerle занимает первое место, а второе делят PerfectC# и C#, так как второй валится на глубокой рекурсии, но нам где не валиться обгоняет PerfectC#.

UPD
Эталонная реализация последних 5 цифр 40000 чисела Фибаначчи — 0.565 миллисекунды и 100000 числа Фибаначчи — 1.407 миллисекунды.
Tags:
Hubs:
+20
Comments105

Articles

Change theme settings