пятница, 5 октября 2012 г.

Break или не break?


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

Такой «разброд» имеет совершенно объяснимые причины. Большинство преподавателей, с одной стороны, когда-то заучили, что структурное программирование — это хорошо, а любое отступление от него — это плохо. С другой стороны, сами они уже фактически отошли от промышленного программирования: я могу на пальцах одной руки пересчитать знакомых (в том числе и по Интернету) учителей информатики, которые сами написали что-то стóящее. Таким образом, наблюдаем закон Дж.Б. Шоу в действии.

Попробуем разобраться в сути вещей. Оператор break — это фактически оператор перехода, знаменитый GOTO, который в 1970-е годы был морально уничтожен, прежде всего, стараниями «отца структурного программирования» Эдсгера Дейкстры [1]. Однако сами «отцы» хорошо понимали, что программа без GOTO ещё не становится автоматически структурной программой. Поэтому появилось на свет эссе Дональда Кнута «Структурное программирование с операторами GOTO» [2]. Кнут писал (перевод мой):

«Другими словами, мы не должны просто удалять операторы GOTO из-за того, что сейчас модно это делать; присутствие или отсутствие операторов GOTO — это не главный вопрос. Истинная цель состоит в том, чтобы формулировать наши программы таким образом, чтобы их было легко понимать.»

Посмотрим, как обстоит дело с пониманием программ, содержащих циклы с операторами break и сравним их с программами, где операторов break умышленно избегают.

Пример 1. С клавиатуры вводятся числа, ввод заканчивается числом 999. Вычислить сумму введенных чисел.

Вот решение с использованием break:
s := 0;
while True do begin
  Readln(x);
  if x = 999 then break;
  s := s + x;
end;
На взгляд автора, этот алгоритм очень хорошо соответствует словесному описанию: «строим цикл, выход из которого происходит при получении числа 999».

Теперь посмотрим на «кошерные» альтернативы. Нужно как-то выполнить те же действия (то есть, выйти из цикла при получении числа 999), не используя оператор выхода из цикла.

Во-первых, можно поставить условие цикла x <> 999, но при этом нужно будет инициализировать переменную x до цикла каким-то «магическим числом», отличным от 999, например, 1 (или 998 :-). Программист, который будет разбираться в таком коде через некоторое время, спасибо вам не скажет. Кроме того, вторую часть тела цикла придется взять в условный оператор, а эта вторая часть может быть достаточно большой. Читабельность явно не повышается. Зато программа «структурная», можно «взять с полки пирожок». :-)
s:=0;
x := 1;
while x <> 999 do begin
  Readln(x);
  if x <> 999 then
    s := s + x;
end;
Заметим, что число 999 появляется в программе дважды: в заголовке цикла и в условии в теле цикла, что само по себе уже нехорошо.

Можно вынести оператор Readln за цикл, продублировав его в теле цикла:
s := 0;
Readln(x);
while x <> 999 do begin
  s := s + x;
  Readln(x);
end;
На взгляд автора, при этом два оператора ввода, выполняющие одну и ту же функцию, «размывают» логику программы и не добавляют ей «прозрачности». Кроме того, вместо Readln в других ситуациях может стоять целая группа операторов, и тут уже дублирование будет выглядеть совсем некрасиво.

Кроме того, можно ввести логическую переменную (флаг), которой присваивается истинное значение при получении числа 999:
stop := False;
while not stop do begin
  Readln(x);
  if x = 999 then
    stop := True
  else
    s := s + x;
end;
Но тут нужно вспомнить о «бритве Оккама» — не плоди лишних сущностей. И ещё — любое усложнение системы, как правило, снижает её надежность.

Еще один вариант — перейти на цикл с постусловием:
s := 0;
repeat
  Readln(x);
  if x <> 999 then
    s := s + x;
