Языки программирования, базовый курс.

Выражения и операторы

Способ записи

Попрошу без выражений !

Рассмотрим два определения:

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

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

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

Чаще всего для записи выражений используется привычная нам алгебраическая запись (слегка адаптированная), в которой знак операции ставится между значениями: 2 + 3. Такой способ записи называется инфиксным.

Но это не единственный способ. При записи функций мы ставим обозначение функции, такое как sin или tg, перед значением: sin x. Такая запись называется префиксной. В языках программирования функции обычно записывают в префиксной форме. Но никто не мешает операции + - * / тоже трактовать как функции и писать, например, вместо 2 + 3 выражение + 2 3, или, в более традиционной форме, plus (2,3). По историческим причинам принято в префиксной записи обозначение функции или операции тоже вносить внутрь скобок: (plus 2 3)

Записывая в виде функций все арифметические операции, мы немного потеряем в читаемости текста, зато приобретем целый ряд важных преимуществ. Во-первых, можно будет забыть о том, что умножение делается перед сложением. Порядок вычисления выражения всегда будет определяться скобками. Сравните 2 + 3 * 4 и (plus 2 (mult 3 4)). Разночтений при втором способе записи не существует. Во-вторых, все операции и функции в выражениях будут записываться совершенно единообразно. Что существенно облегчает компиляцию программ и позволяет работать с выражениями как с данными. Основная проблема такого способа записи, который называется префиксным, это обилие скобок. Оказывается, существует способ записи выражений, который позволяет обойтись вообще без скобок. Этот способ называется постфиксным, и состоит в том, что знак операции пишется после операндов.

Уже рассмотренное выражение запишется в нем как 3 4 * 2 + или даже как 2 3 4 * +.

Чтобы понять последнюю запись, вспомним, как мы вычисляли значения выражений, когда изучали подстановки. Здесь можно делать то же самое - заменяем 3 4 * на 12, а 2 12 + на 14 и выражение вычислено. Но можно использовать и другой подход, очень важный с точки зрения теории. Рассмотрим структуру данных, называемую стек.

Стек (англ. stack — стопка) — структура данных с методом доступа к элементам LIFO (Last In — First Out, последним пришел — первым вышел). Чаще всего принцип работы стека сравнивают со стопкой тарелок: чтобы взять вторую сверху, нужно снять верхнюю. Добавление элемента, называемое также заталкиванием (push), возможно только в вершину стека (добавленный элемент становится первым сверху), выталкивание (pop) — также только из вершины стека, при этом второй сверху элемент становится верхним.

При использовании стека интерпретация постфиксных выражений определяется всего двумя правилами:

  1. Если в записи выражения встретилось число, то занести его в стек.
  2. Если встретился знак операции, то выбрать из стека столько чисел, сколько требуется для данной операции, выполнить над ними операцию, и результат занести обратно в стек.

По окончании процесса выражение должно закончиться, а в стеке должно остаться ровно одно число - оно и будет значением выражения. Проверьте!

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

( PLUS 2 3 (MULT 3 5 8) 11 )

Таким образом, любое выражение можно записать тремя различными способами: инфиксным, префиксным и постфиксным. Поскольку это все-таки одно и то же выражение, должен существовать способ автоматического перевода из одного представления в другое. И он действительно существует. Несложно придумать, например, преобразователь постфиксной записи в инфиксную - для этого в вышеприведенных правилах нужно вместо того, чтобы вычислять выражение, просто заносить в стек соответствующую строку в скобках, например, ’(2+3)’. Однако, обратное преобразование не такое простое. Чтобы понять, как это работает в общем виде, рассмотрим еще один способ записи выражений - в виде дерева.

/
+ *
2 3 4 8

Это запись выражения (2+3) / (4*8)

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

На основе постфиксной записи построено несколько языков. Наиболее известный из них - FORTH, рассмотрим его более подробно. Любая программа на Форте является выражением, записанным в постфиксной форме. Существует глобальный стек, в котором происходит вычисление этих выражений. Некоторые операции вызывают побочные эффекты, например . (точка) печатает число, находящееся на верху стека. Программа

3 3 * 5 5 * +
.

вычисляет значение выражения 32 + 52 и печатает результат.

Для удобства работы со стеком существуют служебные операции, например, DUP дублирует верхушку стека. Так, 3 DUP + это то же самое, что 3 3 +. Другая служебная операция, SWAP, меняет местами два верхних элемента стека. Например, 5 3 - . печатает 2, и 3 5 SWAP - . тоже печатает 2.

Теперь посмотрим, как это можно использовать. Определение подпрограммы начинается с : после которого идет имя подпрограммы, дальше текст, и в конце ставится ;

