Содержание


Rust - новый язык программирования: Часть 8. Работа с памятью - заимствованные указатели

Comments

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

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

1. Общие свойства заимствованных указателей

В Rust заимствованные указатели (borrowed pointers) представляют собой тип указателя общего назначения. В противоположность собственным указателям на блок памяти, когда владелец указателя на блок памяти является одновременно и владельцем самого этого блока памяти, заимствованные указатели никогда не предусматривают какого-либо владения. Указатель такого типа может быть позаимствован (borrowed) любым объектом, но при этом компилятор тщательно проверяет, не превышает ли время существования заимствованного указателя время существования самого объекта.

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

Следует особо подчеркнуть, что заимствованные указатели являются одним из самых гибких и мощных инструментов в Rust. Заимствованный указатель может указывать практически в любое место: на управляемый блок памяти или на область обмена в общей памяти (heap), на позицию в стеке и даже на внутренний элемент другой структуры данных. Заимствованный указатель так же гибок и универсален, как C-указатель или ссылка в C++. Но в отличие от компиляторов C и C++, в компилятор Rust включены специальные статические проверки, обеспечивающие полную безопасность программ, использующих заимствованные указатели, ещё на стадии компиляции. Другим преимуществом заимствованных указателей является то, что они невидимы для механизма сборки мусора, поэтому работа с заимствованными указателями помогает сократить излишние накладные расходы, связанные с автоматическим управлением памятью. Это очень важно для задач системного программирования, особенно в тех случаях, когда любые потери производительности представляют собой критические факторы.

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

И хотя заимствованные указатели имеют несколько другие усовершенствованные теоретические обоснования (указатели на область памяти), ключевые базовые концепции их несомненно знакомы любому, кто работал с языками C и/или C++. Возможно именно поэтому самым лучшим способом объяснить их применение и некоторые ограничения будет рассмотрение практических примеров.

2. Практическое применение заимствованных указателей

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

struct Point { x: float, y: float }

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

Листинг 1. Три варианта размещения значений локальных переменных
let on_the_stack: Point = Point { x: 3.0, y: 4.0 };
let managed_box: @Point = @Point { x: 5.0, y: 1.0 };
let owned_box: ~Point = ~Point { x: 7.0, y: 9.0 };

Процедуру вычисления расстояния между любыми двумя заданными точками написать несложно, но вопрос в том, чтобы такая процедура не зависела от способа хранения передаваемых ей значений координат. То есть, процедура должна одинаково корректно обрабатывать передаваемые пары структур, будь то on_the_stack и managed_box или managed_box и owned_box. Разумеется, можно написать функцию, принимающую аргументы типа Point, другими словами, координаты всегда будут передаваться по значению. Для структуры, содержащей всего лишь две координаты, это может быть вполне приемлемым решением, но в реальных производственных задачах гораздо чаще встречаются объекты с многочисленными полями, а если эти поля к тому же ещё и изменяемые, то возникает угроза искажения семантики всей программы непредсказуемым образом. По этой причине предпочтительнее определить функцию, которая принимает значения (в данном случае: координаты точек) по указателю. Для этого можно воспользоваться заимствованными указателями, как показано в листинге 2.

Листинг 2. Определение функции, принимающей значения по указателю
fn compute_distance( p1: &Point, p2: &Point ) -> float {
  let x_d = p1.x - p2.x;
  let y_d = p1.y - p2.y;
  sqrt( x_d * x_d + y_d * y_d )
}

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

compute_distance( &on_the_stack, managed_box );
compute_distance( managed_box, owned_box );

В первом вызове оператор & используется для взятия адреса переменной on_the_stack, так как она (и её значение) имеет тип Point, и чтобы добраться до значения, необходимо передать адрес этой переменной (подобно тому, как это делается в языке C при передаче значения по ссылке). В Rust это называется заимствованием (borrowing) локальной переменной on_the_stack; фактически здесь создаётся альтернативный путь к одним и тем же данным, в определённой степени "псевдоним" (alias).

