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

Ресурсы далее в этой статье.

Что касается структурированных нелокальных выходов, то вы,
видимо, хорошо знакомы с их самым распространенным типом —
исключениями. Если же последние 20 лет вы безвылазно занимались
программированием на C, Fortran и Cobol, то вот краткое введение в
исключения.

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

Ресурсы можно найти полезные рекомендации по этим вопросам.

Функции-генераторы

Другая разновидность структур продвинутой управляющей логики — генераторы.
Это функции, которые могут генерировать наборы значений и возвращать их
по одному при каждом вызове. Генератор может создать «закладку» на
своем месте в текущей функции, тем самым облегчив написание программы.

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

Листинг 4. Пример программы с генератором на Python

#Это наша функция-генератор
def sequence(start):
current = start
while true:
yield current
current = current + 1

generator_func = sequence(1)
#Создать новый генератор последовательности, начинающейся с 1
print generator_func.next() #выводит 1
print generator_func.next() #выводит 2
print generator_func.next() #выводит 3

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

Логическое программирование

Другая разновидность продвинутой управляющей логики, интенсивно
используемая в языках типа Prolog — логическое программирование. В
Prolog вы даете компьютеру набор определений, и он «волшебным образом»
отвечает на запросы и устанавливает для нас наборы значений. Например,
рассмотрим следующую программу на Prolog (заглавные буквы обозначают
переменные):

Листинг 5. Простая логическая программа на Prolog

likes(john,sports). %это объявляет, что Джон любит спорт
likes(john,food). %это объявляет, что Джон любит поесть
likes(amy,X) :- likes(john,X). %Эми любит X, если X любит Джон
likes(brad,food). %Брэд любит поесть
likes(brad,television). %Брэд любит смотреть телевизор

%запросить и вывести, что любит и Эми, и Брэд.
?- likes(amy,X), likes(brad,X), write(X).

Таким образом, Prolog создает списки того, что любит Джон и Брэд.
Также устанавливается правило для того, что любит Эми – она любит всё,
что любит Джон. Далее, когда выполняется запрос, то сначала находится
ответ на вопрос: «Что любит Эми?» Ответ — «Всё, что любит Джон». Далее
осуществляется проход по списку того, что любит Джон, и выбирается
первый элемент — спорт. Затем осуществляется переход к следующему
утверждению: Брэд должен любить то же самое, что и Эми (в этом
выражении обозначено как X ). Однако спорт не входит в список Брэда. Значит, Prolog откатывается и находит новое значение для X
из списка Эми. Следующее значение — еда. Далее проверяется, есть ли она
в списке Брэда. Есть, поэтому система переходит к следующему шагу —
записывает найденное значение X.

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

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

Итак, мы рассмотрели три разновидности продвинутой управляющей
логики: нелокальные выходы, функции-генераторы и перебор с возвратами.
Что между ними общего? По сути, каждая выполняет определённые
манипуляции с программным стеком и указателем команд. В нелокальных
выходах стек с блоками выхода отмечается “закладкой”, как и блок
обработки выхода. Когда вызывается нелокальный выход, стек
раскручивается до отмеченной точки, и указатель команд перемещается к
обрабатывающему блоку. В функциях-генераторах переменная содержит
указатель на стек генератора, а также на ту точку функции, где
генератор в последний раз остановился. В переборе с возвратами
“закладка” сохраняется там, где произошло присваивание, и управляющая
логика переходит на это место, если расчет не удается и требуется
возврат.

Эти закладки можно называть «точками продолжения» — позициями, где
программа продолжит исполнение, если будет вызвана структура
продвинутой управляющей логики. Или если точнее, то они известны как продолжения (continuations). В действительности все эти структуры управляющей логики можно реализовать при помощи одной функции: call-with-current-continuation.

call-with-current-continuation — это функция языка
программирования Scheme, которая принимает текущий стек и указатель
команд и объединяет их в единую вызываемую сущность ( продолжение)
, после чего вызывает другую функцию с продолжением в качестве
единственного её параметра. Продолжение — вызываемая сущность,
принимающая один параметр, который затем возвращается в точку создания
продолжения. Звучит несколько путано, но так оно и есть. Рассмотрим
несколько примеров, чтобы получить представление о том, как всё
выглядит на практике.

