Skip to content

Latest commit

 

History

History
1829 lines (1414 loc) · 122 KB

proglang2.md

File metadata and controls

1829 lines (1414 loc) · 122 KB

Данные, имена и значения

Что для одного человека константа, для другого переменная
Алан Перлис

Следующая часть материала посвящена подробному исследованию элементов языков.

Прежде всего рассмотрим имена. Они есть почти в любом языке программирования, и можно сказать, что языки программирования были изобретены ради имен. Ассемблер иногда называют системой программирования в символических адресах, под термином "символические адреса" в этом определении понимаются имена, которые программист дает адресам и числам.

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

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

3 девицы D1,D2 и D3 под окном
Пряли поздно вечерком
...

В языках программирования высокого уровня роль имен значительно расширилась. Но чаще всего имя является названием для места памяти, где (возможно) что-то лежит. Рассмотрим фрагмент программы

X = X + 2;

Заметим, что два X здесь обозначают разные вещи. Первый X это место, куда должно быть записано значение. Второй X обозначает само значение. Фактически такая запись это syntax sugar для

X = value(X)+2;

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

Переменные

Различают так называемые “левый” и “правый” контексты для переменных. Еще раз внимательно разберемся с терминологией. Пусть переменная с именем X равна числу пи. Тогда у нас есть:

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

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

Простая переменная имеет следующий вид:

имя —> ссылка —> значение

Здесь стрелки означают, что зная имя, можно получить ссылку (адрес), а зная адрес, можно получить значение. Конкретный способ получения для нас сейчас не очень важен. Заметим только, что память, в которую можно записать значение, или адрес этой памяти, может ассоциироваться со ссылкой как в процессе компиляции программы, так и во время выполнения. Реальное значение адреса обычно скрыто от программиста, но во многих языках его можно получить, например, с помощью операции & в языке C, или @ в Pascal. В других языках связь между значением и ссылкой не такая простая, и получить адрес в непосредственно машинной форме невозможно. Но он всегда где-то есть, ведь значения переменных больше негде хранить, кроме как в памяти компьютера.

Константы

Константа имеет вид

имя —> значение

Внутренняя “механика” процесса получения значения по имени, опять же, может быть достаточно сложной, но смысл всегда остается тот же самый — по имени можно получить значение. Частным случаем констант являются числа, тут можно считать, что имя отсутствует, а можно считать, что именем является сама запись числа. Это не существенно, важно лишь то, что константа не предполагает способа для изменения значения. Соответственно, слева от знака присваивания константа сама по себе появляться не может.

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

Существует интересная разновидность констант, у которых есть имена, а вот фактические значения, им соответствующие, нас не интересуют. Так, например, кодируя пол человека, можно мужскому поставить в соответствие 1, а женскому 0, а можно мужскому 95, а женскому 34, и ровным счетом ничего от этого не изменится. Подобные константы в Паскале задаются посредством перечислимых типов, а в C с помощью перечислений (enum). В качестве примера рассмотрим описание колоды карт (например, для игры пасьянс в Windows)

Pascal:

type cardsuit = (clubs,diamonds,hearts,spades);

C:

enum cardsuit {CLUBS,DIAMONDS,HEARTS,SPADES};

В функциональном языке Erlang такие константы используются очень широко, и любое слово, не являющееся ключевым словом языка и начинающееся с прописной буквы, является с точки зрения языка константой с неизвестным программисту числовым значением. Некоторые такие константы по соглашению имеют предопределенный смысл, например, константа true.

Числовые типы и подтипы

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

В большинстве случаев различают следующие числовые типы:

Натуральный, Целый, Вещественный, Комплексный. Не в любом языке программирования существуют все эти типы, но их обычно стараются вводить в язык, не в первой версии, так в десятой. Например, в языке С изначально не было комплексных чисел, но в стандарте C99 их ввели. Стандартные операции (+ - * /) для одних и тех же чисел разных типов выполняются по-разному. Для натурального и целого типов отличаются результаты операции вычитания. Значение выражения 10 - 20 для целых чисел будет равно -10, а для натуральных это скорее всего ошибка, хотя можно получить достаточно неожиданный результат вроде 4294967286 или 65526. На разных машинах он может быть разным, но все равно неправильным. Разница между целыми и вещественными числами состоит в результате операции деления. Обычно деление целых чисел рассматривают как деление нацело, то есть 4/3 = 1.

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

