Продолжается подписка на наши издания! Вы не забыли подписаться?

Побочные эффекты и функциональное программирование

Автор: Matthew Podwysocki
Источник: http://codebetter.com/blogs/matthew.podwysocki/archive/2008/09/12/side-effects-and-functional-programming.aspx
Опубликовано: 28.04.2009

Одно из моих первых сообщений на CodeBetter было связано с побочными эффектами и с тем, как они в неуправляемом варианте могут стать настоящим злом. Сегодня я хочу вернуться к этой теме. Несколько месяцев назад, в Редмонде, у меня была возможность посидеть с Эриком Мейером и, среди прочего, поговорить о функциональном программировании. В частности, мы обсуждали и ряд вопросов, касающихся управления побочными эффектами и состоянием в коде, и то, что в сущности ни C#, ни F# не поддерживают эту концепцию. Такие языки, как Хаскель, конечно, делают это с помощью монад IO и других подобных структур. То, что такие языки, как F# и Erlang, не являются чисто функциональными языками – другой вопрос, поскольку вам не приходится объявлять побочные эффекты (чтение БД, вывод в консоль, порождение процесса и т.д.).

О функциональном программировании Эрик будет говорить на JAOO. Я думаю, что в те времена, когда меняется закон Мура, мы должны признать, что функциональному программированию принадлежит уникальное место из-за его чистоты и постоянства. Давайте рассмотрим некоторые из этих вопросов в аспекте работы с функциональными конструкциями и побочными эффектами, и посмотрим, какого рода влияние они могут оказать на нас.

Проблема чистоты

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

Тучи сгущаются, когда на столе появляются отложенные (ленивые) вычисления. Одной из главных достижений C # 2,0 и, в особенности, 3.0 с LINQ, была идея ленивых вычислений с использованием ключевого слова yield. Она позволила нам отложить работу до тех пор, пока она не станет абсолютно необходимой. Это одна из особенностей функционального программирования, особенно таких ленивых языков, как Haskell.

Давайте посмотрим на фрагмент кода, написанного на F# и его эквивалента на C #, и определим порядок побочных эффектов. Можно ли их предсказать? Когда они случаются? Получу ли я в конце список целых чисел?

F#

#light 

let divisible_by_two n =  
  printf "%i делится на два?" n 
  n % 2 = 0 

let divisible_by_three n = 
  printf "%i делится на три?" n 
  n % 3 = 0 
   
let c1 = {1 .. 100} |> Seq.filter divisible_by_two 
let c2 = c1 |> Seq.filter divisible_by_three 

c2 |> Seq.iter(fun c -> printfn "%i" c)

C#

static bool DivisibleByTwo(int n) 
{ 
    Console.Write("{0} делится на два?", n); 
    return n % 2 == 0; 
} 

static bool DivisibleByThree(int n) 
{ 
    Console.Write("{0} делится на три?", n); 
    return n % 3 == 0; 
} 

var c1 = from n in Enumerable.Range(1, 100) 
         where DivisibleByTwo(n) 
         select n; 

var c2 = from n in c1 
         where DivisibleByThree(n) 
         select n; 

foreach (var c in c2) Console.WriteLine("{0}", c);

Ну, как вы думаете? Если вы скажете, что список в конце прекрасно выведется, вы жестоко ошибетесь. На самом деле результат будет выглядеть примерно так:


Почему? Хм, потому что в LINQ, возвращаемый тип – всегда IEnumerable<T>, а в случае F# я использовал последовательности, которые, в отличие от List<'a>, вычисляются лениво. Что можно с этим сделать? Это уже другой вопрос. К сожалению, языковые конструкции в .NET не запрещают таких вещей, так что нет реального способа предотвратить такие вещи. Придется вам самому бдительно следить, чтобы этого не случилось. Spec# может немного помочь, поскольку может обеспечить, что на протяжении данной операции не произойдет изменения состояния, но не защитит и не предупредит вас о таких вещах, как запись в консоль, в логи, в БД и т.д. Перейдем к следующему примеру и посмотрим, что происходит.

Управление исключениями и ленивые вычисления

Еще одна интересная тема – управление исключениями при использовании ленивых вычислений. Когда дело доходит до лениво вычисляемых функций и структур, обработка возможных исключений может стать очень непростым вопросом. Посмотрим на пример попытки перехвата исключения, которое может возникнуть в коде из-за деления на ноль. Как вы думает, что случится? Будет ли оно перехвачено и вернет ли нам просто пустую коллекцию? Посмотрим:

F#

#light 

let numbers = seq[1 ; 5; 2; 3; 7; 9; 0] 

let one_over n = 
  try 
    n |> Seq.map(fun i -> 1 / i) 
  with 
    | err -> seq[] 
     
