Содержание


Rust - новый язык программирования: Часть 12. Замыкания. Do-выражения

Comments

В предыдущих статьях цикла был сделан общий обзор языка программирования Rust, обсуждались основы синтаксиса: были описаны простейшие компоненты (переменные, простые типы данных, операторы и расширения), управляющие конструкции и составные структуры данных, функции и деструкторы. Особое внимание было уделено модели памяти в целом, концепции владения объектами, общим принципам управления памятью, использованию собственных и управляемых блоков общей памяти, а также заимствованных указателей. В отдельных статьях рассматривались средства ввода/вывода, а также векторы и строки и связанные с ними сущности - контейнеры и итераторы. Данная статья посвящена замыканиям и do-выражениям языка Rust.

Замыкание (closure) само по себе не является конкретным элементом синтаксиса, скорее это свойство, функциональная особенность языка программирования, позволяющая захватывать ("замыкать") внешнюю среду, содержащую конкретные связанные переменные, внутри определения функции (процедуры) или набора связанных функций. Тем не менее, начать рассмотрение замыканий следует всё же с синтаксической конструкции, которая так или иначе связана с замыканиями, - это лямбда-функция или лямбда-выражение (lambda expression).

1. Лямбда-выражение

Лямбда-выражение или лямбда-функция, которую иногда называют ещё анонимной функцией или функцией без имени, позволяет определить функцию и трактовать её как значение в пределах одного выражения. Лямбда-функции, когда-то считавшиеся прерогативой так называемых "функциональных языков программирования", уже достаточно давно поддерживаются в более широко распространённых процедурных языках (Python, C++, C# и др) и хорошо знакомы большинству программистов.

Лямбда-выражение состоит из списка идентификаторов, помещённого между символами "вертикальная черта" (pipe-символ), за которым следует вычисляемое выражение:

| список_идентификаторов | выражение

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

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

Одним из самых важных свойств лямбда-выражения является то, что оно захватывает окружающую его вычислительную среду, то есть, осуществляет замыкание, чего обычные функции в своих определениях делать не могут. Форма, в которой захватываются элементы среды, зависит от типа функции, логически выводимого для лямбда-выражения. В самой простой и наименее затратной форме, являющейся аналогом выражения &fn() {}, лямбда-выражение захватывает элементы окружающей среды по ссылке, используя наиболее подходящие для этого случая заимствованные указатели (borrowed pointers) на все внешние переменные, упоминаемые в теле лямбда-функции. С другой стороны, компилятор может решить, что лямбда-выражение должно копировать или перемещать значения (в зависимости от их типа) из внешней среды в замыкаемую среду данного лямбда-выражения.

Ниже, в листинге 1 приведён пример определения функции, которая в качестве аргумента принимает функцию высшего порядка. Затем определённая функция вызывается с аргументом в виде лямбда-выражения.

Листинг 1. Использование лямбда-выражения при передаче функции как аргумента в другую функцию
fn ten_times( f: &fn(int) ) {
  let mut i = 0;
  while i < 10 {
    f(i);
    i += 1;
  }
}

ten_times( |j| println( fmt!( "The number is %d", j ) ) );

2. Замыкания

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

Листинг 2. Некорректное обращение к переменной, определённой вне функции
let some_number = 13;

fn trespasser() -> int {
  return some_number;   // Ошибка: запрещено обращаться к переменным вне тела функции
}

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

Листинг 3. Определение и использование замыкания в форме лямбда-функции
fn call_closure_with_arg( cl_func: &fn( int) ) { cl_func(4); }

let captured_coefficient = 1024;
let closure = |arg| println( fmt!("Исходное значение = %d, скорректированное = %d",
                             arg, arg * captured_coefficient) );
call_closure_with_arg( closure );

Тот факт, что после списка аргументов (располагаемых между pipe-символами, как предписано при определении лямбда-функции) может быть указано только одно выражение, может показаться существенным неудобством. Но здесь следует вспомнить о том, что одиночным выражением в Rust считается также блок выражений, заключённый в фигурные скобки: { выражение1; выражение2; ... }, а значением такого блока будет результат вычисления самого последнего выражения, если за ним не следует точка с запятой, в противном случае блок даёт "пустой" результат ().

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

let square = |x: int| -> uint { (x * x) as uint };

Замыкания могут быть представлены в различных формах, в зависимости от конкретной решаемой задачи и её контекста. Чаще всего используется стековое замыкание (stack closure), которое имеет тип &fn и имеет прямой доступ к локальным переменным во внешней (относительно данного замыкания) области видимости, как показано в листинге 4.

Листинг 4. Простой пример стекового замыкания
let mut max = 0;
[67, 12, 94, 5, 37, 21, 17, 59].map( |x| if *x > max { max = *x } );

Стековые замыкания работают весьма эффективно, поскольку захватываемая ими внешняя среда размещена в стеке, и обращения к захваченным локальным переменным выполняются через указатели. Для гарантии того, что время существования стековых замыканий не будет превышать время существования "замыкаемых" ими локальных переменных, стековые замыкания в Rust не являются first-class-объектами (подробнее об этом см.предыдущую статью). Это означает, что они могут быть использованы только в качестве аргументов. Стековое замыкание нельзя сохранить в структуре данных или вернуть из функции, как результат. Несмотря на это ограничение, стековые замыкания достаточно широко используются в Rust-программах.

2.1. Собственные замыкания

Собственные замыкания (owned closures) имеют тип ~fn по аналогии с типом ~ указателя и захватывают объекты, которые могут быть переданы между задачами безопасным образом. Они копируют замыкаемые значения почти так же, как это делают управляемые замыкания (managed closures), но при этом собственные замыкания становятся ещё и владельцами захваченных объектов, то есть, доступ к захваченным объектам из других частей кода запрещается. Собственные замыкания используются в программах, обеспечивающих параллелизм выполнения, главным образом, в программах с несколькими порождёнными задачами (tasks).

2.2. Совместимость различных видов замыканий

В Rust замыкания обладают удобным свойством совместимости типов: любой вид замыканий можно передавать (причём не только в качестве аргументов, но и в качестве возвращаемого типа) в функции, в которых ожидается тип &fn(). Таким образом, при написании функции высшего порядка (high-order function), которая только лишь выполняет вызов своего аргумента-функции и больше ничего не делает с этим аргументом, практически всегда можно объявлять тип аргумента, как &fn(). В этой ситуации вызывающая сторона может передавать в функцию высшего порядка замыкание любого вида наряду с обычными функциями, как показано в листинге 5.

Листинг 5. Функция высшего порядка, принимающая в качестве аргументов любые замыкания
fn call_twice( f: &fn() ) { f(); f(); }
let closure = || { "Это замыкание; тип не имеет значения"; };
fn function() { "Это обычная функция"; }
call_twice( closure );
call_twice( function );

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

3. Do-выражения

Do-выражение (do expression) предоставляет в распоряжение программиста более привычный блоковый синтаксис записи лямбда-выражения, включающий специализированное преобразование возвращаемого выражения внутри сопровождающего do-блока:

do выражение [ |список_идентификаторов| ] { блок };

Здесь квадратные скобки не являются элементом синтаксиса do-выражения, а сообщают о том, что список идентификаторов не обязателен.

Список идентификаторов (если он задан) и блок обрабатываются так же, как если бы они составляли лямбда-выражение. Таким образом, оба показанных ниже вызова являются равнозначными:

f( |j| g(j) );
do f |j| { g(j); }

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

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

Листинг 6. Функция прохода по вектору с вызовом произвольной обрабатывающей функции
fn each( v: &[int], oper: &fn( v: &int ) ) {
  let mut n = 0;
  while n < v.len() {
    oper( &v[n] );
    n += 1;
  }
}

Если при вызове определённой выше функции использовать замыкание, то это будет выглядеть следующим образом (подобно замыканиям, показанным в предыдущем разделе):

each( [1, 2, 3], |n| { some_processing(n); } );

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

do each( [1, 2, 3] ) |n| {
  some_processing(n);
}

Вызов функции предваряется ключевым словом do, а вместо записи замыкания в списке аргументов оно фактически "выносится за скобки", напоминая обычный блок кода, который легче читается.

3.1. Использование do-выражений для распараллеливания задач

Ещё одним важным способом применения do-выражения является создание параллельно выполняемых задач (tasks) с помощью функции spawn. Функция spawn определена в стандартном модуле task и имеет сигнатуру spawn(fn: ~fn()). Другими словами, это специализированная функция, которая принимает в качестве аргумента собственное замыкание (owned closure), не имеющее аргументов. Пример использования функции spawn показан в листинге 7.

Листинг 7. Создание параллельно выполняемой задачи с помощью функции spawn
use std::task::spawn;

println( "Основная программа: точка 1" );
do spawn() || {
  println( "Создана параллельно выполняемая задача" );
}
println( "Основная программа: точка 2" );

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

Если обратить внимание на то, что передаваемое в spawn замыкание никогда не имеет собственных аргументов, и оно вынесено за скобки, то необходимость в записи следующих непосредственно друг за другом пустых списков аргументов становится крайне сомнительной. И в самом деле Rust разрешает опускать пустые "ненужные" списки аргументов, что делает запись ещё более лаконичной и понятной, как показано в листинге 8.

Листинг 8. Сокращённая запись do-выражения при отстутствии аргументов
use std::task::spawn;

println( "Основная программа: точка 1" );
do spawn {
  println( "Создана параллельно выполняемая задача" );
}
println( "Основная программа: точка 2" );

В таком виде do-выражение становится ещё больше похожим на встроенную управляющую конструкцию.

Заключение

В данной статье рассматривались замыкания, представляющие собой весьма удобную форму определения и использования функций (лямбда-функций) непосредственно в том месте, где они необходимы, а кроме того обладающие важной способностью захватывать переменные из окружающего контекста. Также рассматривались do-выражения, с одной стороны являющиеся альтернативной формой записи лямбда-функций, а с другой стороны представляющие способ интерпретации функций высшего порядка как управляющих структур. Следующие две статьи цикла будут посвящены методам и обобщённым функциям языка Rust.


Ресурсы для скачивания


Похожие темы


Комментарии

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

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=40
Zone=Linux, Open source
ArticleID=952065
ArticleTitle=Rust - новый язык программирования: Часть 12. Замыкания. Do-выражения
publish-date=11072013