Сложно сказать, какой способ хуже. Во многих языках есть правила приведения типов, то есть автоматического преобразования типов в выражении. В языке MODULA-2, например, вообще запрещено смешивать целые числа с вещественными без явного их преобразования. Другое решение было принято в некоторых вариантах языка BASIC - там вообще нет целых чисел. В заключение темы попробуйте определить, что делает вот такая программа:

int i,j;
int a[10,10];
for (i=1; i<11;i++){
    for ( j=1; j<11;j++){
      a[i-1,j-1] = (i/j) * (j/i);
    }
}

Числовые подтипы

В компьютере обычно имеется несколько вариантов представления чисел того или иного типа. Например, натуральное число может занимать 1 байт, 2 байта, 4 байта итд. Если 1 байт это 8 бит, то с помощью одного байта можно представить числа от 0 до 255, всего 256 значений. Логично было бы как-то выразить такую машинно-зависимую возможность в языке. Хотя бы с целью экономии памяти. С другой стороны, существует много видов данных, представляемых в виде ограниченных числовых диапазонов. Месяцев в году всего 12, дней в месяце не более 31, а химических элементов непонятно сколько, но вряд ли более 200. Для таких типов можно ввести в язык отображаемую на реальные типы данных возможность - указать диапазон изменения типа, а компилятор пусть подберет наиболее подходящее внутреннее представление. В некоторых языках идут еще дальше, проверяя, какие именно значения программист пытается присвоить переменным, и проверяя, какие действия можно, а какие нельзя совершать со значениями тех или иных типов.

Подтипы в языке Ada. Язык Ada имеет одну из наиболее развитых систем типов и подтипов, поэтому мы рассмотрим ее более внимательно. В этом языке есть две возможности. Во-первых, можно явно объявить один тип как подтип другого. При этом переменным базового типа можно присваивать значения производного типа.

type WeekDays is range 1 .. 7;  -- Дни недели это числа от 1 до 7
subtype WorkDays is WeekDays range 1 .. 5; -- Рабочие дни это числа от 1 до 5
A : WeekDays := 8; -- ОШИБКА!
B : WorkDays := 4;
C : WeekDays := B; -- OK

Другой пример:

-- Натуральные числа
subtype Natural  is Integer range 0 .. Integer'Last;

-- Положительные числа
subtype Positive is Integer range 1 .. Integer'Last;

Во-вторых, можно объявлять производные типы, сохраняющие все свойства базового,но несовместимые с ним.

type Apples is new Integer ;
type Oranges is new Integer ;
A : Apples := 8;
B : Oranges := A+B; -- ОШИБКА!
-- Яблоки нельзя складывать с апельсинами.

Атрибуты данных

Откуда компилятор знает, что можно делать с переменными, а что нельзя? Посмотрим еще раз на диаграмму чуть выше, изображающую переменную. Как мы уже говорили, ссылка это не просто адрес в памяти, но еще и информация о типе данных. Если программа компилируется, то эта информация зачастую просто “выбрасывается” после генерации кода. Все проверки соответствия типов производятся во время компиляции. Другой подход состоит в том, чтобы хранить где-то информацию о типе данных и проверять соответствие типов во время выполнения. Часто комбинируют оба подхода, или же один и тот же компилятор может сохранять какую-то информацию о типе, а может и не сохранять в зависимости от настроек. В компиляторах языка C++ эта информация называется RTTI (Run-Time Type Information).

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

Языки L-типа и R-типа. В языках L-типа информация о типе является частью ссылки, а в языках R-типа - частью значения. При этом неважно, когда именно используется информация о типе - во время компиляции или во время выполнения программы. Если в языке L-типа переменной каким-нибудь способом подсунуть данные не того типа, среда исполнения не будет «знать», что данные неправильные. Наиболее распространенным примером является выход за границы массива. В языках R-типа такой трюк невозможен. К языкам L-типа относятся, например, Pascal и C. Пример языка R-типа - PHP. В нем выйти за границы массива не удастся - они хранятся вместе со всем массивом.

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

Хотя в языке L-типа всегда можно сделать проверки на безопасность, многие программисты полагают, что основное достоинство таких языков - это именно возможность делать что угодно, обходя проверки. Действительно, некоторые алгоритмы системного программирования работают быстрее и реализуются удобнее при наличии возможности обойти проверку типов. Поэтому возможности так или иначе «обмануть компилятор» рано или поздно добавляют в большинство языков L-типа. Например, в языке Pascal записать значение за границей статического массива невозможно, но уже в варианте Turbo Pascal такая возможность есть. Покажем, как в Turbo Pascal можно записать что-то в память за границами массива.