В тех случаях, когда передаются указатели на блоки памяти managed_box и owned_box, никаких дополнительных действий не требуется. Компилятор автоматически выполняет преобразования указателя на блок памяти вида @point или ~point в заимствованный указатель &point. Это другая форма заимствования, здесь заимствуется содержимое управляемого или собственного блока памяти соответственно.

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

3. Другие способы применения оператора &

В предыдущем разаделе одно из значений координат точки было определено следующим образом:

let on_the_stack: Point = Point { x: 3.0, y: 4.0 };

Это означает, что в данном случае передача содержимого структуры Point в другие функциии возможна только по значению. Следовательно, при использовании заимствованного указателя приходится в явной форме выполнять операцию взятия адреса переменной on_the_stack. Но иногда более удобно разместить оператор & напосредственно в определении переменной on_the_stack, например:

let on_the_stack2 : &Point = &Point { x: 3.0, y: 4.0 };

Применение оператора & к r-значению (r-value; то есть, к локации, присваивание которой невозможно) является всего лишь удобным сокращением для создания временного объекта и взятия его адреса. Приведённое выше определение можно записать менее лаконично, но, возможно, немного более понятно:

let tmp = Point { x: 3.0, y: 4.0 };
let on_the_stack2: &Point = &tmp;

4. Заимствованные указатели на поля структур данных

Как и в C, оператор & не ограничен процедурой получения адресов локальных переменных. Он способен также получать адреса полей структур данных и отдельных элементов массивов. Для примера рассмотрим определение типа данных для прямоугольника Rectangle, приведённое в листинге 3, а также несколько различных способов определения объекта этого нового типа.

Листинг 3. Определение и использование структуры Rectangle
struct Point { x: float, y: float }
struct Size { w: float, h: float }
struct Rectangle { origin: Point, size: Size }

let rect_stack = &Rectangle {origin: Point {x: 1f, y: 2f}, size: Size {w: 3f, h: 4f}};
let rect_managed = @Rectangle {origin: Point {x: 3f, y: 4f}, size: Size {w: 3f, h: 4f}};
let rect_owned = ~Rectangle {origin: Point {x: 5f, y: 6f}, size: Size {w: 3f, h: 4f}};

После этого для каждого определённого выше объекта типа Rectangle можно извлекать его отдельные подкомпоненты с помощью оператора &, например, следующим образом:

compute_distance( &rect_stack.origin, &rect_managed.origin );

Здесь происходит всё то же самое заимствование (borrowing) полей origin из объекта в стеке и из управляемого блока памяти для вычисления расстояния между указанными объектами.

5. Заимствование управляемых блоков памяти и rooting

На нескольких примерах, приводимых в предыдущих разделах, было показано заимствование блоков общей памяти (heap), как управляемых, так и собственных. До этого момента вопросы безопасности не обсуждались. Как уже было отмечено в начале статьи, во время выполнения заимствованный указатель - это просто указатель и ничего более. Следовательно, устранение присущих языку C проблем с "висящими" указателями требует тщательной проверки во время компиляции.

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

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

Листинг 4. Демонстрация времени существования заимствованного указателя в простом случае
struct X { f: int }
fn example1() {
  let mut x = X { f: 3 };
  let y = &mut x.f;  // создан заимствованный указатель на изменяемое поле структуры
  ...                // заимствованный указатель остаётся корректным в теле функции
}  // время существования заимствованного указателя завершено

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

Ситуация усложняется, если заимствуются данные из блоков общей памяти (heap), как показано в листинге 5.

Листинг 5. Время существования заимствованного указателя на управляемый блок общей памяти
fn example2() {
  let mut x = @X { f: 3 };
  let y = &  // создан заимствованный указатель на изменяемое поле структуры
  ...            // заимствованный указатель продолжает существовать в теле функции
}  // время существования заимствованного указателя завершено

