Исполняемый код как идентификатор самого себя

О TCL: русскоязычный раздел Написать автору Связь по xmpp (jabber)

Содержание

Содержание
Проблемы и решения
Работа по расписанию
Графический интерфейс: то, что нельзя
Немного о типах данных
Код как данные
Разновидности лисперов
TCL: а что у нас?

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

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

Проблемы и решения

Работа по расписанию

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

Когда я только начинал писать на TCL, у меня получилось бы что-нибудь вроде следующего кода:

# Заданиям присваивается уникальный идентификатор,
# по которому их можно отменить.
variable nextTaskId 0

# регистрирует задание для периодического исполнения
# возвращает: идентификатор задания для отмены
proc run-each-ms {interval script} {
    variable nextTaskId
    variable tasks
    set thisTaskId [incr nextTaskId]
    set tasks($thisTaskId,script) $script
    set tasks($thisTaskId,interval) $interval
    set tasks($thisTaskId,nextAfterId) \
        [ after idle ::DoOneTime $thisTaskId ]
    return $thisTaskId
}

# отменяет периодическое задание по данному идентификатору
proc cancel {taskId} {
    variable tasks
    after cancel $tasks($taskId,nextAfterId)
    array unset tasks $taskId,*
}

proc DoOneTime {taskId} {
    variable tasks
    set tasks($taskId,nextAfterId) \
        [ after $tasks($taskId,interval) [info level 0] ]
    uplevel #0 $tasks($taskId,script)
}

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

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

# регистрирует задание для периодического исполнения
proc run-each-ms {interval script} {
    # автоматически отменить во избежание множественных регистраций
    dont-run-each-ms $interval $script
    after idle [MakeRunner $interval $script]
}
# отменяет задание для периодического исполнения
proc dont-run-each-ms {interval script} {
    after cancel [MakeRunner $interval $script]
}
proc MakeRunner {interval script} {
    return [list ::DoOneTime $interval $script]
}
proc DoOneTime {interval script} {
    after $interval [info level 0]
    uplevel #0 $script
}

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

Рассмотрим пример, в котором используется последнее решение:

foreach {taskname interval command} {
    check-new-mail 60000 check-new-mail
    ping-something 60000 {ping something}
} {
    proc timer-enable-$taskname {} [list run-each-ms $interval $command]
    proc timer-disable-$taskname {} [list dont-run-each-ms $interval $command]
}
#....
timer-enable-check-new-mail
timer-enable-ping-something
#....

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

Даже простейший однострочный вызов показывает преимущества второго решения:

# этот файл можно загружать сколько угодно раз.
# фоновое задание будет всего одно.
run-each-ms 5000 background-job

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

Графический интерфейс: то, что нельзя

Задача: у нас есть программа с графическим интерфейсом; необходимо обновлять значение свойства -state у элементов интерфейса, чтобы показывать, какие действия возможно выполнить в настоящий момент, а какие нет.

Существует классическое объектно-ориентированное решение: определяется тип объекта action; к таким объектам привязывается с одной стороны выполняемая команда, а с другой — элементы графического интерфейса, которые её активируют.

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

Для успешной работы нашего решения должны выполняться следующие условия:


package require Tk

# для виджета с опциями -command и -state:
# привязать состояние к элементу массива ::can
# (ключ = значение -command).
# требует неизменности -command.
proc auto-enabled-buttonlike-widget {path} {
    %bind-enabled-flag [$path cget -command] \
        flagVariable
    %default flagVariable [expr { [ $path cget -state ] ne "disabled" } ]
    set traceHandler [namespace code [list %synchronize-button-state $path] ]
    trace add variable flagVariable write $traceHandler
    bind $path <Destroy> \
        +[namespace code [list @lose-button-widget %W $traceHandler] ]
}

# для меню: проверяет переменные, соответствующие командам меню,
# при его активации.
proc auto-enabled-menu-widget {path} {
    $path configure -postcommand \
        "[namespace code [list %synchronize-menu-state $path]];\n\
         [$path cget -postcommand]"
}


# команда -> имя глобальной переменной для её состояния
proc %enabled-global-var-name {command} {
    return [namespace current]::can($command)
}