type
small_array = array[1..10] of integer;
big_array = array[1..100] of integer;
cheat = record
    case integer of
    1: (x: ^small_array);
    2: (y: ^big_array);
    end;
var test:cheat;
    v:small_array; (* это жертва нашего теста *)
begin
  (* v[11]:=5; *)  (* так сделать не получится *)
  test.x := @v;
  test.y^[11]:=3;  (* 11й элемент находится за границей массива *)
end.

Языки L-типа можно приблизить к языкам R-типа с помощью программного моделирования.

В дополнение к L и R языкам, формально можно выделить еще и языки N-типа, где атрибуты — часть имени. В практически вымершем уже диалекте языка BASIC все переменные, имена которых заканчиваются на $ являются строковыми.

Basic
REM ВЕЩЕСТВЕННОЕ
X = 5.5
REM СТРОКА
X$ = "SOME TEXT"

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

$foo # переменная
@foo # массив
%foo # таблица
FOO  # файл
&foo # подпрограмма
 # и это все - разные имена

Логические значения

Практически в любом языке есть какой-то вариант операторов if, while и других, требующих для своей работы значения вида “правда”-“ложь”, иначе называемых логическими. Даже если в языке нет специальных значений этого типа, они все равно неявно присутствуют. Обычно существует набор операций, дающих в результате значения логического типа. Например <, >, >= и другие. Для представления логических значений используют следующие приемы:

1. Выделенный тип. В этом случае можно явно объявлять логические переменные, способные принимать всего 2 значения. Тип этот часто называют Boolean, bool или logical, а значения - true и false.

Pascal

var x : Boolean;
x := 4 < 5;
if x then

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

BASIC

IF X < 3 THEN GOTO 44
REM а так нельзя :
P = X < 3

3. Использование другого типа, например целого. Такой подход использован в языке С - любое целое число, не равное 0 означает true, а 0 означает false.