: SUM-OF-SQUARES
 DUP * SWAP   DUP *  +  ;

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

3 5 SUM-OF-SQUARES .

Это то же самое, что просто написать 3 5 DUP * SWAP DUP * + .

Можно пойти дальше, и заметить, что возведение в квадрат в виде DUP * встречается у нас дважды.

: SQUARED  
 DUP * ;

: SUM-OF-SQUARES 
  SQUARED  SWAP SQUARED  +  ;

Легкость выделения подпрограмм, а также то, что накладные расходы на вызов подпрограмм у Форта намного меньше, чем у других языков, приводит в результате к очень компактным программам.

Арность операций

Известные нам арифметические операции используют два операнда, и поэтому называются двуместными или бинарными. Если рассматривать знак - перед выражением как операцию, например, -(2*Z), то он, а также элементарные функции будут одноместными, или унарными операциями. Иногда встречаются и трехместные, или тернарные операции.
Так, в языке С

y = x > 0 ? x : 0;

означает “если x > 0 то x, иначе 0. В ряде случаев число операндов может иметь значение само по себе, как характеристика операции. Обычно это происходит в тех языках, в которых программист может вводить свои собственные операции. Тогда это свойство операций, исходя из общей части названий “унарные”, “бинарные”, “тернарные” именуют арностью (arity).

Полиморфные операции.

Как вы, наверное, уже заметили, одна и та же операция (-) может обозначать немного разные вещи, в зависимости от её арности. Выше говорилось, что результаты некоторых операций над разными типами могут различаться. Однако мы привыкли писать что-то вроде a+b, не задумываясь о том, целые a и b или вещественные.

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

Что касается полиморфизма, вносимого программистом, то тут единства подхода нет. В ранних языках определять полиморфные операции и функции, как правило, было невозможно. Особенно удивительно это выглядит в языке Modula-2, в котором смешивать типы данных в выражениях запрещено. В ранних библиотеках функций на Фортране была принята такая запись: к именам функциий, работающих с вещественными числами, приписывалась спереди буква A, а к функциям, использующим целые числа - буква I, например, ASQRT и ISQRT. Потом люди догадались, что выбор нужной функции можно возложить на компилятор. Характеристикой каждой полиморфной функции является ее сигнатура. Сигнатура - это имя функции и набор данных, определяющих типы аргументов и результат функции. Например, для функции sin это может быть запись sin: real->real, то есть функция с именем sin преобразует значение real в значение real. Для функции округления до ближайшего целого сигнатура будет round: real->int. Если у нас есть, например, две функции для извлечения квадратного корня из числа, одна работающая с натуральными числами, вторая с вещественными, то их сигнатуры могут быть такими: sqrt: int->int и sqrt: real->real В программе мы пишем только имя функции, а компилятор решает, какую из функций, в сигнатуры которых входит это имя, нужно вызвать. Вопрос состоит в том, можно ли по контексту определить, о какой именно функции идет речь.

Пусть, например, имеется участок программы:

  int x;
  int y;
...  
 y=random();
 x=random();

Вроде бы, тут все ясно: имеется две функции с сигнатурами random:()->real и random:()->int. Но как только возникает любое преобразование типов, например, в выражении y+x+random(), появляется неоднозначность. В C++ появилось правило, которое впоследствии перенесли на большинство языков: полиморфные функции не могут различаться только типом результата, а выбор сигнатуры функции определяется исключительно типом аргументов.

Порядок действий

В школе нас учили, что умножение и деление делаются прежде сложения и вычитания. Подавляющее большинство языков программирования заимствует и расширяет эту практику. Поскольку для программирования требуется несколько больше бинарных операций, чем для арифметики, на дополнительные операции, такие как and, &, &&, or, и другие, тоже распространяется идея приоритетов. 7-8 уровневая система приоритетов операций в языке программирования не редкость. Главная проблема тут состоит в том, что в разных языках одни и те же операции имеют разные приоритеты.

Например, в С

if (a < 3 && a>0) 

правильно написанное выражение, поскольку приоритет && ниже, чем у > и <.

А в Pascal

if a<3 and a>0 then

является ошибкой, потому что компилятор пытается интерпретировать его как

a < (3 and a) > 0

По какой-то причине создатель языка Pascal решил,что так будет лучше.

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

Вывод из рассуждения о приоритетах простой: сомневаетесь - ставьте скобки.

Получение значений

Привычной для нас является запись вида

x = x+1;