until x = 999;
Во-первых, как и в одном из предыдущих вариантов, здесь два раза всплывает число 999. Во-вторых, вторую часть тела цикла снова нужно помещать в условный оператор. В-третьих, читать программы с циклами repeat — это сущее наказание: встретив слово repeat, судорожно пытаемся найти соответствующий until с условием, без этого всё вообще непонятно. Потом опять нужно смотреть наверх: что же там в теле цикла...

Можно, конечно, и так :-):
s := 0;
try
  while True do begin
    Readln(x);
    if x = 999 then raise Exception.Create('');
    s := s + x;
  end;
except end;
Но по сути это то же самое, что и break. Использовать здесь исключения — всё равно, что гвозди микроскопом забивать.

Рассмотрим еще один пример.

Пример 2. Найти в массиве A[1..N] элемент, равный X, или сообщить, что такого элемента нет.

Решение с помощью break:
nX := 0;
for i:=1 to N do
  if A[i] = X then begin
    nX := i;
    break;
  end;
if nX = 0 then
     writeln('Не нашли!')
else writeln('nX = ', nX);
Как и для первого примера, программа с break почти дословно повторяет словесное описание идеи решения: «просматриваем все элементы массива с первого до последнего; как только нашли нужный элемент, запоминаем его номер и выходим из цикла».

Вот альтернатива без break:
nX := 1;
while (nX <= N) and (A[nX] <> X) do
  Inc(nX);
if nX > N then
     writeln('Не нашли!')
else writeln('nX = ', nX);
Теперь представим себе, что будет, если в трансляторе включена проверка выхода за границы массива, логические выражения вычисляются полностью и элемента, равного X, в массиве нет: программа вылетит в результате обращения за пределы массива.

Вот еще вариант, с логической переменной:
nX := 1;
found := False;
repeat
  if A[nX] = X then
       found:= True
  else Inc(nX);
until found or (nX > N);
if not found then
     writeln('Не нашли!')
else writeln('nX = ', nX);
Какая программа понятнее, каждый решает сам. :-)

Итак, подытожим.
  1. Оператор break есть практически во всех современных языках программирования. «Значит, это кому-нибудь нужно!»
  2. Это инструмент, который нужно использовать в соответствии с его назначением.
  3. Само по себе наличие или отсутствие оператора break ничего не говорит о том, грамотно ли написана программа; задача состоит в том, чтобы сделать ее наиболее понятной и «прозрачной».
  4. Использование оператора break относится к так называемым «структурным» переходам [3], то есть к переходам вперёд в пределах того же модуля, что не нарушает принципы структурного программирования.

Разобравшись с break, можно перейти к его непосредственному «родственнику» — оператору continue, который передает управление сразу в конец цикла, переходя к следующему шагу, если требуется.

Пример 3. В цикле обрабатываются все элементы массива A[1:N]. Для каждого из них сначала выполняются операторы S1, S2, ..., SN, в результате вычисляется некоторое значение x. Если получено значение x = 1, нужно выполнить еще операторы T1, T2, ..., TM, а в противном случае перейти к следующему элементу.

«Классическое» решение выглядит так:
for i:=1 to N do begin
  S1;
  S2;
  ...
  x := SN;
  if x = 1 then begin
    T1;
    T2;
    ...
    TM;
  end
end;
Вроде бы всё хорошо. Но мы потеряли «локальность»: при достаточно длинном теле условного оператора нужно еще «сканировать» цикл до конца, проверяя, не выполняются ли какие-то действия в том случае, когда x <> 1. А эту проблему может элегантно решить continue:
for i:=1 to N do begin
  S1;
  S2;
  ...
  x := SN;
  if x <> 1 then continue;
  T1;
  T2;
  ...
  TM
end;
Здесь уже точно ясно, что при x <> 1 никаких дополнительных операций не происходит. По мнению автора, такой вариант более «прозрачен» и, по крайней мере, не хуже предыдущего.

Остается еще один «смежный» вопрос: можно ли писать подпрограммы с несколькими выходами? Давайте посмотрим пример рекурсивной процедуры.