int x;
x = 4 < 5;
if (x) {
 …

Описания переменных

Некоторые языки, например Pascal и C, требуют описания переменных до их использования. В других языках первое появление имени переменной в тексте программы является описанием этой переменной. Иногда переменные можно описывать, но делать это не обязательно. Таким образом, можно выделить два основных признака языков по отношению к описанию переменных: можно ли описывать переменные в языке и необходимо ли это делать.

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

1. Переменная может содержать 0. Проблема с этим подходом состоит в том, что не все типы допускают 0 в качестве значения. Достаточно посмотреть на описанный выше тип Positive.

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

3. Использование неинициализированных переменных может быть запрещено или невозможно.

4. Для каждого типа существует свое собственное заведомо корректное начальное значение. В объектно-ориентированных языках этот способ расширяется довольно элегантным способом, который мы рассмотрим дальше. Обычно в язык вводят возможность объявлять переменные, сразу присваивая им значения. Например:

C

int mass = 84; length = 50;

Часто описание переменной включает только название ее типа, например,

int m;

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

auto m = 15; // m - целочисленная переменная

а в языке NIM просто не писать название типа:

var x, y: int    # объявляем две переменные типа int
var s = "abc"    # объявляем строковую переменную

Переменные-ссылки

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

имя — > ссылка —> значение (само является ссылкой) —> значение

Иногда их так и называют - ссылочные переменные. Впервые такие переменные появились в языке Algol-68.

C

int *p; int q = 5;
p = &q;
*p = 3; // теперь q тоже равно 3

PASCAL

var p,q: ^integer;
new (q);
q^:=5;
p:=q;  // p и q теперь обозначают одно и то же место в памяти
p^:=3  // теперь q^ тоже равно 3

Обратите внимание на операторы * в C и ^ в Pascal. Они осуществляют так называемую операцию разыменования, или получение переменной по ссылке. Таким образом, если у нас переменная p является ссылкой на целое

(p —> ссылка) —> ссылка на целое -> значение

то *p это как если бы у нас была такая переменная с именем *p, объединяющая в себе первые два элемента данной цепочки

*p — > ссылка на целое —> значение

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

int *p = 0;     // Это присваивание начального
                // значения ссылке (а не переменной)
*p = 0;         // Это копирование значения в то
                // место, на которое указывает ссылка

Для чего нужны ссылочные переменные? В основном они призваны решать три ответственные задачи.

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

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

3. Передавать параметры в процедуры, что является специальной разновидностью случая 1. Это мы будем подробно разбирать чуть позже, в разделе, посвященном процедурам и функциям.

Хотя в языках Python и Javascript нет ссылочных переменных в явном виде, неявные ссылки там есть, и они часто приводят к тому, что программа делает не то, что имел в виду программист.

a = [2,3]
b = a
b[0] = 42
print(a) # напечатает [42,3]

Мы поменяли значение переменной b, а изменилось еще и значение переменной a. То есть две эти переменные являются ссылками на одно и то же место в памяти.

При внимательном рассмотрении процесса разыменования возникает вопрос: куда указывает ссылка с самого начала? Дело в том, что ссылка вообще-то не обязана указывать на какую-то память, и для реализации динамического распределения памяти нам как раз и нужны ссылки, “ни на что не указывающие”, для которых мы впоследствии выделим память. Как мы помним, в языке L-типа определить, что именно содержится в данном участке памяти, во время выполнения программы невозможно.

Попытка читать и писать данные по произвольному адресу может привести к непредсказуемым последствиям. Таким образом, имеется существенное отличие между начальными значениями числовых переменных и начальными значениями ссылок. Для первых большинство (или все) возможные сочетания бит представляют собой разрешенные значения, за редкими исключениями, а для ссылок наоборот — абсолютное большинство адресов вызовет ошибку при попытке их разыменования (хотя являться значением ссылочной переменной они могут). Иначе говоря, далеко не все возможные адреса содержат осмысленные данные определенного типа, не все возможные адреса вообще есть в компьютере, не все возможные адреса являются адресами памяти, и не все возможные адреса памяти доступны данной программе.

Для решения этой проблемы в языках L-типа обычно выделяется специальная константа, называемая NULL или nil, которую можно присваивать любой переменной ссылочного типа. При этом гарантируется, что ни один “легальный” адрес такого значения иметь не будет. Чаще всего константа NULL численно равна 0, но это не обязательно так.

Присваивая значение NULL или nil, можно получить указатель, не ссылающийся ни на какую область памяти, и более того, используя это значение, можно проверить, что указатель ни на что не ссылается. Таким образом, “пожертвовав” одним значением из многих тысяч возможных, решили важную проблему с указателями.

C

int *a = NULL;
…
if (a == NULL) // память не выделена
   a = malloc(100); // выделение памяти

Pascal

type
   arr = array[1..20] of integer;
   parr = ^arr;
var p:parr;
begin
  p:=nil;
…
  if  p = nil then
      new(p);
end.

Область видимости переменных

Вводя в языки переменные, их создатели обеспечили програмистам возможность разными именами без всяких сложностей называть разные вещи. Ссылки дают возможность разными именами обозначить один и тот же участок памяти. Логично было бы спросить, можно ли одним и тем же именем назвать разные вещи? Разумеется, можно. Представим себе, что было бы, если бы это было не так. Если бы каждое имя в программе было закреплено за строго определенным участком памяти, то, прежде всего, намного осложнилась бы совместная работа нескольких программистов над одной и той же программой. Им пришлось бы договариваться о том, какие имена кто из них использует. Например, один мог бы все идентификаторы начинать с alice_, второй с bob_ и так далее. Кроме того, многие переменные, не несущие смысловой нагрузки, например, вездесущий параметр цикла i, пришлось бы каждый раз называть по-новому. Да еще учитывать, какие имена уже использованы, а какие еще нет.

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

Блоком называется участок программы, заключенный между открывающей и закрывающей операторными скобками, например begin…end в Pascal или {} в C/C++ или выделенный каким-то другим способом.

Примеры

ALGOL 60

begin integer x,i,z;
  x := 3;
  begin real x
    x := 5.5;  comment: это уже другой x;
    i := 6;
  end;
end;

C++

{
  int x,i,z;
  x = 3;
  {
    double x;
    x = 5.5;  // это уже другой x
    i = 6;
  }
  x = 7;  // возвращаемся к прежнему пониманию x
}

// проблема :
for (int i = 0; i < 3; i++)
    cout << i << endl;
for (int i = 0; i < 5; i++)   // это та же самая i
    cout << i << endl;        // или другая или это ошибка?

В языке NIM (с синтаксисом, похожим на Python) для создания блока используется ключевое слово block

block myblock:
  var x = 25
echo x # не работает, x тут не определено

В связи с областями видимости идентификаторов возникает целый ряд своеобразных проблем. Первая из них может показаться надуманной: а что если программист, только что “закрывший” вложенным объявлением внешнее, вдруг решит все-таки обратиться к внешней переменной (она же никуда не девается во время выполнения блока, просто ее имя временно отдается другой переменной). Несмотря на явную абсурдность такого вопроса (зачем тогда было называть внутреннюю переменную тем же именем?), некоторые языки предоставляют такую возможность. Так, в C++ можно обратиться к переменной, находящейся на самом верхнем (глобальном) уровне, поставив перед именем двойное двоеточие ::x

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

typedef enum { LESS, NOT_LESS } RELATION_A_TO_B;

RELATION_A_TO_B c;

if (a < b)
  c = LESS;
else
  c = NOT_LESS;

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

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

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

Lisp:

(defconstant LESS 1)
(defconstant NOT_LESS 2)
(setq a 5)
(setq b 3)
(setq x
    (cond  
         ((< a b) 'LESS)  
         (t 'NOT_LESS)  
    )
)
(write x)

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

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

От ссылок и указателей логично будет перейти к задаче распределения памяти при выполнении программы, или, что то же самое, отождествлению ссылок (переменных или констант) с конкретными физическими адресами памяти.

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

C:

static int i = 10;

Автоматическое распределение памяти используется для тех переменных, которые в С, Pascal или PHP описываются внутри функций. При вызове функции или входе в блок переменная создается, ей выделяется какое-то место в памяти для хранения значения, при завершении функции или блока - место освобождается.

C

int function_auto()
{
    int p = 10; // переменная p - автоматическая
    p = p+1;
    return p;
}
// переменная p больше не существует

Динамическое распределение памяти это постановка в соответствие ссылочным переменным каких-то адресов памяти.

С

int main(){
    int *a;
    a = (int *) malloc(100);
    a[5] = 33;
    free(a);
}

Pascal

procedure testmem;

type intarray = array [1..100] of integer;

var a : ^intarray;

begin
  new(a);
  a^[5] := 33;
  dispose(a);
end;

Java

int [] a;
a = new int[100];
a[5] = 33;

Функция malloc в C, операторы new в Pascal, C++ и Java выделяют память для переменной. Заметим, что сама переменная-ссылка, которая используется для доступа к выделенной памяти, может относиться к классу автоматической памяти. В этом случае при завершении блока память останется распределенной, а единственное средство доступа к ней будет потеряно. В C и Pascal существуют средства (free и dispose соответственно) для того, чтобы освободить память перед тем, как средство доступа к ней будет потеряно. Почему память не освобождается автоматически? Вспомним п.1) из списка “для чего нужны ссылки”. Две и более переменных могут указывать на один и тот же адрес памяти.

Может существовать другая ссылочная переменная, указывающая на тот же участок памяти. Освобождать память автоматически можно только тогда, когда не останется ни одной переменной, на нее ссылающейся. В C и Pascal следить за тем, чтобы вся полученная от системы память возвращалась обратно, должен программист. Однако в языке Java никакого аналога функции free мы не находим. Получается, что есть какой-то способ автоматически освобождать память? Есть, но он связан с большим объемом программного моделирования. Проверять, не найдется ли где-нибудь еще одна переменная, указывающая на тот же адрес, каждый раз при выходе из блока было бы слишком накладно. Поэтому такую проверку выполняют сразу для всех переменных и для всех выделенных блоков памяти. Те участки памяти, к которым нет доступа, освобождаются. Эта довольно сложная процедура носит название “сборка мусора”. Зато программисту не приходится думать о том, нужно ли освободить память при выходе из блока, где она выделялась.

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

Программисты — не математики, как бы нам этого ни хотелось.
Ричард Гэбриел

Выражения

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

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

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

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

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

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

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

Вот цитата из руководства по языку LISP: «PROG-выражение всегда заканчивается как минимум пятью скобками»

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

Уже рассмотренное выражение запишется в нем как 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 2 \* 3 5 читается хуже, чем (+ (+ 2 2) (\* 3 5))

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

           /
     +          *
(2       3) (4     8)

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

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

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

3 3 * 5 5 * + .

вычисляет значение выражения 3 * 2 + 5 * 2 и печатает результат.

Для удобства работы со стеком существуют служебные операции, например, 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 - 3*X)), то его, а также элементарные функции будут одноместными, или унарными операциями. Иногда встречаются и трехместные, или тернарные операции. Так, в языке С