Для начала — небольшой пример использования продолжения:

Листинг 6. Пример продолжения

(display
;;Вызов продолжения вернет параметр как возвращаемое значение для функции
;;the call-with-current-continuation function. Это место, где помещается «закладка»
(call-with-current-continuation
;;Чтобы лучше запомнить, переменные продолжения часто пишутся с «k» или
;;«kont» — от «continuation». В этом примере продолжением будет «kont».
;;Это функция, которая при вызове вернет в помеченное “закладкой” место свой
;;параметр.
(lambda (kont)
;;В этом примере мы просто выполним продолжение. Оно возвращает число 5
;;в отмеченное “закладкой” место.
(kont 5))))
(newline)

Предыдущая программа просто выводит число 5. Заметьте, что вызов продолжения с одним параметром возвращает это значение в отмеченное закладкой место.
Теперь рассмотрим чуть более сложный пример. В этом случае продолжение
должно использоваться для преждевременного выхода. Это несколько
надуманный пример, но он хорош в качестве иллюстрации. В этой программе
каждое число списка возводится в квадрат, но если в списке есть какие-либо не-числа, то вместо списка просто возвращается символ *error*.

Листинг 7. Преждевременный выход с использованием продолжения при возникновении ошибки

(define a '(1 2 3 4 5))
(define b '(1 b 2 e f))
(define (square-list the-list)
;;Здесь закладка
(call-with-current-continuation
;;early-exit-k вернет нас к закладке
(lambda (early-exit-k)
;;Выполнить процедуру
(map
(lambda (x)
;;Проверить, является ли числом
(if (number? x)
;;да, является, выполнить умножение
(* x x)
;;нет, не является, сделать _сейчас_ выход со значением *error*
(early-exit-k '*error*)))
the-list))))
;;Это возведет элементы списка в квадрат
(display (square-list a))
(newline)
;;Это выдаст ошибку
(display (square-list b))
(newline)

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

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

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

Листинг 8. Восстановление стека при помощи продолжения

;;Глобальная переменная под продолжениеn
(define k-global #f)

;;Мы используем let* вместо let, чтобы обеспечить нужный
;;порядок вычисления
(let* (
(my-number 3)
(k
;;оставить здесь закладкуe
(call-with-current-continuation
;;Продолжение будет записано в kont
(lambda (kont)
;;вернуть продолжение
kont))))

;;Мы используем my-number, чтобы показать, что старый стек
;;сохранён. Когда мы второй раз перейдем к продолжению, значение
;;останется измененным
(display "The value of my-number is: ")
(display my-number)
(newline)
(set! my-number (+ my-number 1))

;;Сохранить продолжение в глобальной переменной.
(set! k-global k))

;;«kontinuation» — это продолжение
(if (procedure? k-global)
(begin
(display "This is the first run, k-global is a continuation")
(newline)
;;Передать 4 продолжению, которое перейдет в закладку, которая присвоит
;;его переменной k
(k-global 4))
(begin
;;Это вызывает вторичное исполнение
(display "This is the second run, k-global is not a continuation, it is ")
(display k-global)
(newline)))

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

Исключения на основе продолжений

Рассмотрим, как выглядят исключения:

Листинг 9. Пример исключения

try {
//Код, который должен сгенерировать исключение
} catch(SQLException e) { //catch a specific exception
//Код обработки ошибок
} catch(Exception e) { //catch a general exception
//Код обработки ошибок
}

//Остальной код

Итак, в первую очередь нужно написать макрос, который задает:

  • блок обработки ошибок
  • позицию остального кода
  • исполняемый код

Так что результат раскрытия макроса должен выглядеть примерно так:

Листинг 10. Желаемый результат раскрытия макроса для предполагаемого исключения

;;Здесь функция throw определяется как доступная глобально, но при ее вызове вне
;;блока try будет выдано сообщение об ошибке
(define throw (lambda (x) (display "No try clause is active!") (newline)))

(let* (
;сохранить старый охватывающий блок try
(old-throw throw)
;мы сохраняем результаты в retval, так как должны «прибраться»
;перед выходом
(retval (call-with-current-continuation
;Исключение будет использовать это продолжение для возврата
;к оставшемуся коду
(lambda (k-exit-to-remaining-code)
(let (
;Это определяет обработчик выхода
(error-handler
(lambda (exception)
(k-exit-to-remaining-code
;;Здесь код обработки ошибок...
))))
;Это устанавливает наш обработчик как официальную
;функцию throw
(set! throw error-handler)
;;Здесь обычный код...
;;Исключение можно выдать при помощи
(throw 'test-exception))))))

;Восстановить прежний блок try
(set! throw old-throw)

;Возвратить результат
retval)

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

Листинг 11. Макрос для генерирования кода исключения

;;Это задает функцию throw
(define throw (lambda (x) (display "No try clause is active!") (newline)))

;;Задает синтаксис блока try
(define-syntax try
(lambda (x)
(syntax-case x (catch)
(
(try expression (catch exception exception-handler-expression))
(syntax
(let* (
(old-throw throw)
(retval
(call-with-current-continuation
(lambda (k-exit)
(let (
(error-handler
(lambda (exception)
(k-exit exception-handler-expression))))
(set! throw error-handler)
expression)))))
(set! throw old-throw)
retval))))))

;;Небольшой набор тестов

;Функция, которая выдает ошибку
(define (test-function)
(throw 'ERROR))

;Функция, которая не выдает ошибку
(define (test-function-2)
(display "No error is generated.")
(newline))

;Проверка нашего блока try
(try (test-function) (catch e (begin (display "Exception! e is: ")
(display e) (newline))))
(try (test-function-2) (catch e (begin (display "Exception! e is: ")
(display e) (newline))))

Хотя основные аспекты мы обсудили, всё еще остается несколько
проблем. Одна из них — возможная путаница с продолжениями. Например,
если продолжения используются не только для исключений, но и для других
разновидностей логики преждевременного выхода, то возможны затруднения.
Рассмотрим следующий код:

Листинг 12. Неудачное взаимодействие продолжений

;;Использует ранее определенный макрос try/catch
(try
(begin
(call-with-current-continuation
(lambda (kont)
(try
(kont 'value) ;;выходит за продолжение, но также пропускает
;;сброс действующего продолжения
(catch e (begin
(display "Interior exception handler. Exception e is: ")
(display e)
(newline))))))
;;Так как мы не выходим через блок try обычным образом, то это
;;при первом вызове отправит нас _назад_ ко внутреннему блоку catch!
(throw 'ERROR))
(catch e (begin
(display "Exterior exception handler. Exception e is: ")
(display e)
(newline))))

Как видим, такие свободные перескоки могут вызвать определенные
сложности с хранением закладок. Для облегчения подобных проблем в
Scheme существует специальная процедура dynamic-wind.
Она замечает, когда продолжение перескакивает через заданный стек, и
это можно использовать для сброса стека, когда продолжение
перескакивает назад и вперед. Процедура dynamic-wind
принимает три аргумента, каждый из которых представляет собой процедуру
без аргументов. Первый — процедура для выполнения при каждом входе в
стек, второй — собственно процедура для запуска, третий — процедура для
выполнения при каждом выходе из стека. Следующий код дает краткий
пример работы dynamic-wind:

Листинг 13. Пример использования dynamic-wind

(let (
(k-var (call-with-current-continuation
(lambda (kont)
(dynamic-wind
(lambda () (display "Entering frame") (newline))
(lambda ()
(begin
(display "Running")
(newline)
(call-with-current-continuation
(lambda (inner-kont)
;;Выйти через границу dynamic-wind,
;;сохраняя текущее продолжение
(kont inner-kont)))))
(lambda () (display "Leaving frame") (newline)))))))
(if (procedure? k-var)
(k-var 'the-value)
(begin
(display k-var)
(newline))))

Сначала создается внешнее продолжение. Затем программа входит в стек,
вызывая процедуру «входа». Далее выполняется процедура, создающая новое
продолжение внутри dynamic-wind. Это продолжение затем возвращается через точку исходного продолжения. Однако, поскольку оно пересекает линию dynamic-wind , то выполняется процедура «выхода». Далее снова выполняется внутреннее продолжение, перемещающее управление через dynamic-wind a, которая снова вызывает процедуру «входа». Далее происходит возврат назад через dynamic-windс очередным вызовом процедуры «выхода».

Это довольно путаная последовательность вызовов, но она имеет смысл, если представить dynamic-wind
как «защитную линию» от далеко идущих продолжений. Для того чтобы
управляющая логика могла пересечь «защитную линию» (при помощи
продолжения, либо обычной управляющей конструкции), должна быть
выполнена соответствующая процедура «уборки» («входа» или «выхода», в
зависимости от направления).

С помощью этого можно защитить себя от некоторых проблем с макросом try . dynamic-wind можно использовать для перезагрузки информации о том, в каком блоке try/catch исполняется код. Вот пример:

Листинг 14. Улучшенный try/catch с dynamic-wind

;;Здесь функция throw определяется как доступная глобально, но при ее вызове внеn
;;блока try будет выдано сообщение об ошибке
(define throw (lambda (x) (display "No try clause is active!") (newline)))

;;Задает синтаксис блока try
(define-syntax try
(lambda (x)
(syntax-case x (catch)
(
(try expression (catch exception exception-handler-expression))
(syntax
;;Точка выхода при помощи продолжения k-exit
(call-with-current-continuation
(lambda (k-exit)
(let (
;;Это два обработчика исключений: старый и новый.
;;dynamic-wind устанавливает их и сносит
;;при входе и выходе из блока
(old-throw throw)
(error-handler
(lambda (exception)
(k-exit exception-handler-expression))))
(dynamic-wind
;;Вход в блок — установить обработчик ошибок
(lambda () (set! throw error-handler))
;;Собственно обработка
(lambda () expression)
;;Выход из блока — установить старое значение обработчика
(lambda () (set! throw old-throw)))))))))))

Эта версия, с одной стороны, короче, а с другой — подходит и к
исходным тестовым примерам и к тем, где используются продолжения.
Также, если вы считаете, что нечестно добавлять новую управляющую
структуру с помощью dynamic-wind, то Кент Дибвиг продемонстрировал, что dynamic-wind можно реализовать средствами call-with-current-continuation.

Мы коснулись не всех моментов, когдаe try/catch
может сгенерировать непредвиденное поведение, но изложенного достаточно
для большинства случаев. В следующем разделе мы вернемся к некоторым
возможным проблемам.

Генераторы на основе продолжений

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

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

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

Теперь рассмотрим интерфейс на Scheme, который может понадобиться для генераторов в стиле Python:

Листинг 15. Пример использования генератора в стиле Python

(define-syntax generator
(syntax-rules ()
(
(generator (yieldfunc) body ...)
(let (
(placeholder #f) ;;позиция в этой функции
(return-proc #f) ;;как выйти
(finished #f)) ;;завершилась ли работа генератора
;;это вход в генератор
(lambda ()
(call-with-current-continuation
(lambda (tmp-return-proc)
;;сохранить выход в return-proc"
(set! return-proc tmp-return-proc)
(let (
;;yieldfunc сбрасывает позицию и возвращает значени
(yieldfunc
(lambda (x)
(call-with-current-continuation
(lambda (current-placeholder)
;;новая позиция
(set! placeholder current-placeholder)
;;вернуть значение
(return-proc x))))))

;;Вернуть специальное значение, если генератор завершил работу
(if finished
'generator-finished

;;Если это первый вызов, то позиция не указана,
;;так что мы просто запускаем тело генератора.
;;Если позиция есть, то мы возвращаемся к той точке.
(if placeholder
(placeholder 'unspecified)
(let (
(final-value (begin body ...)))
(set! finished #t)
(return-proc final-value))))))))))))

(define sequence-generator
;;Исходные параметры
(lambda (start end increment)
;;yield будет использоваться для генерирования одного значения
(generator (yield)
;;Тело основной функци
(let loop ((curval start))
(if (eqv? curval end)
curval
(begin
;;выдать значение
(yield curval)
;;перейти далее
(loop (+ curval increment))))))))

;;Пример использования
(define my-sequence (sequence-generator 1 3 1))
(display (my-sequence))(newline)
(display (my-sequence))(newline)
(display (my-sequence))(newline)
(display (my-sequence))(newline)

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

Как уже упоминалось, с продолжениями, опирающимися на исключения,
могут возникать проблемы. В целом вопрос стоит так: если имеется блок try для запуска генератора и блок try для вызова генератора, а также исключение, которое выдает генерирующая функция, то какой из блоков catch будет выполнен? В тех реализациях, которые использую я, был бы вызван первый блок catch
Самый ли это очевидный исход? Зависит от конкретной ситуации. Однако
эти виды взаимодействия продолжений могут быть проблематичными, потому
что не до конца ясно, какое действие считается «подходящим».

Перебор с возвратами на основе продолжений

Наконец, рассмотрим перебор с возвратами. Интерфейсом к нашей системе поиска с возвратами будет служить функция amb. Она принимает список значений. Для каждого из них amb делает закладку для возврата. Если текущее значение списка не удовлетворяет условиям (что сообщается вызовом функции amb:fail ), то программа возвращается к последней закладке и пробует новое значение. Вспомогательная функция amb:assert вызывает amb:fail , если ее параметр не равен true. Листинг 16 показывает эти функции в действии:

Листинг 16. Использование перебора с возвратами в Scheme

(let* (
(a (amb 1 2 3 4 5 6 7))
(b (amb 5 6 7 8 9 10)))
(amb:assert (> a b))
(display "a is ") (display a) (newline)
(display "b is ") (display b) (newline))

Когда функция запускается первый раз, она выбирает 1 для a и 5 для b. Так как a не больше b,
то функция терпит неудачу и возвращается к последней точке, где
оставлена закладка. В этом случае это будет присваивание значения b. Далее программа попробует все возможные значения b. Ни одно из них не подходит, поэтому далее произойдет возврат к присваиванию значения a . Далее будет перепробовано 2 со всеми значениями b. Так будет продолжаться до тех пор, пока не будет найдено значение a , которое больше b, и с этого места программа возобновит работу.

Реализация показана в листинге 17:

Листинг 17. Реализация перебора с возвратами

;Определение AMB
(define amb:fail '*)

(define amb:initialize-fail
(lambda x
(set! amb:fail
(if (null? x)
(lambda () (error "amb tree exhausted!"))
(car x)))))

(amb:initialize-fail)

(define amb
(lambda alternatives
(letrec (
(amb-internal
;;sk возвращает значение (удачное продолжение),
;;alts — список значений
(lambda (sk alts)
;;неудачный исход, если вариантов нет
(if (null? alts)
(prev-amb-fail)
(begin
(call/cc
;;fk — точка для перехода при неудачномn
;;варианте (неудачное продолжение)
(lambda (fk)
;;задать функцию неудачи для возврата в эту точку
(set! amb:fail
(lambda ()
(set! amb:fail prev-amb-fail)
(fk 'fail)))
;;вернуть первый вариант
(sk (car alts))))
;;Мы попадаем сюда после неудачи, поэтому
;;снова запускаем функцию для оставшейся
;;части списка
(amb-internal sk (cdr alts))))))
;;это предыдущая функция неудачи
(prev-amb-fail amb:fail))
(call/cc
;;сделать закладку на месте присваивания значения sk
(lambda (sk)
;;перебрать все варианты
(amb-internal sk alternatives)
;;После прохода по ним отметить, что у нас неудача
(prev-amb-fail))))))

;;Вызов amb без аргументов приведет к неудаче. Эта функция
;;просто делает более очевидным то, что должно происходить.
(define amb:assert-failure
(lambda () (amb)))

(define amb:assert
(lambda (pred)
(if (not pred) (amb:assert-failure))))

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

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

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

Карта сайта: 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34