numbers |> one_over |> Seq.iter(fun x -> printfn "%i" x)

C#

static IEnumerable<int> OneOver(IEnumerable<int> items) 
{ 
    try 
    { 
        return from i in items select 1/i; 
    } 
    catch 
    { 
        return Enumerable.Empty<int>(); 
    } 
} 

var numbers = new[] {1, 5, 2, 3, 7, 9, 0}; 
foreach (var number in OneOver(numbers))  
    Console.WriteLine("{0}", number);

Чего вы ожидаете? Удалось ли нам перехватить исключение? Ответ – нет, поскольку возвращаемая структура не вычисляется до более поздних времен, у этого блока try/catch нет никакой возможности сработать. Теперь вопрос к вам: как вы собираетесь с этим бороться?

Логика здесь в корне порочна. Давайте перейдем к другому сценарию, на этот раз работающему с управлением ресурсами.

Управление ресурсами и ленивые вычисления

Еще одна область исследований – управление ресурсами и ленивые вычисления. Как вы обеспечите освобождение ресурсов после завершения вычислений? Относительно несложно допустить мелкие ошибки, которые затем будут преследовать вас. Посмотрим на краткий пример использования ленивых вычислений и чтения файла до конца. Что происходит в следующем коде?

F#

#light 

open System.IO 

let readLines =  
  use reader = File.OpenText(@"D:\Foo.txt") 
  let lines() = reader.ReadToEnd() 
  lines 

printfn "%s" (readLines())

C#

Func<string> readLines = null; 

using(var stream = File.OpenText(@"D:\foo.txt")) 
    readLines = stream.ReadToEnd; 

Console.WriteLine(readLines());

Ответ, конечно, в том, что мы получим исключение ObjectDisposedException из-за того, что поток уже давно закрыт к моменту вызова функции readLines. Наш reader уже давно выпал из области видимости, вот мы и получаем исключение. Так как же это исправить?

Замыкания и ленивые вычисления

Последний вопрос касается создания экземпляров переменных, замыканий и их значения в аспекте ленивых вычислений. Рассмотрим краткий пример возможных проблем:

C#

var contents = new List<Func<int>>(); 
var s = new StringBuilder(); 

for (var i = 4; i < 7; i++) 
    contents.Add(() => i); 

for (var k = 0; k < contents.Count; k++) 
    s.Append(contents[k]()); 

Console.WriteLine(s);

Можно было ожидать, что в данном случае результат будет 456. Но это вовсе не так. На самом деле вы получите результат 777. Почему? Это связано со способом создания вспомогательного класса для этого замыкания компилятором C#. Если у вас, как у меня, есть Resharper, вы заметите, что он выдает предупреждение "Access to modified closure". Мы можем изменить это, чтобы получить локальную переменную внутри конструкции цикла замыкания для надлежащей инициализации значения. Если мы так и поступим и изменим код, он будет выглядеть следующим образом:

C#

var contents = new List<Func<int>>(); 
var s = new StringBuilder(); 

for (var i = 4; i < 7; i++) 
{ 
    var j = i; 
    contents.Add(() => j); 
} 

for (var k = 0; k < contents.Count; k++) 
  s.Append(contents[k]()); 

Console.WriteLine(s);

Jason Olson привел хорошее объяснение в своем сообщении "Lambdas – Know Your Closures" (http://www.managed-world.com/2008/06/13/LambdasKnowYourClosures.aspx). Но как в эту картину вписывается F#? Рассмотрим первый пример кода, написанный на F#, и посмотрим, какой результат мы получим.

F#

#light 

open System.Text 

let contents = new ResizeArray<(unit -> int)>() 
for i = 4 to 6 do 
  contents.Add((fun () -> i)) 
  
let s = new StringBuilder() 
for k = 0 to (contents.Count – 1) do 
  s.Append(contents.[k]()) |> ignore 
  
printfn "%s" (s.ToString())

При запуске этого кода мы, как и ожидалось, получим "456". Почему это так? Компилятор F# генерирует код несколько не так, как компилятор C#, как мне кажется, несколько менее подверженным ошибкам способом.

Заключение

Хотелось бы знать, как бы вы справились с этими проблемами? Что бы вы сделали по-другому в каждом из примеров, чтобы получить правильный результат ленивых вычислений?

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

Я очень рекомендовал бы вам посмотреть презентацию Эрика, на которую я дал ссылку выше, поскольку она содержит идеи, которые могут улучшить вас как программиста. Кроме того, посмотрите видео "Towards a Programming Nirvana" Симона Джонса и Эрика Мейера на Channel 9 (http://channel9.msdn.com/posts/Charles/Simon-Peyton-Jones-Towards-a-Programming-Language-Nirvana/). Оно определенно откроет вам глаза.


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

Copyright © 1994-2016 ООО "К-Пресс"