где смысл символа x с разных сторон от знака присваивания немного разный, и выясняется из контекста. Но не везде это так, есть языки, в которых разыменование, или, что то же самое, получение значения переменной по ее имени, должно выполняться в явном виде. Один из таких языков - уже упоминавшийся Forth. В нем есть операция записи значения в переменную ! и операция взятия значения переменной и помещения его на стек @. Соответственно, x=x+1 запишется на Forth (в постфиксной записи!) как

x @ 1 + x !

что читается как “x взять, 1 прибавить, x записать”. Сама переменная x в этой записи просто обозначает ссылку на какое-то место в памяти.

Граждане первого класса

В теории языков программирования существует устойчивое выражение «объект первого класса». Это означает, что данный вид объектов «имеет право» появляться в выражениях и присваиваниях, а также служить параметрами процедур и функций точно так же, как целые числа или простые переменные. Например, в Pascal или C функции не являются объектами первого класса, их нельзя присваивать (сами функции, а не результаты), нельзя изменять во время исполнения.

Знаки операций.

Семантика операций в ранних языках программирования была, в основном, заимствована из языка математики. Основная "доработка" сводилась к адаптации слишком вольной математической записи к требованиям устройств ввода. Например, к замене записи x2 на вызов функции sqr(x). В языке Fortran были исключены даже символы < и >, отсутствовавшие на некоторых клавиатурах того времени. Вместо них полагалось писать странные аббревиатуры .LT. и .GT. (именно так, с точками).

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

Стандарт языка Algol-60 предусматривает множество спецсимволов для обозначения операций, такие как ¬, ≤ или ≥. Все они есть в Unicode, но отсутствуют на клавиатурах.

В итоге сложилась некая традиция именования операторов, причем главные расхождения возникли по поводу оператора "не равно". Его обозначают !=, <>, #, /=, ~=, '=

. Несколько меньшее разнообразие наблюдается в отношении логических операций "И", "ИЛИ" и "НЕ". Здесь имеется две тенденции - обозначать их английскими словами или же символами, как в языке C.

До тех пор, пока основными объектами "первого класса" в языках программирования были числа, особых проблем не возникало. Основным исключением была, как ни странно, операция "равно", истинность которой в случае вещественных чисел означает одно из двух: либо одно значение является копией второго, либо произошло счастливое совпадение. Результат операции 1.347+1.123 вовсе не обязан быть в точности равен 2.47 вследствие ошибок округления. Выходов из ситуации существует три: вообще не определять операцию "равно" для значений с плавающей точкой, вычислять равенство с точностью до некоторого очень маленького числа, и отдать все программисту, пусть он разбирается. Последний способ использован в языке C, причем особенности языка дополнительно осложняют задачу. Вот пример программы:

main()
{
float a, b, c;

   a = 1.347f;
   b = 1.123f;
   c = a + b;
   if (c == 2.47)            
      printf("OK.\n");
   else
      printf("OOPS! %13.10f != %f",c,c);
}

Если же вместо c == 2.47 написать c == 2.47f, то результат будет другим. Дело в том, что C "любит" преобразовывать числа с плавающей точкой в числа двойной точности.

Приведение типов

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

double x = 3/4 + 5.1/6.1;

Какие преобразования должны быть произведены ? Возможно, что программист имел в виду, что 3 и 4 - вещественные числа. В этом случае хорош способ приведения типов “сверху вниз”, когда требуемый тип результата определяет тип, к которому приводятся аргументы операции. Другой способ, “снизу вверх” предполагает, что действия производятся последовательно, в соответствии с правилами вычисления выражений, и каждое подвыражение вычисляется так, как если бы кроме него вокруг ничего не было. Оба подхода в чем-то логичны, но в современных языках чаще используется второй.

Автоматическое приведение типов может привести к достаточно неожиданным проблемам.

PL/1

N = 0;
Q = N+0.1;

Здесь N-число с фиксированной точкой, а Q - вещественное. В результате этого совершенно обычного присваивания Q становится равным 0.0625.

Как это происходит? N - целое двоичное число. Значит, 0.1 должно быть приведено к двоичному виду. Происходит это с определенной точностью, так как 0.110- бесконечная двоичная дробь (проверьте!). В результате отбрасывания знаков от дроби остается 0.00012, или 1/16, или 0.0625.

Заметим, что следует различать приведение типов путем преобразования значений от прямого приведения типов, когда компилятору говорят "отныне ты будешь знать, что в данной области памяти лежит значение типа float". При этом никакого преобразования значений не происходит, зато код получается трудночитаемый и плохо переносимый. Попробуйте понять, каким способом следующая функция вычисляет sqrt(1/x)

float InvSqrt(float x){
   float xhalf = 0.5f * x;
   int i = *(int*)&x; 
   i = 0x5f3759d5 - (i >> 1); 
   x = *(float*)&i; 
   x = x*(1.5f - xhalf*x*x); 
   return x;
}