y = x > 0 ? x : 0;

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

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

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

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

Что касается полиморфизма, вносимого программистом, то тут единства подхода нет. В ранних языках определять полиморфные операции и функции, как правило, было невозможно. Особенно удивительно это выглядит в языке Modula-2, в котором смешивать типы данных в выражениях запрещено.

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

В школе нас учили, что умножение и деление выполняются прежде сложения и вычитания. Подавляющее большинство языков программирования заимствует и расширяет эту практику. Поскольку для программирования требуется несколько больше бинарных операций, чем для арифметики, на дополнительные операции, такие как 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 в этой записи просто обозначает ссылку на какое-то место в памяти.

Побочные эффекты

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

y = 2 * 2;

на

y = 4;

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

(x++) + (++x)

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

Операции, определяемые программистом

По-видимому, всем создателям ранних языков программирования хотелось дать программистам возможность определять свои собственные операции. Однако, единого мнения по поводу того, как это следует делать, до сих пор не сложилось. В языке C++, например, можно переопределять операции, уже имеющиеся в языке, для своих типов. Например, операция << исходно означает сдвиг числа влево (умножение его на 2^N). Эта же операция, примененная к потокам вывода, означает вывод значения в поток с использованием формата по умолчанию.

cout << "Значение x " << x;

В языке NIM возможно определение вообще любых своих знаков операций, состоящих из символов + - * \ / < > = @ $ ~ & % ! ? ^ . |