Пример 4. Разработать процедуру, которая выводит на экран решение задачи «Ханойская башня» [4].

Рекурсивное решение этой задачи хорошо известно, одна из реализаций выглядит так:
procedure Hanoi(n, k, m: integer);
var p: integer;
begin
  if n = 0 then exit;
  p := 6 - k - m;
  Hanoi(n-1, k, p);
  writeln(k, ' -> ', m);
  Hanoi(n-1, p, m)
end;
Здесь n — количество дисков, которые нужно перенести; k — номер начального стержня и m — номер конечного стержня.

И все было бы хорошо, но тут нарушен ещё один принцип, который стал «священной коровой» ортодоксального структурного программирования: процедура имеет (увы :-) два выхода, один естественный, и второй — по признаку окончания рекурсии.

Можно ли было обойтись без этого? Да, можно, «упаковав» все тело процедуры внутрь условного оператора:
procedure Hanoi(n, k, m: integer);
var p: integer;
begin
  if n > 0 then begin
    p := 6 - k - m;
    Hanoi(n-1, k, p);
    writeln(k, ' -> ', m);
    Hanoi(n-1, p, m)
  end
end;
Но тут мы опять теряем локальность — при достаточно длинном тексте процедуры нужно еще посмотреть, не выполняются ли какие-то действия после условного оператора. Да и не совсем понятно, зачем добавлять лишний уровень вложенности. «Не плоди лишних сущностей». По мнению автора, первое приведенное решение более понятное и более красивое.

Пример 5. Разработать функцию, которая определяет, если ли в квадратной матрице элемент, равный заданному значению.

Будем предполагать, что введен тип данных
type Matrix = array[1..N,1..N] of integer;
Тогда функцию можно написать так:
function Find(const A: Matrix; X: integer): boolean;
var i, j: integer;
begin
  Result := True;
  for i:=1 to N do
    for j:=1 to N do
      if A[i,j] = X then exit;
  Result := False
end;
Здесь оператор exit фактически выполняет роль break для вложенного цикла. С формальной точки зрения у функции два выхода. В общем, «я своим ученикам никогда такое не зачту» — уже слышу я от учителей информатики.

А давайте подумаем, какие альтернативы? Можно вот так:
function Find(const A: Matrix; X: integer): Boolean;
var i, j: integer;
label finish;
begin
  Result := True;
  for i:=1 to N do
    for j:=1 to N do
      if A[i,j] = X then goto finish;
  Result := False;
finish:
end;
Но тут вообще «криминал» — метка и goto! Хотя по сути ничего не изменилось, тот же выход из вложенного цикла.

Конечно, здесь можно использовать исключение:
function Find(const A: Matrix; X: integer): Boolean;
var i, j: integer;
begin
  Result := False;
  try
    for i:=1 to N do
      for j:=1 to N do
        if A[i,j] = X then
          raise Exception.Create('');
  except
    Result := True
  end
end;
Но ведь это далеко не «исключительная ситуация», поэтому по смыслу исключения здесь всё-таки не особо уместны. Кроме того, нужно учитывать, что при обработке исключений выполняется большое число машинных команд, что снижает эффективность программы.

Любителей «чистого» структурного программирования наверняка устроит такой вариант решения:
function Find(A: Matrix; X: integer): Boolean;
var i, j: integer;
begin
  Result := False;
  for i:=1 to N do
    for j:=1 to N do
      if A[i,j] = X then Result := True
end;
Но, к сожалению, по эффективности он не может составить конкуренцию предыдущим, так как в любом случае рассматриваются все N2 элементов массива, даже если элемент A[1,1] равен X.

Выводы

Эта статья, прежде всего, о том, что любые идеи и принципы нужно воспринимать в контексте конечной цели. Одна из основных целей концепции структурного программирования — повысить читаемость программ и за счёт этого облегчить их отладку, тестирование и сопровождение. Поэтому применение операторов break, continue и exit нельзя считать отступлением от структурного программирования, если это не ухудшает читаемость программы и не приводит к запутыванию логики алгоритма.

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