В большинстве языков предполагается, что вычисление выражений не имеет неявных побочных эффектов, то есть от того, будет выражение вычислено или нет, ничего не изменится. Иначе говоря, от замены y = 2 * 2; на y = 4; не изменится ровным счетом ничего. Однако, есть и исключения. В языках C и C++, а также в других, унаследовавших их синтаксис, операция ++ увеличивает значение переменной на 1. В связи с этим новичков в C часто озадачивают вопросом: чему равно

(x++) + (++x)

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

Операторы

Давайте, наконец, что-нибудь сделаем !

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

Операторы присваивания

Оператор присваивания меняет среду, и в этом состоит его основное и единственное назначение. Традиционно оператор присваивания записывается в виде, напоминающем математическое равенство:

e1 = e2

Это сходство с математической записью много лет являлось проблемой при освоении программирования, поскольку программистами чаще всего становились математики, привыкшие, что e1 = e2 означает не совсем “вычислить значение e1 и записать его как e2”. По крайней мере, для математика e1 = e2 и e2 = e1 это примерно одно и то же. Чтобы математикам было проще воспринимать обозначения языков программирования, в языке Algol ввели новое обозначение e1 := e2, асимметричным видом оператора намекая на то, что это не равенство. Изобретатель языка Pascal Никлас Вирт и по сей день полагает, что такой вид оператора присваивания необычайно важен, хотя в наше время программистами чаще всего становятся люди, весьма далекие от математики. Гораздо хуже была другая ошибка, допущенная при проектировании сразу нескольких ранних языков. В PL/1 и Basic знаки присваивания и сравнения совпадают. Как, например, следует понимать запись x = y = 2 ? Оказывается, это означает, что x присваивается результат сравнения y и 2. То есть, если y равен 2, то x будет присвоено значение true. Какими именно сочетаниями символов записываются операции присваивания и сравнения,не так важно. Главное, что они должны быть разными.

Совпадение обозначений этих двух операций не только мешает читать программы, но и делает невозможным введение так называемого кратного присваивания, основная идея которого как раз и состоит в том, чтобы x = y = 2; понимать как “присвоить y значение 2, а x значение y”. У оператора присваивания известно два основных способа развития. Первый из них состоит в том, чтобы присваивать значения нескольким переменным одновременно. Тогда можно писать, например, x,y = 5,6 но это еще не самое интересное. Такой способ записи позволяет поменять местами значения двух переменных, не используя третью: x,y = y,x. А это уже нечто действительно полезное.

Тем не менее, программистам, учившимся языкам Basic или Pascal, бывает сложно привыкнуть к системе обозначений языка C, в котором операция сравнения обозначается как ==. И здесь надо сделать важное замечание относительно языка C и производных от него. В них присваивание является именно операцией, которая имеет результат. Вполне логично, что этим результатом является присваиваемое значение. Таким образом, y=x=2 в языке C означает “присвоить переменной y результат присваивания переменной x значения 2. Теперь зайдем немного вперед и посмотрим на такой оператор языка C:

if (x=y) printf ("yes");

Результатом операции присваивания является значение y. Поскольку в C нет логического типа, printf выполнится всегда, когда y исходно содержит любое ненулевое значение. Самое интересное, что после выполнения этого оператора x будет действительно равен y. Такие ошибки не так-то просто находить. Вторая важная особенность языка C состоит в том, что, поскольку = это операция, x = y+1; это выражение. Если в Pascal x := y+1; формально означает «вычислить выражение y+1 и результат присвоить переменной x», то в C семантика этого оператора формально состоит в том, чтобы просто вычислить выражение (внимание!) x = y+1. Значение попадет в переменную x в процессе вычисления. Поэтому в C является законным оператор y+1; и даже просто 345; Это означает «вычислить выражение, а результат забыть». В приведенных примерах это действие не имеет смысла, но в определенных ситуациях, которые мы рассмотрим в разделе «Функции и процедуры», оно оказывается полезным.

Операторы управления

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

Первый оператор, который мы рассмотрим, играет основополагающую роль в программировании и в подавляющем большинстве языков не обозначается вообще никак. Это оператор “следования”, он гарантирует нам, что вычисления выполняются в строго определенной последовательности. Как правило, операторы выполняются в той последовательности, в которой они написаны, однако бывают и исключения. Одним из таких исключений является язык Occam, в котором можно в явном виде написать:

SEQ
	x := x + 1
	y := x * x

и два присваивания будут выполнены последовательно. А можно написать:

PAR
	x := x + 1
	y := y + 1

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