proc `+$+` (x: int): string =
...
# теперь у нас есть операция +$+

var s:string = "Hello " & +$+ 5
echo s

Операторы

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

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

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

e1 = e2

Это сходство с математической записью много лет являлось проблемой при освоении программирования, поскольку программистами чаще всего становились математики, привыкшие, что e1 = e2 означает не «вычислить значение e1 и записать его как e2», а нечто другое. Для математика e1 = e2 и e2 = e1 это примерно одно и то же. Чтобы математикам было проще воспринимать обозначения языков программирования, в языке Algol ввели новое обозначение e1 := e2, асимметричным видом оператора намекая на то, что e2 := e1 это не совсем то же самое. Изобретатель языка 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”);

Результатом операции присваивания является присваиваемое значение. Поскольку в 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;

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

Оператор кратного присваивания мы встречаем в языках Python и Lua

x,y = 3,5 # присваивает x 3, а y - 5

А что будет, если написать

x,x = 3,5

Наверное, правильно было бы объявить, что результат такого присваивания не определен, как это и сделано в языке Lua.

Совместимость типов: что можно присваивать

В большинстве языков программирвания оператор присваивания делает нечто большее, чем просто копирование байтов из одной области памяти в другую. Например, при присваивании обычно производится приведение типа правой части к типу переменной левой части. Во многих объектно-ориентированных языках можно переопределить оператор присваивания так, что при его использовании с определенными типами или элементами структур будут происходить какие-то дополнительные действия. Например, в системе Delphi/Lazarus присваивание

...
Label1.Caption := 'Hello, World';

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

В Javascript тот же подход применяется в системе vuejs, где такое приcваивание:

this.message = "Hello World";

приводит к изменению кода HTML, который отображается в данный момент.

В связи со всем этим возникает вопрос о том, что же можно присваивать, а что нельзя, и всегда ли мы можем написать a = b; если a и b - значения одного и того же типа? Ответ, как обычно, зависит от языка. Например, в C нельзя присваивать массивы.

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

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

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

let name = readLine(stdin)
# name = "Paul" # так нельзя
fn main() {
    let x = 5;
    println!("The value of x is: {}", x);
    x = 6;    // ошибка!
    println!("The value of x is: {}", x);
}

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

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

let x = 10;;

это не присваивание переменной x значения 10 (хотя очень похоже), а определение имени x и установление соответствия константы 10 этому имени. Для описания функции используется в точности такой же синтаксис:

let sum a b = a + b;;

Это похоже на однократные присваивания, но смысл (семантика) этих выражений другой. В языке Elixir знаком = так и вовсе обозначается сопоставление с образцом, так что можно написать:

{1,2,p} = {1,2,42}