# привязывает в вызывающей процедуре flagVar к флагу разрешения команды,
proc %bind-enabled-flag {command flagVarName} {
    set globalFlagVarName [%enabled-global-var-name $command]
    uplevel 1 [list ::upvar #0 $globalFlagVarName $flagVarName]
}

# устанавливает переменную, если она ещё не установлена
proc %default {varName toValue} {
    upvar $varName var
    if {![info exists var]} {set var $toValue}
}

# синхронизирует -state кнопкоподобного виджета с его флагом
proc %synchronize-button-state {path args} {
    %bind-enabled-flag [$path cget -command] flagVariable
    $path configure -state [expr { $flagVariable ? "normal" : "disabled" } ]
}

# удаляет trace, когда виджет удалён
proc @lose-button-widget {path removedTrace} {
    %bind-enabled-flag [$path cget -command] flagVariable
    trace remove variable flagVariable $removedTrace
}

# переставляет state для элементов меню,
# для которых существуют флаговые переменные
proc %synchronize-menu-state {path} {
    %for-command-menu-entries i command $path {
        %bind-enabled-flag [$path entrycget $i -command] flagVariable
        %default flagVariable on
        if {[info exists flagVariable]} {
            $path entryconfigure $i -state \
                [expr { $flagVariable ? "normal" : "disabled" } ]
        }
    }
}

# цикл по пунктам меню, обладающим опцией -command
proc %for-command-menu-entries {entryIndexVarName commandVarName path body} {
    set lastEntryIndex [$path index end]
    upvar $entryIndexVarName i $commandVarName command
    for {set i 0} {$i <= $lastEntryIndex} {incr i} {
        if {[$path type $i] ni {cascade separator tearoff}} {
            set command [$path entrycget $i -command]
            uplevel 1 $body
        }
    }
}

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

proc i-can {command} { %set-flag $command on }
proc i-cannot {command} { %set-flag $command off }
proc %set-flag {command onOff} {
    %bind-enabled-flag $command flagVariable
    set flagVariable $onOff
}

Можно сравнить наше решение с одной реализацией классических actions. С одной стороны, мы решаем другую задачу: ActionPackage (см. по ссылке выше) динамически обновляет текст и некоторые другие свойства виджетов, не ограничиваясь -state. Однако наше решение лишено и некоторых недостатков ActionPackage. Например, поддержка локализации графического интерфейса, допускающая переключение языка «на лету», помешает работе ActionPackage, но не скажется на нашем решении.

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

::action::define editPaste -command cm_editPaste ....
# и так пятнадцать раз

Осмелюсь утверждать, что каждый, кому приходится писать такой код, предпочёл бы этого не делать. Но если даже здесь я ошибаюсь, нужно учесть следующее: интерфейс, основанный на самоидентифицирующих командах, легко обернуть в интерфейс, основанный на внешних идентификаторах; обратное же верно далеко не всегда. Если кому-нибудь потребуется возможность явного определения actions, добавить это к нашему решению будет очень легко. Обёртка будет подсовывать нашим утилитам что-нибудь вроде [list runScriptById $id] в качестве команды; процедура же runScriptById будет выглядеть примерно так:

proc runScriptById {id args} {
    variable definedScripts
    uplevel #0 $definedScripts($id) $args
}

Кстати, использование uplevel в runScriptById иллюстрирует, как корректно вызвать на исполнение скрипт-префикс для глобального пространства имён. Именно этот тип принимают встроенные команды, в описании которых есть слова «is invoked with ... additional arguments».

Немного о типах данных

Мы слышали не раз, что в TCL всё есть строка. Уточним эту формулировку: тип любого значения первого класса является подтипом типа «строка». При этом значения типа «строка» в TCL не имеют внутренней идентичности: две строки, совпадающие в смысле [string equal], неотличимы друг от друга. Обычно в языках, где у строк есть идентичность, это связано с тем, что строка может измениться «на месте». Два равных строковых значения "Hello" и "Hello" могут быть идентичны друг другу (если деструктивно заменить первый символ в одной из них, он изменится в другой), либо неидентичны.

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

Кстати, популярное и полезное расширение tcom разрушает это свойство, имея на то веские причины; именно поэтому, работая с tcom, лучше изолировать взаимодействующий с ним код и возвращаемые им значения от остальной программы. Фактически, используя tcom, вы пишете уже на другом языке, не на TCL.

Код как данные

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

Эти типы не совпадают между собой; последние два типа получаются из первых двух пересечением множества допустимых значений с множеством строковых представлений чистых списков. Кроме того, параметром каждого из этих типов является пространство имён и уровень стека интерпретатора (info level); преобразование между любыми из них достаточно просто (особенно после появления команды apply в TCL 8.5), но для каждого программного интерфейса важно осознавать, значение какого именно типа он ожидает получить.

Впрочем, при использовании чужих интерфейсов всегда можно преобразовывать передаваемое значение в безразличный к пространству имён списочный префикс или список (см. namespace code). Если чей-то интерфейс не работает с такой формой исполняемого кода, он нуждается в серьёзной доработке (если меня не подводит память, когда-то давно пакет tclodbc не принимал для обратного вызова ничего, кроме имён процедур; сейчас он нормально работает с командными префиксами).

Мы не рассмотрели ещё один исполняемый тип: скрипт с подстановками в стиле команды Tk bind. С ним важно уметь работать, если уж приходится это делать.

# Вы уверены, что понимаете, что происходит во всех трёх случаях и
# почему? А если нажать «пробел», «обратный слэш», «фигурные скобки»?.
bind . <KeyPress> { puts {%A} }
bind . <KeyPress> { puts "%A" }
bind . <KeyPress> { puts %A }

Разновидности лисперов

Раз уж мы заговорили о гомоиконности языка, вспомнить об этих существах придётся. Программистов, работающих с языками семейства Lisp, можно разделить на две группы. Одни любят функции высших порядков, другие любят макросы и работу по дереву (имею в виду AST). При необходимости лиспер будет пользоваться и тем и другим; но по умолчанию мозг каждого из них начинает работать в каком-то одном направлении. Потребуй у двух разных лисперов нечто, возвращающее функцию — один напишет кучу макросов и однострочную lambda-форму, другой создаст пучок функций и их скомбинирует.

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

Проблема с одним из этих подходов — а именно с функциями высших порядков — в том, что сравнивать функциональные значения между собой либо невозможно, либо не слишком полезно. Сравниваемые значения могут быть идентичны (EQ), но если это не так, мы уже не добьёмся ничего полезного от других способов сравнения (EQL, EQUAL и так далее).

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

Ничего принципиально невозможного в этом нет: достаточно проверить, что make-adder не модифицирует замыкаемые переменные. Итак, для эквивалентности достаточно того, что два функциональных значения ссылаются на один и тот же код, не модифицируют замкнутые переменные, и значения этих переменных равны (в каком смысле «не модифицируют» и в каком смысле «равны», можно понимать по разному). Боюсь, что такую проверку не реализуют лишь потому, что мышление функциональщика отвергает идею нестрогого сравнения, результатом которого является либо «точно равны», либо «хрен его знает».

Если вместо того, чтобы комбинировать функции, мы будем заниматься генерацией кода, результаты вполне можно будет сравнивать без учёта идентичности. В emacs lisp, не поддерживающем лексические замыкания без библиотеки cl-macs или подобных, мне иногда приходится это свойство использовать. И не только мне, и не только иногда.

(add-to-list 'some-awful-event-hook
             (lambda ()
               (do-something)
               (do-something-else)))

Интересно тут то, что отсутствие лексических замыканий в ядре языка логично приводит к тому, что lambda-макро просто возвращает lambda-форму, а их можно сравнивать с помощью equal, как и символы, которыми в том же emacs lisp представляются именованные функции.

Если вы, о читатель, когда-нибудь соберётесь написать аналог emacs на Common Lisp (многие развлекаются подобным образом), имейте в виду: эти свойства emacs lisp — не уродства убогого недоязычка, а очень даже полезные штуки, которые во многих случаях делают жизнь проще.

TCL: а что у нас?

Настоящих замыканий (с изменяемыми переменными внутри) TCL не поддерживает, и изобразить их в рамках его системы типов невозможно: в лучшем случае мы применяем reification и получаем объект, который надо явно освобождать. Зато нам остаются «псевдо-замыкания» без изменяемых переменных, и тут мы с удивлением обнаруживаем, что от этого произошёл некий профит, которого в каком-нибудь «взрослом» лиспе добиться трудно: любые такие «псевдо-замыкания» можно сравнить между собой (а также передать по сети, сохранить в файле, в общем, один раз — уже EIAS).

set server1 {{channel host port} {
    puts "And each separate dying ember wrote its $host upon the floor"
    puts $channel "I'm a little busy here. Please call again later"
    close $channel
}}
socket -server [list apply $server1] 1234
lappend allServers $server1
#....
proc addServer {newServer} {
    if {$newServer in $::allServers} {
        # ... whatever
    }
}

А. Коваленко, Птн Окт 2 12:21:54 MSD 2009