Почему про оператор следования обычно ничего не говорят ? Видимо, вследствие кажущейся его очевидности. Тем не менее, в теоретических работах, таких как книга Э.Дейкстры “Дисциплина программирования” он рассматривается наравне со всеми остальными.

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

  if (a==fun1(x) && c==fun2(y))

Здесь разработчики языка гарантируют нам, что fun1(x) обязательно будет вычислено раньше fun2(y), хотя в выражении

  fun1(x) + fun2(y)

такой гарантии нет.

С оператором следования тесно связана концепция составных операторов, про которые мы уже упоминали в связи с операторными скобками. Идея состоит в том, чтобы дать возможность программисту написать несколько операторов в любом месте, где можно написать один. В Algol и Pascal используются конструкции begin … end, а в C-подобных языках - фигурные скобки {}. В некоторых языках составных операторов нет вообще. Иногда это является недостатком языка, например в некоторых диалектах Basic после IF можно написать только один оператор. А в языке Modula-2 составные операторы не нужны, но это важное преимущество этого языка. Вместо использования каких-либо скобок там в конце каждого структурного оператора надо писать свой END.

IF x < y THEN
	writestr(“yes”);
	x:=y;
ELSE
	writestr(“no”);
END;
FOR i:= 1 TO 10 DO
 s:=s+1;
 q:=q+2;
END;

Такой способ записи добавляет к большинству программ несколько лишних END, но зато улучшает читаемость и вводит единообразие в структуру программы. Можно пойти по этому пути еще дальше, и заканчивать все if на end if, все for на end for, и так далее, чтобы было понятно, какой именно оператор заканчивает данный end. Так сделано в некоторых диалектах языка Basic.

Еще один весьма странный вариант этого способа группировать операторы был применен в языке Algol-68. Там каждый оператор if, case, итп, предлагалось заканчивать тем же словом, но написанным задом наперед: fi, esac и так далее. Способ не прижился, и исчез вместе с Algol-68.

Про необычный способ группировать операторы в языке Python мы уже говорили.

Вторым из обычно игнорируемых операторов является пустой оператор. Он вообще ничего не делает, но иногда бывает нужен, в основном для того,чтобы занять место, где по правилам синтаксиса должен быть оператор. В C и Pascal он обозначается одинаково, как действительно пустое место, после которого идет точка с запятой. Однако, в языке Fortran ничегонеделание обозначается длинным словом CONTINUE.

Два способа использования пустого оператора встречаются чаще всего. Первый - это ожидание какого-либо события.

while (! key_pressed());

Здесь, кроме проверки условия, ничего делать не надо.

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

if (a<b) or ( ( c<d) and (p=q)) and (a<2) then
  ; // ничего не делать
else
  do_something;

Интересно, что в языке Perl специально для этого случая придумана конструкция unless, по смыслу противоположная if (см. следующий раздел).

Пустой оператор иногда путают с разделителями операторов. В качестве разделителей чаще всего используют точку с запятой, но, например, в языке Basic это двоеточие или перевод строки, а в FORTRAN каждый оператор должен начинаться с новой строки. Существенная разница тут имеется между C-подобными языками и Pascal. В языке Pascal точка с запятой разделяет операторы, а в C - заканчивает их. Поэтому запись

	print_number(4);
 	print_number(5);

в C означает просто два вызова подпрограммы, а в Pascal - два вызова подпрограммы, за которыми следует пустой оператор (предполагается, что эта конструкция заключена в операторные скобки).

Альтернатива.

Оператор, обычно называемый словом if, дает возможность выполнить один из двух участков программы в зависимости от некоторого условия. За исключением нескольких ранних отклонений, во всех процедурных языках он записывается примерно одинаково. Хотя в языке Perl наличествуют два дополнения. Во-первых, там можно писать if после оператора:

a=b if a<b;

во-вторых, там есть инверсия if - оператор unless, который позволяет выполнить оператор при невыполнении некоторого условия.

a=b unless a>=b;

Но в большинстве случаев разработчики языков используют 3 стандартные формы оператора if:

Первая форма : проверить условие, в случае его истинности выполнить оператор p, в противном случае ничего не делать.

if <условие> then p;

Вторая форма : проверить условие, в случае его истинности выполнить оператор p1, в противном случае выполнить оператор p2.

if <условие> then p1 else p2;

Эдесь и далее мы будем использовать запись вида <что-то в угловых скобках> для обозначения различных категорий языковых конструкций. Что в данном случае имеется в виду под словом <условие>, интуитивно понятно. Ближе к концу курса мы введем по этому поводу строгие определения. Сейчас достаточно понимать, что угловые скобки не являются частью оператора, а просто ограничивают описание на естественном языке)

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