при этом переменной p будет поставлено в соответствие число 42.

При изучении того или иного языка программирования, нужно найти ответы на следующие вопросы:

  • есть ли в данном языке кратные присваивания?
  • есть ли однократные присваивания?
  • возможны ли вообще присваивания?

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

С точки зрения терминологии более правильно некоторые операции 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), хотя в выражении

x = fun1(x) + fun2(y)

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

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

IF x<y THEN
  writestr(“yes”);
  x:=y;
ELSE
  writestr(“no”);
END;

FOR i:= 1 TO 10 DO
 s:=s+1;
END;
x = 5
if x < 6 then
  print("yes");
  x=7;
else
  print("no");
end

s = 0
for i=1,10,1 do
 s = s+i;
end

print (s);

Такой способ записи добавляет к большинству программ несколько лишних END, но зато улучшает читаемость и вводит единообразие в структуру программы. Можно пойти по этому пути еще дальше, и заканчивать все if на end if; все for на end for, и так далее. В этом случае будет более понятно, какой именно оператор заканчивается. Так сделано в некоторых диалектах языка Basic. Еще один весьма странный вариант этого способа группировать операторы был применен в языке Algol-68. Там каждый оператор if, case, итп, предлагалось заканчивать тем же словом, но написанным задом наперед: fi, esac и так далее. Этот способ остался в языке интерпретатора команд системы UNIX, который называется bash.

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

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

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

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);
}
begin
  print_number(4);
  print_number(5);
end

в C означает просто два вызова подпрограммы, а в Pascal - два вызова подпрограммы, за которыми следует пустой оператор.

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

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

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

if (a < b) print(a);

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

print(a) if a<b;

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

print(a) 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, else if или как-то иначе. В данном примере оператор p3 выполнится только в случае, когда <условие1> и <условие2> ложны, а <условие3> истинно.

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

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

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

Оператор выбора

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

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 очень сильно отличаются. По какой-то причине создатели языков не могут прийти к единому мнению о том, как именно этот оператор должен выглядеть и что он должен делать.

Встречались даже совсем странные случаи:

Algol:

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

Basic:

ON i GOTO 11,3,6,23,1,44,11

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

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

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

  • какого типа может быть выражение для выбора варианта?
  • могут ли в вариантах использоваться диапазоны значений?
  • могут ли в вариантах использоваться произвольные условия (не только равенство)?
  • существует ли вариант, который выполнится, если все условия ложны (else, default)?
  • что происходит после выполнения выбранного варианта?

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

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

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

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 после каждого варианта писать строго обязательно.

Все эти особенности и странности оператора switch/case привели к тому, что в наиболее популярных сегодня скриптовых языках Lua и Python этого оператора вообще нет.

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

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

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 это просто механическое соединение трех выражений - для начала, окончания и изменения значения. В них могут использоваться произвольные переменные, и любое из них (и все три вместе) может отсутствовать. Другие языки, напротив, предлагают еще более экстремальную форму этого оператора

Ruby:

for i in 0..5
...
end

Python:

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

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

for(;;) ... или

while true do ...

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

LOOP
...
  EXIT;
...
END;

Оператор EXIT используется в том случае, когда нужно все-таки прервать выполнение цикла. Следующим выполнится тот оператор, который стоит после 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».

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

Оператор GOTO, великий и ужасный

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

Этот ужасный оператор продолжает выполнение программы с нового места, отмеченного специальной меткой.

if(x > 10) goto l;
...
// 1000 строк кода
...
l: printf("x > 10")

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

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

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

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

Pascal:

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:

Java:

outerloop:  // метка цикла
  for (i=1; i <= 6; i++)
  {
    for (j=1; j <= 6; j++)
    {
        if (next_value(i,j) == 0) break outerloop;
    }
  }

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

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, так и к метке, на которую делается переход.

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

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

Ожесточенная дискуссия по поводу goto началась со статьи Э.Дейкстры, озаглавленной "Go To Statement Considered Harmful" и написанной в 1968 году. Эта дискуссия в какой-то момент привела к появлению статьи Френка Рубина ""Go To Statement Considered Harmful" considered harmful" (обратите внимание на вложенные кавычки).

Окончательной победой goto над академическими взглядами можно считать тот факт, что в реализации языка Modula-2, вышедшей в 1989 году, имелся этот оператор, хотя при разработке этого языка его там не предполагалось.