В этом примере x представляет блок общей памяти, соответственно y является указателем на этот блок памяти. И здесь время существования y ограничено частью тела функции, следующей за его определением. Но при этом имеется весьма важное отличие от первого примера: что произойдёт, если для x будет выполнена ещё одна операция присваивания какой-либо другой переменной в этой части функции? Если бы компилятор не обращал на это внимание, то рассматриваемый управляемый блок памяти мог бы стать "лишённым корня" (unrooted), следовательно, наверняка превратился бы в цель для механизма сборки мусора. Блок общей памяти "лишается корня", если в общей памяти не существует ни одного указателя, связанного с ним. Такая ситуация приводит к нарушению защиты памяти, когда блок памяти, изначально связанный с x, был освобождён и обработан механизмом сборки мусора, тогда как указатель y, не относящийся к общей памяти, продолжает указывать на этот блок.

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

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

Листинг 6. Компилятор защищает заимствованный указатель на управляемый блок памяти
fn example2() {
  let mut x = @X { f: 3 };
  let x1 = x;
  let y = &x.f;  // создан заимствованный указатель на изменяемое поле структуры
  ...            // заимствованный указатель продолжает существовать в теле функции
}  // время существования заимствованного указателя завершено

Теперь, даже если x будет переназначен другой переменной, указатель y будет оставаться корректным до конца тела функции. Этот процесс называется "закреплением корня" (rooting).

6. Заимствование собственных блоков общей памяти

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

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

Листинг 7. Использование заимствованного указателя на собственный блок памяти
fn example3() -> int {
  let mut x = ~MyBox { f: 3 };
  if some_condition() {
    let y = &x.f; // создан заимствованный указатель на изменяемое поле структуры
    return *y;    // значение, на которое указывает y, возвращается из функции
  }  // время существования заимствованного указателя y завершено
  x = ~MyBox { f: 4 };
  ...
}

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

Может быть не совсем понятно, почему столько внимания уделяется свойству изменяемости переменной, содержимое которой заимствуется. Причина этого повышенного внимания заключается в том, что система времени выполнения (runtime system) освобождает любой собственный блок общей памяти сразу после того, как собственный указатель на этот блок изменяется или выходит из области видимости. Поэтому программа, показанная в листинге 8, является некорректной, и не будет скомпилирована.

Листинг 8. Указатель становится некорректным после передачи владения объекта заимствования
fn example3() -> int {
  let mut x = ~X { f: 3 };
  let y = &x.f;
  x = ~X { f: 4 }; // ошибка: недопустимое переназначение, 
          // так как y становится некорректным
  *y
}

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

Рис.1. Состояние памяти до переназначения x
Состояние памяти до переназначения x
Состояние памяти до переназначения x

После переназначения переменной x состояние памяти изменится, как показано на рис.2.

Рис.2. Состояние памяти после переназначения x
Состояние памяти после переназначения x
Состояние памяти после переназначения x

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

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

Листинг 9. Усложнённый пример некорректного кода при использовании заимствованного указателя
fn example3() -> int {
  struct R { g: int }
  struct S { f: ~R }

  let mut x = ~S { f: ~R { g: 3 } };
  let y = &x.f.g;
  x = ~S { f: ~R { g: 4 } }; // ошибка: переназначается владение внешней структурой
  x.f = ~R { g: 5 };         // ошибка: переназначается владение внутренней структурой
  *y              // в любом случае заимствованный указатель становится некорректным
}

В приведённом выше примере компилятор обнаружит две ошибки: первая возникает при изменении переменной x, вторая - при изменении поля структуры x.f. Оба изменения делают некорректным заимствованный указатель y.

Заключение

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


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


Похожие темы


Комментарии

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

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=40
Zone=Linux, Open source
ArticleID=951073
ArticleTitle=Rust - новый язык программирования: Часть 8. Работа с памятью - заимствованные указатели
publish-date=11012013