Третья форма (многовариантный if): проверить условие, в случае его истинности выполнить оператор p1, в противном случае проверить другое условие, в случае его истинности выполнить оператор p2, и так далее несколько раз; если все условия ложны, выполнить оператор pN.

if <условие1> then p1 
elseif <условие2> then p2 
elseif <условие3> then p3 
else pN;

В одних языках используется написание elseif, в других elsif или как-то иначе.

В данном примере оператор p3 выполнится только в случае, когда <условие1> и <условие2> ложны, а <условие3> истинно.

Как и многие другие элементы языков, оператор if не сразу появился в том виде, в котором мы привыкли его видеть. В Fortran было сразу два разных оператора if - арифметический и логический ( это были разные операторы, а не разные формы одного и того же if ). Логический if был почти такой, как сейчас, но после него можно было записывать только один оператор, арифметический if мы уже рассматривали.

С оператором if в тех языках, в которых есть операторные скобки, имеется одна проблема. Как следует понимать такую запись:

if  a>b then 
if c<d then print ('1')
else print ('2');

К какому if относится последний else ?

Вместо того, чтобы решать этот вопрос (а он, в отличие от проблемы с (x++) + (++x), имеет ответ в стандарте языка) надо просто всегда в таких случаях ставить операторные скобки. Интересно, что в языках, подобных Modula-2 или Python, эта проблема вообще не возникает.

Оператор case.

Если в третьей форме оператора if все условия являются взаимоисключающими и используют сравнение с одной и той же переменной, получается оператор case.

case n
when 0 then puts 'Ноль'
when 1, 3, 5, 7, 9 then puts 'Нечет'
when 2, 4, 6, 8    then puts 'Чет'
else puts 'Меньше ноля или больше девяти'
end

В нем значение некоторого выражения последовательно участвует в нескольких условиях. Если какое-то из них оказывается истинным, то выполняется соответствующий участок кода. Все это полностью аналогично многовариантному if, хотя очень часто на условия накладываются некоторые ограничения. Но если операторы if в разных языках обычно похожи, то операторы case очень сильно отличаются. Почему-то создатели языков не могут прийти к единому мнению о том, как именно этот оператор должен выглядеть и что он должен делать.

Первой конструкцией, отдаленно напоминающей case, был уже упоминавшийся арифметический if в Fortran. Смысл его был такой:

Существовал даже совсем уже странный случай

case i goto 11,3,6,23,1,44,11

что значит: при i равном 1 перейти к метке 11, при i равном 2 перейти к метке 3 итд.

Противоположной экстремальной формой оператора case можно считать многовариантный if, в котором на условия не накладывается никаких ограничений.

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

В языке Pascal выражение для выбора варианта должно быть перечислимого или целого типа. В условиях не могут использоваться диапазоны и произвольные условия. Вариант по умолчанию есть в некоторых диалектах и называется else. После выполнения условия происходит завершение выполнения оператора.

case X of
 0: writeln('ноль');
 1, 5, 7, 9: writeln('нечетное');
 2, 4, 6, 8: writeln('четное');
else
writeln('другое');
end;

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

switch(n) {
  case 0:
    printf("Ноль");
    break;
  case 1:
  case 2:
  case 3:
  case 4:
  case 5:
printf("Меньше шести\n");
  case 6:
printf("Меньше семи\n");
  case 7:
  case 8:
printf("Меньше девяти\n"); break;
  default:
    printf("Не знаю, что с этим делать\n");
}

Оператор break завершает выполнение оператора case. Таким образом, при n равном 6 в этом примере будет напечатано : Меньше семи
Меньше девяти
Таким образом, в C каждый case операторa switch аналогичен паре if - goto.

В языке Ruby, на котором был первый приведенный пример, выражение для выбора варианта может быть любого типа, допускающего сравнение. В условиях могут использоваться диапазоны и разнообразные условия. Вариант по умолчанию есть и называется он else. После выполнения условия происходит завершение выполнения оператора. Кроме того, case в языке Ruby может быть использован для получения значения:

kind = case year
    when 1850..1889 then "Blues"
    when 1890..1909 then "Ragtime"
    when 1910..1929 then "New Orleans Jazz"
    when 1930..1939 then "Swing"
    when 1940..1950 then "Bebop"
    else "Jazz"
end



Достаточно экстремальный вариант использования такого универсального оператора case приводит Ч. Уэзерелл:

select TRUE of
 (x = 0): sign = 0;
 (x < 0): sign = -1;
 (x > 0): sign = 1;
end select;

Смысл такого фрагмента становится понятен не сразу.