Литература
  1. Dijkstra E.W., Go To Statement Considered Harmful // Communications of the ACM, Vol. 11, No. 3, March 1968, pp. 147-148. (PDF)
  2. Knuth D.E. Structured Programming with GO TO Statements // ASM Computing Surveys. 1974. 6(4). P.261-301. (HTML, PDF)
  3. Непейвода H.H., Скопин И.Н. Основания программирования. — Москва-Ижевск: Институт компьютерных исследований, 2003. (PDF)
  4. Гарднер М., Математические головоломки и развлечения. — М.: Мир, 1999.

Ярлыки: , , , , , , ,

пятница, 13 апреля 2012 г.

Есть ли operator присваивания в Паскале?

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

В ходе подготовки учебника к экспертизе у редактора возникли сомнения по поводу моей фразы: «Оператор «:=» — это оператор присваивания». По её мнению, оператор присваивания — это всё выражение вида «переменная := выражение;», а символы «:=» — это знак присваивания.

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

Поскольку речь идет о Паскале, обратимся, прежде всего, к отчёту «The Programming Language Pascal», который написал Никлаус Вирт, создатель этого языка. Он пишет:

«The most fundamental statement is the assignment statement. It specifies that a newly computed value be assigned to a variable (or a component of a variable).»

«Assignment statement» — это оператор присваивания, здесь слово «statement» действительно переводится на русский как оператор в значении «инструкция», «команда».

Но вопрос в другом — как назвать символ «:=»? Вирт в своих работах не дает ему никакого названия, такой же подход принят и в стандарте ISO7185.

Часто утверждается, в Паскале вообще нет оператора присваивания, такого как в Си и Си++, потому что присваивание — «это утверждение, а не выражение». Однако существует и другое мнение. Например, в книге М. Канту «Essential Pascal» видим термин «colon-equal operator». А Н. Дейл прямо использует термин «assignment operator» (см. «Programming in Pascal», глава 2).

Итак, мы добрались до сути: «:=» — это оператор или не оператор? Чтобы ответить на этот вопрос, нужно понять, какое значение слова «оператор» имеется в виду. В английском языке используется два совершенно разных слова, которые переводятся на русский одним и тем же словом «оператор» — это «statement» и «operator». С оператором-statement вроде бы всё понятно. Оператор-operator — это другой термин, который связан с математическим понятие оператора — правила преобразования одного объекта в другой.

Выражение «assignment operator» широко используется в таких языках, как Си и Си++, где оператор присваивания обозначается знаком «=». Его суть в том, что оператор присваивания создает новый объект на основе двух существующих объектов, записывая новое значение в переменную.

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

Считается, что в «классическом» Паскале оператора присваивания (в смысле operator) нет. Но это не совсем верно. Вот пример программы, которая работает во всех современных версиях Паскаля:
var a: integer;
    b: longint;
begin
  a := 5;
  b := a;
  b := 25;
  a := b;
end.
Очевидно, что здесь перед записью значения в переменную некоторые преобразования данных (приведение к другому типу) автоматически выполняются.

Кроме того, в современной версии Free Pascal символ «:=» вполне законно называется оператором присваивания, потому что его можно перегружать, как и в Си++. В программе, приведенной ниже, показан пример такой перегрузки.
type
  complex = record re,im: real; end;
var z: complex;
  operator :=(r: double) z: complex;
  begin
    z.re:=r;
    z.im:=0;
  end;
begin
  z:=3;
end.
Выводы: Таким образом, в современном Паскале «X:=Y;»  — это оператор присваивания (statement), а «:=» — это тоже оператор присваивания (operator).

Автор благодарит О.А. Полежаеву за интересное обсуждение, в результате которого появилась эта заметка.

Ярлыки: , , ,