Разобравшись с ответами на эти вопросы, можно использовать оператор case (или switch, что то же самое) и переводить программы с других языков. Например, для перевода оператора switch с C на Pascal в общем случае придется использовать набор команд if ... then ... goto. Однако, если каждый вариант в программе на C завершается оператором break, то программа на Pascal может быть написана с использованием case.

Интересно, что в противостоянии «C-шного» и «паскального» операторов выбора окончательного решения так и не принято. В языке Java оператор switch полностью копирует вариант из C, тогда как в языке C#, тоже созданном на основе синтаксиса C, switch пишется как в C, но имеет полностью «паскальную» семантику, то есть оператор break после каждого варианта писать строго обязательно.

Оператор повторения

Третья фундаментальная конструкция после следования и выбора вызывает повторение одного и того же фрагмента программы.

x = 0;
while (x < 3)
{
   printf("x = %d\n",x);
   x = x+1;
}

Пока условие, написанное после while, остается истинным, участок программы повторяется снова и снова. Можно предположить, что условие продолжения цикла должно вообще-то изменяться в теле цикла. Но этого недостаточно, поскольку есть очевидные ситуации (увеличиваем x на четном шаге и уменьшаем на нечетном), когда цикл не завершается. Более того, существуют программы, в которых условие внутри цикла изменяется, но мы не знаем, завершится ли цикл. Примером может служить так называемая проблема Улама:

while n > 1 do
begin
   if odd(n)
then 
     n := 3*n + 1
else
     n := n div 2
end;

До сих пор науке неизвестно, завершится ли этот цикл при произвольном n.

Таким образом, оператор повторения является хорошим средством зацикливания программ и создания ошибок.

Почти во всех языках существует специальная форма этого оператора под названием for. Она собирает в одном месте: первоначальное присваивание значения, проверку условия окончания, изменение переменной.

for (x = 0;x < 3; x = x+1)
{
   printf("x = %d\n",x);
}

В некоторых языках, например, в Pascal, оператор for организован так, чтобы хоть иногда можно было проверить, завершится ли он.

for x := 0 to 2 do
begin
   writeln('x = ',x);
end;

Поскольку начальное и конечное значения x известны, и на каждом шаге x изменяется на 1, то исход выполнения оператора можно предсказать. Однако, в языке C это полезное начинание полностью истреблено. Оператор for в языке C это просто механическое соединение трех выражений - для начала, окончания и изменения значения. В них могут использоваться произвольные переменные, и любое из них (и все три вместе) может отсутствовать. Другие языки, напротив, предлагают еще более экстремальную форму этого оператора

for i in 0..5
 ...
for n in range(1, 5):
 ...

Цель - подчеркнуть тот факт, что i будет последовательно принимать все целые значения из диапазона от 1 до 5 включительно, и никакие другие.

Существует также две ситуации, когда while и for одинаково неудобны. Первая - это довольно странное с точки зрения чистой теории намеренное зацикливание программы. Это довольно часто встречается в жизни, поскольку существуют программы, которые действительно никогда не должны заканчиваться, а кроме того, существуют способы принудительного завершения программ. К таким программам относятся, например, программы управления различными промышленными объектами вроде электростанций или доменных печей. Эти производства никогда не останавливаются, и никакого смысла в завершении управляющей программы там нет.

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

Обычно в таких случаях пишут что-то вроде

for(;;)
...

или

while true do
...

Но в некоторых языках есть специальная конструкция для этой цели:

loop
...
break;
...
end;

Оператор break используется в том случае, когда нужно все-таки прервать выполнение цикла. Следующим после break выполнится тот оператор, который стоит после end.

Удивительно, но оператор бесконечного цикла значительно менее популярен среди конструкторов языков программирования, чем так назвываемый цикл с проверкой в конце:

Pascal

x:=0;
repeat
  x:=x+1;
until x>=3

Обратите внимание, что условие окончания цикла не только переехало в конец, но и изменилось на противоположное. Теперь оно читается как «до тех пор, пока не станет x >= 3».

В языке C есть похожий оператор.

x:=0;
do{
  x:=x+1;
}while(x<3);

Здесь смысл условия не изменяется. Зачем так сделали ? Видимо, автор языка Pascal пытался приблизится к английскому языку и сказать что-то вроде ”repeat something until condition is satisfied”. Создатели же C смотрели на проблему с точки зрения программиста, которому удобнее, чтобы операторы были более единообразны.

Вполне естественно, что оператор break (в других языках он может называться exit или leave) можно использовать также внутри циклов while, for и repeat..until. Это удобно, например, при поиске в массиве.

Любой из операторов for, loop и repeat..until можно заменить оператором while. В случае for достаточно «разобрать» оператор на три части и вставить их в соответствующие места оператора while. Про бесконечный цикл мы уже говорили. Как превратить repeat..until в while, догадайтесь сами.

Гораздо интереснее тот факт, что любой оператор if тоже можно выразить через while. Делается это так:

if ( a == 2)
  printf (“yes”);

заменяется на

bool p = true;
while ( a == 2 && p )
{
	printf (“yes”);
	p = false;
}

Таким образом, все разнообразие операторов управления пока что свелось к одному единственному while. Зачем же их так много ? Для удобства программиста. Вспомните, что говорилось про «syntax sugar».

Метод переменных состояния.

Наиболее универсальным способом решения всех проблем, связанных с организацией циклов является так называемый метод переменных состояния. Мы его только что использовали, введя переменную p для своевременного завершения цикла. Идея метода в том и состоит. Любой цикл записывается в виде

p := true;
while p do
...

Переменная p изменяется в теле цикла так, чтобы обеспечить выход в нужный момент. Если нужно выйти из цикла в произвольном месте, можно написать:

p := true;
while p do
begin
  ...
  if x=0 then p:=false;
  if p then
  begin
   // остаток цикла
  end
end;

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

Оператор GOTO

Кроме while (он же if, repeat..until , case, unless итп) в арсенале операторов управления имеется еще один. Он называется goto и вызвал, наверное, самую продолжительную и ожесточенную дискуссию среди пользователей и авторов языков программирования. Проблеме goto были посвящены многие статьи, она обсуждается уже не один десяток лет, но решения пока что нет. Формулируется эта знаменитая проблема следующим образом: «если оператор goto настолько плох, может быть его совсем убрать ?».

То, что goto действительно плох, возражений в общем-то не вызывает. Действительно, лучшим способом безнадежно запутать любой код является неумеренное использование этого оператора. Почему же от goto так трудно отказаться ? Причина проста. Рассмотрим часто встречающийся в жизни алгоритм поиска:

var 
	a:array[1..10] of integer;
	i:integer;
	label 1;
begin
    ...
	for i:=1 to 10 do
	  if a[i] = 0 then goto 1; // ищем элемент, равный 0
	write('не ');
  1:	writeln('найдено');
end.

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

Еще одна ситуация, когда goto упрощает чтение программы это обработка аварийных ситуаций. Сравните два варианта одной и той же программы:

if x>=0 then
begin
  y := sqrt(x) + f(x);
  if y > 0 then 
  begin
    z := 1/y;
    writeln (z);
  end
  else
    writeln ('error');
end
else
 writeln ('error');
if x<0 then goto 1;
y := sqrt(x) + f(x);
if y = 0 then goto 1;
z := 1/y;
writeln (z);
 ...
1: writeln ('error');

Заметим, что и здесь можно использовать переменные состояния, хотя это не приведет к более читаемому коду. Программисты привыкли писать goto, и авторы языков решают эту проблему сразу в двух направлениях. Обычно в языке оставляют оператор goto и в дополнение к этому для каждого случая, когда goto может быть полезным, вводят специальный оператор. Один из таких операторов мы уже видели - это break, завершающий цикл. Вместо «goto к началу цикла» в язык вводят оператор continue, а вместо «goto к концу процедуры» - оператор return. Заметим, что в языке Pascal исходно не было ни одного из этих «суррогатных» goto, а в языке C и в поздних вариантах Pascal они есть почти все.

Две задачи, которые не решаются при помощи суррогатных goto это выход из вложенных циклов и анализ причины завершения цикла. Решение первой проблемы предлагается в языке java:

outerloop:
for (i=1; i <=6; i++) {
	for (j=1; j <=6; j++) {
	  if (next_value == 0) break outerloop;
	}
} 

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

Частичное решение второй проблемы предлагается в языке Python:

for j in range(1, 10):
   if f(i,j) == 0:
       break
else:
  print('не найдено')

Здесь часть else относится не к if, а к for и выполняется в том случае когда цикл завершен нормальным путем, а не посредством break. Так что же, goto это объективное зло ? Нет, это просто инструмент. Но инструмент, об который слишком легко порезаться. Вот несколько правил техники безопасности, соблюдая которые, можно писать вполне читаемый код с использованием goto:

  1. Оператор goto применяется в двух случаях: для выхода из цикла (короткий goto вперед) и для создания бесконечного цикла (длинный goto назад).
  2. В случае выхода из цикла оператор goto должен находиться не далее, чем в 20 строках (то есть на той же странице) от метки, на которую делается переход.
  3. В случае зацикливания программы обязателен комментарий как к самому goto, так и к метке, на которую делается переход.