четверг, 3 ноября 2011 г.

Разработка языка программирования на Racket

Racket — современная платформа для разработки языков программирования, одна из самых прогрессивных, наследница Lisp.

Пара слов о Лиспе: будучи созданным 50 лет назад, он живёт и развивается до сих пор, причём последние 20 лет практически без финансирования. Что означает, что язык действительно хорош. Надо отметить, что Джон Маккарти (автор термина «искусственный интеллект», AI) изобрёл Лисп как подручное средство для создания этого самого AI. Маккарти умер, AI как не было так и нет, но идеи, заложенные в Лисп, в течение полувека находят применение в совершенно не связанных с AI областях. Что означает, что идеи действительно хороши.

Цель авторов Racket можно сформулировать так: дать разработчикам возможность создавать свои языки, не лишая их при этом прелестей Лиспа. «Лисп как основа и любой ваш каприз вдобавок» — звучит как предложение, от которого невозможно отказаться. В отличие от Common Lisp, где новые языки как правило создаются на базе грамматики S-выражений, в Racket для них допустима любая грамматика. Плюс к тому, отладчик и редактор с подсветкой синтаксиса — практически даром. Решительно невозможно отказаться.

По какой-то причине в сети нет подробного руководства по созданию своего языка в Racket «от и до». Впрочем, уже есть, раз вы его читаете. Оно было собрано по кускам из одного короткого примера, документации Racket и кода встроенных в Racket языков (Algol, Datalog). Для руководства такие изыски ни к чему, поэтому мы сейчас сделаем в Racket калькулятор. Типа такого:
X = 2.8
Y = (X + 1) * 5
print X / Y
с блекдж... пардон, с пошаговым выполнением, бектрейсами и REPL.

Вещи, относящиеся к языкам, Racket желает видеть в одной из своих «системных» директорий, поэтому директорию, в которой мы ведём разработку (назовём её work), лучше с самого начала внести в соответствующий список. В среде DrRacket это делается следующим образом: меню Language → Choose Language → кнопка «Show Details» → раздел «Collection Paths» → Add. В директории work у нас будет директория calc, где будут файлы, относящиеся к новому языку.

Этап 1: базовый комплект

Начнём с лексического анализатора, который будет в файле calc/lexer.rkt:
#lang racket

(require
  ; lex -- стандартное средство для построения лексических анализаторов
  parser-tools/lex 
  ; Библиотека регулярных выражений для lex. Символы, импортированные из неё,
  ; будут предваряться знаком двоеточия, в целях избежания конфликтов имён
  (prefix-in : parser-tools/lex-sre))

; Указанные вещи из этого модуля идут на экспорт
(provide value-tokens op-tokens
         position-line position-col position-offset
         calc-lexer)

; Регулярные выражения для букв, цифр и всяких пробелов
(define-lex-abbrevs
  (lex:letter (:or (:/ #\a #\z) (:/ #\A #\Z)))
  (lex:digit (:/ #\0 #\9))
  (lex:whitespace (:or #\newline #\return #\tab #\space #\vtab)))

; Токены, имеющие значения -- идентификатор (например X) и число (например 2.8)
(define-tokens value-tokens (IDENTIFIER NUMBER))

; Токены, не имеющие значений -- арифметические операции, скобки, присваивание,
; операция печати (PRINT) и специальный токен EOF
(define-empty-tokens op-tokens
  (EOF ASSIGN PLUS MINUS MULTIPLY DIVIDE LEFT-PAREN RIGHT-PAREN PRINT))

; calc-lexer -- это функция, полученная в результате вызова lexer-src-pos
(define calc-lexer
  ; lexer-src-pos отличается от lexer тем, что выдаёт не просто токены,
  ; а position-token -- токены с информацией об их положении в исходном файле
  (lexer-src-pos
   ; пробелы и прочее пропускаем, рекурсивно запуская lexer дальше
   ((:+ lex:whitespace) (return-without-pos (calc-lexer input-port)))
   ; токены операций
   ("=" (token-ASSIGN))
   ("+" (token-PLUS))
   ("-" (token-MINUS))
   ("*" (token-MULTIPLY))
   ("/" (token-DIVIDE))
   ("(" (token-LEFT-PAREN))
   (")" (token-RIGHT-PAREN))
   ("print" (token-PRINT))
   ; идентификатор = буква + [произвольный набор букв и цифр]
   ((:: lex:letter (:* (:or lex:letter lex:digit)))
    (token-IDENTIFIER (string->symbol lexeme)))
   ; число = [знак] + не менее одной цифры + [точка [с цифрами]]
   ((:: (:? #\-) (:+ lex:digit) (:? (:: #\. (:* lex:digit))))
    (token-NUMBER (string->number lexeme)))
   ; специальный токен EOF необходимо вернуть при достижении конца файла
   ((eof) 'EOF)))
Теперь парсер, calc/parser.rkt:
#lang racket

(require
  ; модуль с генератором парсеров
  parser-tools/yacc
  ; модуль, содержащий нужную нам функцию raise-read-error
  syntax/readerr
  ; наш лексический анализатор
  calc/lexer)

(provide calc-read-syntax
         calc-read)

; Функция, которую мы будем вызывать при ошибке парсинга
(define (on-error source-name)
  (lambda (tok-ok? tok-name tok-value start-pos end-pos)
    ; генерирует исключение
    (raise-read-error 
             "Parser error" ; текст исключения
             source-name                  ; имя файла
             (position-line start-pos)    ; номер строки с ошибкой
             (position-col start-pos)     ; номер колонки с ошибкой
             (position-offset start-pos)  ; смещение от начала файла
             (- (position-offset end-pos) ; длина фрагмента с ошибкой
                (position-offset start-pos)))))

; calc-parser -- это функция, возвращающая функцию, полученную
; в результате вызова функции parser из модуля parser-tools/yacc
(define (calc-parser source-name)
  (parser
   (src-po  s)   ; это нужно для того, чтобы в функцию обработки ошибки
                 ; передавалась позиция проблемного куска
   (start start) ; стартовый нетерминал у нас называется start
   (end EOF)     ; конец файла у нас называется EOF
   (tokens value-tokens op-tokens) ; две группы токенов, определённые в calc-lexer
   (error (on-error source-name))  ; функция обработки ошибок (см. выше)
   
   ; Грамматика

   (grammar
    ; программа = команды
    (start
     ((statements) $1))
    
    (statements
     ; команды = ничто
     (() '())
     ; или команда + команды
     ; $1 и $2 обозначают соответственно первый и второй элементы
     ; сопоставления, то есть statement и statements
     ((statement statements) (list* $1 $2)))
    
    (statement
     ((assignment) $1)
     ((printing) $1))
    
    (constant
     ((NUMBER) $1))
    
    (expression
     ; выражение = терм
     ((term) $1)
     ; или выражение плюс/минус токен.
     ; PLUS и MINUS здесь -- терминалы, взятые из op-tokens из calc/lexer.
     ((expression PLUS term) (list 'plus $1 $3))
     ((expression MINUS term) (list 'minus $1 $3)))
    
    (term
     ((factor) $1)
     ((term MULTIPLY factor) (list 'multiply $1 $3))
     ((term DIVIDE factor) (list 'divide $1 $3)))
    
    (factor
     ((primary-expression) $1)
     ((MINUS primary-expression) (list 'negate $2))
     ((PLUS primary-expression) $2))
    
    (primary-expression
     ((constant) $1)
     ((IDENTIFIER) (list 'value-of $1))
     ((LEFT-PAREN expression RIGHT-PAREN) $2))
    
    (assignment
     ((IDENTIFIER ASSIGN expression) (list 'assign $1 $3)))
    
    (printing
     ((PRINT expression) (list 'print $2))))))
Теперь свяжем лексический и синтаксический анализаторы в одно целое с помощью функции, которая принимает некоторый входной поток (в Racket это называется «порт») и соответствующее ему имя файла, и возвращает результат граматического разбора. Правим дальше calc/parser.rkt:
(define (parse-calc-port port file)
  ; посчитать номера строк заранее
  (port-count-lines! port)
  ; создать и сразу вызвать парсер
  ((calc-parser file)
   ; и передать в него функцию, которая должна выдавать токены один за другим
   (lambda ()
     ; calc-lexer как раз это и делает 
     (calc-lexer port))))
Поскольку наш анализатор должен будет работать в среде Racket на тех же правах, на которых работает «родной» анализатор, мы должны реализовать функции calc-read и calc-read-syntax, которые придут на замену стандартным read и read-syntax. Окончание calc/parser.rkt:
(define (calc-read in)
  (syntax->datum
   (calc-read-syntax #f in)))

(define (calc-read-syntax source-name input-port)
  (parse-calc-port input-port source-name))
Пояснение: в то время как calc-read возвращает прочитанные данные (datum) вида ((assign X (+ 1 2)), calc-read-syntax возвращает синтаксические объекты, что есть по сути те же самые данные, но с привязкой к участкам исходного файла и прочей информацией. В зависимости от режима работы Racket пользуется либо read, либо read-syntax, и во избежание неожиданностей лучше сделать их одинаковыми. Что и сделано: calc-read просто берёт синтаксические объекты из calc-read-syntax и превращает их в данные с помощью syntax->datum.

Настало время встроить наш анализатор в среду Racket. Это очень просто: нужно создать файл calc/lang/reader.rkt следующего содержания:
(module reader syntax/module-reader
  #:language 'racket              ; об этом позже
  #:read calc-read                ; переопределение стандартного read
  #:read-syntax calc-read-syntax  ; переопределение стандартного read-syntax
  #:whole-body-readers? #t ; этот флаг устанавливается в том случае, когда наши
                           ; read и read-syntax всегда читают входной поток до
                           ; конца (calc-read и calc-read-syntax так и делают)
  (require calc/parser))   ; модуль, в котором лежат calc-read и calc-read-syntax

Racket, увидев #lang calc в начале файла, будет искать calc/lang/reader.rkt в системных директориях, включая work, где мы всё и делаем. Поэтому мы уже можем начинать писать код на calc в среде Racket. Создадим файл test.calc:
#lang calc
X = 3
Y = X + 1
Print X * X + Y * Y

pi = 3.1415926535
r = 12.2
l = 40
Print pi * r * (2 + l)
Запускать файл на выполнение ещё рано, т.к. реализации языка пока ещё нет. Но уже можно посмотреть на результат синтаксического анализа кода, для чего в Racket есть удобный инструмент под названием Macro stepper. Нажимаем...
и видим сообщение «Parser error» из нашего calc/parser.rkt. Ошибка состоит в том, что слово Print написано с большой буквы, тогда как лексический анализатор допускает только «print» без вариантов. Сделаем его нечувствительным к регистру (calc/lexer.rkt):
; вспомогательная функция, преобразующая строку в выражение, нечувствительное
; к регистру, например "foo" -> (:: (:or #\f #\F) (:or #\o #\O) (:or #\o #\O))
(define-for-syntax (string->ci-pattern s)
  (cons ':: (map (lambda (c)
                   (list ':or (char-downcase c) (char-upcase c)))
                 (string->list s))))

; специализированный макрос для применения в лексическом анализаторе
(define-lex-trans lex-ci
  (lambda (stx)
    (syntax-case stx ()
      ((_ id) ; здесь id -- это строка, которую мы преобразуем, т.е. "print"
       (with-syntax ((result (string->ci-pattern
                              (syntax->datum #'id))))
         #'result)))))

(define calc-lexer
  (lexer-src-pos
   ((:+ lex:whitespace) (return-without-pos (calc-lexer input-port)))
   ("=" (token-ASSIGN))
   ("+" (token-PLUS))
   ("-" (token-MINUS))
   ("*" (token-MULTIPLY))
   ("/" (token-DIVIDE))
   ("(" (token-LEFT-PAREN))
   (")" (token-RIGHT-PAREN))
   ((lex-ci "print") (token-PRINT))
   ((:: lex:letter (:* (:or lex:letter lex:digit)))
    (token-IDENTIFIER (string->symbol lexeme)))
   ((:: (:? #\-) (:+ lex:digit) (:? (:: #\. (:* lex:digit))))
    (token-NUMBER (string->number lexeme)))
   ((eof) 'EOF)))
Запускаем Macro stepper вторично на test.calc и видим
Парсер успешно отработал и выдал в качестве результата набор конструкций вида
(assign X 3)
(assign Y (plus (value-of X) 1))
Что не является валидным кодом на Racket, что подтверждается сообщением «unbound identifier in module». Но по замыслу — полученный код и не должет являться кодом на Racket! Для исполнения этого кода мы должны создать набор макросов, преобразующих его в код на Racket, а точнее — в синтаксические объекты. Декларация #:language в calc/lang/reader.rkt — именно об этом. Мы написали #:language 'racket исключительно потому, что совсем ничего не писать система не позволяет. На самом деле, в этом месте нужно указать путь к модулю с макросами:
(module reader syntax/module-reader
  #:language 'calc/language
  #:read calc-read
  #:read-syntax calc-read-syntax
  #:whole-body-readers? #t
  #:language-info '#(calc/lang/lang-info get-info #f)
  (require calc/parser))
каковой модуль мы сейчас и создадим (calc/language.rkt):
#lang racket

(provide
 ; Системные вещи, на которых мы не будем заострять внимание.
 ; Достаточно знать, что #%module-begin и #%datum из racket
 ; годятся и для calc, поэтому мы переэкспортируем их без изменений
 #%module-begin #%datum
 ; Макросы для выполнения операций языка calc
 assign plus minus divide multiply negate value-of print)

; Окружение = хеш-таблица со значениями переменных
(define current-env (make-hash))

; Присваивание переменной = запись в хеш-таблицу
(define-syntax-rule (assign name value)
  ; Обратите внимание на апостроф перед name: он делает из имени переменной,
  ; переданной в макрос, символ. Таким образом, из (assign X 3) получается
  ; (hash-set! current-env 'X 3), в то время как
  ; (hash-set! current-env X 3) выдало бы ошибку
  (hash-set! current-env 'name value))

; Арифметические операции

(define-syntax-rule (plus a b)
  (+ a b))

(define-syntax-rule (minus a b)
  (- a b))

(define-syntax-rule (divide a b)
  (/ a b))

(define-syntax-rule (multiply a b)
  (* a b))

(define-syntax-rule (negate a)
  (- a))

; Получение значения переменной по имени
(define-syntax-rule (value-of name)
  ; То же самое с апострофом, см. макрос assign
  (hash-ref current-env 'name))

; Печать
(define-syntax-rule (print value)
  (printf "~v\n" value))
Теперь можно наконец выполнить (Run) наш test.calc:
Запустив Macro stepper снова и выключив в нём опцию «macro hiding», мы можем наблюдать, во что в конечном итоге раскрылся наш код:

На этом этапе можно считать, что у нас есть парсер и компилятор. Всё это занимает 110 строк, не считая пустых строк и комментариев.

Этап 2: необходимые примочки

Если мы сейчас попробуем запустить отладчик (Debug) и поставить брекпоинт в код программы, нас ждёт неудача. Объясняется это просто: парсер в calc/parser.rkt возвращает не синтаксические объекты, а простые списки, т.е. datum-ы. Они преобразуются в синтаксические объекты позже, автоматически, но дела это в принципе не меняет, т.к. информации о местоположении синтаксических единиц в исходном коде не появляется. Эта информация известна только парсеру. Он её «забывает» везде, кроме функции on-error. И поставить брекпоинт невозможно, т.к. система просто не знает, какой синтаксический объект к какому месту исходного кода относится. Сейчас мы это исправим (calc/parser.rkt):
(define-syntax (build-so stx)
  (syntax-case stx ()
    ((_ value start end)
     ; вытаскиваем из контекста (stx) $i-start-pos и $j-end-pos, где i и j --
     ; числа, переданные в макрос как start и end; а также source-name
     (with-syntax ((start-pos (datum->syntax
                               stx
                               (string->symbol 
                                (format "$~a-start-pos"
                                        (syntax->datum #'start)))))
                   (end-pos (datum->syntax
                             stx
                             (string->symbol 
                              (format "$~a-end-pos"
                                      (syntax->datum #'end)))))
                   (source (datum->syntax
                            stx
                            'source-name)))
       (syntax
        (datum->syntax
         #f
         value
         ; конструируем уже знакомую по on-error пятёрку значений
         (list source 
               (position-line start-pos)
               (position-col start-pos)
               (position-offset start-pos)
               (- (position-offset end-pos)
                  (position-offset start-pos)))))))))

(define (calc-parser source-name)
  (parser
   (src-pos)
   (start start)
   (end EOF)
   (tokens value-tokens op-tokens)
   (error (on-error source-name))
   
   (grammar
    (start
     ; Числа 1 и 1, переданные в макрос build-so, раскрываются соответственно в
     ; $1-start-pos и $1-end-pos, каковые переменные доступны благодаря указанию
     ; (src-pos) выше и содержат начальную и конечную позиции первого нетерминала
     ; в списке (в данном случае он всего один и есть). 
     ((statements) (build-so $1 1 1)))
    
    (statements
     (() '())
     ((statement statements) (list* $1 $2)))
    
    (statement
     ((assignment) $1)
     ((printing) $1))
    
    (constant
     ((NUMBER) $1))
    
    (expression
     ((term) $1)
     ; Числа 1 и 3 раскрываются макросом build-so в $1-start-pos и $3-end-pos
     ; соответственно, т.е. от начала первого нетерминала до конца третьего.
     ((expression PLUS term) (build-so (list 'plus  $1 $3) 1 3))
     ((expression MINUS term) (build-so (list 'minus $1 $3) 1 3)))
    
    (term
     ((factor) $1)
     ((term MULTIPLY factor) (build-so (list 'multiply $1 $3) 1 3))
     ((term DIVIDE factor) (build-so (list 'divide $1 $3) 1 3)))
    
    (factor
     ((primary-expression) $1)
     ((MINUS primary-expression) (build-so (list 'negate $2) 1 2))
     ((PLUS primary-expression) $2))
    
    (primary-expression
     ((constant) $1)
     ((IDENTIFIER) (build-so (list 'value-of $1) 1 1))
     ((LEFT-PAREN expression RIGHT-PAREN) (build-so $2 1 3)))
    
    (assignment
     ((IDENTIFIER ASSIGN expression) (build-so (list 'assign $1 $3) 1 3)))
    
    (printing
     ((PRINT expression) (build-so (list 'print $2) 1 2))))))
И о чудо, теперь работает отладчик, можно ставить брекпоинты, ходить по шагам, даже стек вызовов имеется:

Racket позволяет отладчику заходить в реализацию вашего языка в лисповых функциях, если в момент отладки модули с этими функциями открыты. Но нашего calc/language.rkt это не касается, поскольку ни одной функции у нас нет — мы обошлись макросами. То есть ближайший уровень, в который может попасть отладчик после кода на calc — это реализация hash-set, + и прочих примитивных вызовов. На такой уровень нам нет необходимости опускаться.

Обратим теперь внимание на то, что для языка calc не работает REPL! Об этом честно собщается после выполнения программы (Run) чёрным текстом на жёлтом фоне. REPL не работает потому, что в реализации (calc/language.rkt) не определён макрос #%top-interaction, который собственно и должен раскрывать полученные в REPL синтаксические объекты. Стандартный макрос из Racket не годится, так что мы напишем свой, очень простой:
#lang racket

(provide
 #%module-begin #%datum
 ; Переопределять #%top-interaction внутри модуля нельзя, поэтому мы
 ; сделаем макрос top-interaction и переименуем его при экспорте.
 (rename-out (top-interaction #%top-interaction))
 assign plus minus divide multiply negate value-of print)

; Макрос действительно такой -- с троеточиями. Это часть синтаксиса.
(define-syntax-rule (top-interaction body ...)
    (begin body ...))
Но этого недостаточно. Проблема в том, что декларации #:read и #:read-syntax в calc/reader.rkt по какой-то причине не распространяются на REPL. REPL в DrRacket принадлежит самой DrRacket и подлежит настройке отдельно, по строго определённым правилам. Нужно добавить декларацию #:language-info в calc/reader.rkt:
(module reader syntax/module-reader
  #:language 'calc/language
  #:read calc-read
  #:read-syntax calc-read-syntax
  #:whole-body-readers? #t
  #:language-info '#(calc/lang/lang-info get-info #f)
  (require calc/parser))
Затем нужно создать модуль calc/lang/lang-info.rkt такого содержания:
#lang racket/base

(provide get-info)

(define (get-info data)
  (lambda (key default)
    (case key
      ((configure-runtime)
       '(#(calc/lang/configure-runtime configure #f)))
      (else
       default))))
Наконец, нужно создать модуль calc/lang/configure-runtime.rkt, содержащий функцию конфигурации рантайма:
#lang racket/base

(require calc/parser)
(provide configure)

(define (configure data)
  ; Конфигурация заключается в установке параметра current-read-interaction
  ; (Параметры в Racket -- что-то вроде динамических переменных в Common Lisp.)
  ; Мы меняем этот параметр на нашу функцию, которая вызывает наш парсер.
  (current-read-interaction even-read))

(define (even-read source-name input-port)
  (begin0
    (parse-calc-port input-port source-name)
    ; Почему-то нужно делать так, чтобы последующий вызов
    ; current-read-interaction вернул EOF. С этой целью производится
    ; нижеследующий трюк с заменой параметра на odd-read. 
    (current-read-interaction odd-read)))

; Вторая часть трюка заключается в замене параметра обратно на even-read.
; Среди разработчиков нет согласия по поводу того, правильно ли так делать;
; оставим этот вопрос на их совести, в любом случае все имеющиеся примеры
; работают именно так.
(define (odd-read src ip)
  (current-read-interaction even-read)
  eof)
И вот, у нас работает REPL:

Благодаря умной работе механизма модулей, два и более открытых одновременно окна с программами на calc не конфликтуют и не засоряют окружение друг друга:

Самое время заметить, что ошибки с необъявленными переменными возникают на этапе выполнения программы, тогда как во всех нормальных средах они должны отлавливаться на этапе компиляции. Полноценный компилятор мы писать не будем, ограничимся проверкой того, что все используемые переменные встретились перед использованием в левой части. Файл calc/compiler.rkt:
#lang racket

(provide compile-program
         compile-statement)

; Хеш-таблица имён переменных
; (не путать с current-env в language.rkt, это разные этапы)
(define variables (make-hash))

(define (compile-program p)
  ; проверить все подвыражения (syntax-e раскрывает синтаксический
  ; объект-список в список синтаксических объектов)
  (for-each check-syntax (syntax-e p))
  ; вернуть исходную синтаксическую конструкцию как результат
  ; (приличный компилятор вернул бы новую, оптимизированную конструкцию)
  p)

(define (compile-statement s)
  (check-syntax s)
  s)

(define (check-syntax s)
  ; Что за объект? Преобразуем в данные и посмотрим.
  (match (syntax->datum s)
    ; Команда присваивания
    ((list 'assign a b)
     ; Сначала проверим правую часть, чтобы поймать ошибки вида X = X
     (check-syntax (third (syntax-e s)))
     ; Теперь занесём левую часть в словарь
     (hash-set! variables a a))
    ; Доступ к переменной
    ((list 'value-of a)
     ; Есть ли она в словаре?
     (unless (hash-has-key? variables a)
       ; Если нет -- ошибка со ссылкой на соответствующий синтаксический объект
       (raise-syntax-error
        #f "access to unassigned variable" (second (syntax-e s)))))
    ; Все прочие команды
    ((list x ...)
     ; проверяются рекурсивно
     (for-each check-syntax (syntax-e s)))
    ; Константы не подлежат проверке
    (_ #f)))
Попросим парсер вызывать "компилятор" перед возвращением результата (calc/parser.lisp):
(require parser-tools/yacc
         syntax/readerr
         calc/lexer
         calc/compiler)

(define (calc-read-syntax source-name input-port)
  (compile-program
   (parse-calc-port input-port source-name)))
Насладимся результатом:

Попросим REPL делать то же самое (calc/lang/configure-runtime.rkt):
(require calc/parser
         calc/compiler)

(define (even-read source-name input-port)
  (begin0
    (compile-statement (parse-calc-port input-port source-name))
    (current-read-interaction odd-read)))
Насладимся результатом и здесь:

Отметим, что ошибки времени выполнения всё же случаются, и снабжены бектрейсом:

На данный момент у нас есть всё необходимое, а написали мы всего 208 строк кода.

Этап 3: элементы роскоши

Такая беда: REPL не позволяет переводить строку. Нажатие Enter ведёт к немедленному отправлению строки на синтаксический анализ. Мы собирались дописать команду на следующей строке, но REPL безжалостен.
Хорошая новость: поведение REPL можно настроить. Открываем снова calc/lang/lang-info.rkt:
(define (get-info data)
  (lambda (key default)
    (case key
      ((configure-runtime)
       '(#(calc/lang/configure-runtime configure #f)))
      ((drracket:submit-predicate)
       (dynamic-require 'calc/tool/submit 'repl-submit?))
      (else
       default))))
и создаём модуль calc/tool/submit.rkt с функцией repl-submit?, которая возвращает #t, если перевод строки завершающий, и #f, если промежуточный. Определить это не так просто, учитывая, что пользователь может вводить в REPL всё, что ему заблагорассудится. Будем считать, что строка готова к синтаксическому анализу, если она не пустая, не завершается оператором (+, -, /, *, =, print) и если скобки в ней сбалансированы. Весьма уместно использовать уже готовый лексический анализатор для выполнения этой задачи:
#lang racket

(require calc/lexer
         parser-tools/lex)

(provide repl-submit?)

(define (repl-submit? ip has-white-space?)
  (let loop ((blank? #t)      ; строка пустая?     
             (pending-op? #f) ; строка завершается оператором?
             (paren-count 0)) ; баланс скобок
    ; Все ошибки лексического анализатора мы обязаны пропускать
    (with-handlers ((exn:fail:read?
                     (lambda (e)
                       #t)))
      (let ((token (position-token-token (calc-lexer ip))))
        (case token
          ((EOF)
           (and (zero? paren-count)
                (not blank?)
                (not pending-op?)))
          ((PLUS MINUS MULTIPLY DIVIDE ASSIGN PRINT)
           (loop #f #t paren-count))
          ((LEFT-PAREN)
           (loop #f #f (+ paren-count 1)))
          ((RIGHT-PAREN)
           (loop #f #f (- paren-count 1)))
          (else
           (loop #f #f paren-count)))))))
Вроде работает:

Почти всё. Осталось только добавить подсветку синтаксиса. Ну и, например, комментарии, чтобы было что подсвечивать. Пусть комментарии будут в стиле bash — от символа «#» до конца строки. Обновляем calc/lexer.rkt, попутно экспортируя из него некоторые вещи, которые скоро понадобятся:
(provide value-tokens op-tokens
         position-line position-col position-offset
         calc-lexer
         lex:comment lex:identifier lex:number lex-ci)

(define-lex-abbrevs
  (lex:letter (:or (:/ #\a #\z) (:/ #\A #\Z)))
  (lex:digit (:/ #\0 #\9))
  (lex:comment (:: "#" (:* (:: (char-complement #\newline))) (:? #\newline)))
  (lex:whitespace (:or #\newline #\return #\tab #\space #\vtab))
  (lex:identifier (:: lex:letter (:* (:or lex:letter lex:digit))))
  (lex:number (:: (:? #\-) (:+ lex:digit) (:? (:: #\. (:* lex:digit))))))

(define calc-lexer
  (lexer-src-pos
   ((:+ lex:whitespace) (return-without-pos (calc-lexer input-port)))
   ; комментарии пропускаем так же, как и пробелы
   ((:+ lex:comment) (return-without-pos (calc-lexer input-port)))
   ("=" (token-ASSIGN))
   ("+" (token-PLUS))
   ("-" (token-MINUS))
   ("*" (token-MULTIPLY))
   ("/" (token-DIVIDE))
   ("(" (token-LEFT-PAREN))
   (")" (token-RIGHT-PAREN))
   ((lex-ci "print") (token-PRINT))
   (lex:identifier (token-IDENTIFIER (string->symbol lexeme)))
   (lex:number (token-NUMBER (string->number lexeme)))
   ((eof) 'EOF)))

Информация о подсветке синтаксиса задаётся определённым образом в директиве #:info файла calc/lang/reader.rkt:
(module reader syntax/module-reader
  #:language 'calc/language
  #:read calc-read
  #:read-syntax calc-read-syntax
  #:whole-body-readers? #t
  #:language-info '#(calc/lang/lang-info get-info #f)
  #:info (lambda (key defval default)
           (case key
             ((color-lexer)
              (dynamic-require 'calc/tool/syntax-color 'get-syntax-token))
             (else (default key defval))))
  (require calc/parser))
Собственно подсветку осуществляет отдельный модуль calc/tool/syntax-color.rkt. В нём находится, по сути, ещё один лексический анализатор, но такой, который выдаёт не токены, а особые «пятёрки» значений: лексема, её тип (комментарий/константа/ключевое слово/символ/etc), «скобочность», а также начало и конец лексемы в исходном файле:
#lang racket

(require parser-tools/lex
         (prefix-in : parser-tools/lex-sre)
         calc/lexer)

(provide get-syntax-token)

(define (syn-val lexeme type paren start end)
  (values lexeme type paren (position-offset start) (position-offset end)))

(define get-syntax-token
  (lexer
   ((:+ whitespace)
    (syn-val lexeme 'whitespace #f start-pos end-pos))
   (lex:comment
    (syn-val lexeme 'comment #f start-pos end-pos))
   (lex:number
    (syn-val lexeme 'constant #f start-pos end-pos))
   ; "Print" у нас будет единственным ключевым словом
   ((lex-ci "print")
    (syn-val lexeme 'keyword #f start-pos end-pos))
   ; Имена переменных у нас будут идентфикаторами
   (lex:identifier
    (syn-val lexeme 'symbol #f start-pos end-pos))
   ; Арифметические операции и "=" будут считаться за скобки
   ; (Операций кроме скобок Racket, похоже, не знает)
   ((:or #\+ #\- #\/ #\* #\=)
    (syn-val lexeme 'parenthesis #f start-pos end-pos))
   ; Сами скобки тоже считаются за скобки, и вдобавок к тому обладают свойством
   ; "скобочности", чтобы редактор Racket подсвечивал открывающие и закрывающие,
   ; опять же, скобки.
   (#\( (syn-val lexeme 'parenthesis '|(| start-pos end-pos))
   (#\) (syn-val lexeme 'parenthesis '|)| start-pos end-pos))
   ((eof) (syn-val lexeme 'eof #f start-pos end-pos))
   (any-char (syn-val lexeme 'error #f start-pos end-pos))))

Внимание: чтобы подсветка синтаксиса заработала, надо перезапустить Racket! Если вдуматься, это значит, что при выполнении всех предыдущих операций Racket не надо было перезапускать! И даже test.calc можно было не закрывать. При том, что мы там «резали по живому» во многих местах. Racket истинно динамическая платформа. Вот что получится после перезапуска:

Подсветка синтаксиса в действии. В REPL, правда, она не работает (то есть работает, но не наша, а стандартная), но не будем придираться к мелочам. Racket — прекрасная вещь. Всё вышеописанное мы сделали, написав 268 строк кода. То, что получилось, можно скачать здесь. Вообще-то можно ещё сделать так, чтобы язык calc включался без #lang calc, и ещё можно научить DrRacket смотреть переменные в отладчике, и ещё можно собрать DrRacket, кастомизированный под calc и готовый к распространению, но обо всём этом как-нибудь в другой раз.

вторник, 27 сентября 2011 г.

Как создавать DSL

Необходимость создания предметно-ориентированных языков (domain-specific languages, DSL) может быть продиктована разными причинами. Например, появлением новой технологической ниши, как было с HTML и SQL. Или крайней неуклюжестью давно занявших свои ниши языков общего назначения... не будем их называть. Ещё бывают причины чисто исторические. Неважно. Пусть вам пришла в голову идея сделать DSL. Сочинили вы язык, описали грамматику, продумали семантику, а дальше что? Как получить транслятор, отладчик, где взять удобную среду для написания кода на этом DSL? Не самому же всё делать с нуля. Вот об этом и речь.

Прежде всего надо определиться: DSL или EDSL? Второй вариант, который расшифровывается как «встроенный предметно-ориентированный язык» (embedded DSL, или internal DSL), сделает вашу жизнь значительно легче. Вы выбираете язык общего назначения, для которого все средства разработки уже сделаны до вас, и создаёте свой язык как надстройку над первым, оставаясь в рамках его грамматики. Фактически, вы делаете библиотеку. Само собой, если изначальный язык (хост-язык) недостаточно гибок в плане грамматических конструкций, то и от DSL особенного удобства ждать не приходится. Тем не менее, можно на Ruby, C#, Scala создавать вполне удобные EDSL. Даже на C++ с шаблонами можно создавать совершенно потрясающие вещи.

Вообще говоря, если вы выбрали EDSL — т.е. готовы жертвовать грамматикой языка ради готовых средств разработки — то почему бы не жертвовать до конца. Откажитесь от грамматики и задавайте программу сразу как экземпляр метамодели (что принято называть абстрактным синтаксическим деревом, хотя оно не абстрактное, да и к синтаксису отношения не имеет). Возьмите платформу, которая заточена под работу с такими программами. Речь, конечно, о семействе лиспов (Common Lisp, Scheme, Clojure). Никакого синтаксиса, кроме простейших S-выражений. Динамическая типизация. Система компилируемых макросов. Культура инкрементальной разработки. Лиспам нет равных в быстроте и удобстве создания EDSL. В знаменитой книге SICP мини-языки в рамках Scheme создаются практически под каждую задачу, и даже без использования макросов.

Мы подошли к месту, когда нельзя не упомянуть JetBrains MPS. Это весьма оригинальная вещь. В компании, где пишут среды разработки для мейнстримных языков программирования, зародилась идея создания IDE для создания IDE для создания DSL. По качеству аналогичных мейнстримным. С автодополнениями, рефакторингами, удобными отладчиками. Сами посудите, какой тут может быть лисп. В лиспе отсутствует культура автодополнений и рефакторинга, не развит статический анализ кода, зато повсюду кошмарные скобки. Лисп не годился. Пара вещей, однако, была заимствована из него: отказ от синтаксиса и макросы. Первое было даже не просто заимствовано, а доведено до абсолюта. В MPS нет ни парсера S-выражений, ни какого-либо другого парсера. Потому что в MPS нет кода. Редактор MPS работает не с кодом, а непосредственно с экземпляром метамодели. В целях облегчения перехода разработчиков на данную технологию редактор напоминает текстовый, декорируя и отображая дерево так, что оно выглядит как код, но им не является. Инструментов для импорта кода MPS не предоставляет (за исключением кода на Java). Что касается макросов для кодогенерации в MPS, то они там завязаны на строгую систему типов, что позволяет автоматически делать статическую проверку типов для создаваемого DSL.

Те, кто говорят, что MPS это переизобретение Лиспа, неправы. Скорее, MPS — это скачок к визуальному метапрограммированию будущего. Что не означает, однако, что этот скачок удачный. Время покажет. Возможно, молодое поколение будет очаровано визуальной средой MPS и автодополнениями. С другой стороны, им может помешать изолированность MPS от мира текстовых программ, т.к. сейчас практически все языки текстовые. Их также может отпугнуть тотальная бюрократизация всего процесса программирования (что неудивительно при хост-платформе Java). В общем, выбор делать им, а мы идём дальше, рассматривая тот случай, когда вам нужен именно текстовый DSL, со своей уникальной грамматикой.

Итак, вы желаете реализовать настоящий DSL. С транслятором проблем не будет — связка lex+yacc, портированная, кажется, на все платформы в мире, уже лет 40 выполняет задачу автоматического построения парсеров. Для грамматик, которым не хватает yacc, есть другие инструменты. Но отладчик и IDE придётся писать самостоятельно. Если только не... найти хост-язык с настраиваемой грамматикой и взять существующую IDE для него. Если она не сломается от введения новой грамматики. Фактически, получится EDSL, но без ограничений на синтаксис, которые накладывались бы нерасширяемым хост-языком. Этот подход называется «extensible programming»; он был довольно популярен в 1960-х, потом заглох, и ожил только в XXI веке. Поэтому живых языков с настраиваемой грамматикой не очень много.
  • Forth — старейший из таких языков. Отличается тем, что «своей» грамматики практически не имеет. Таков же его преемник Factor. Программирование на Forth, однако, может оказаться не таким простым.
  • Common Lisp отметился и здесь. Помимо defmacro, которые используются для создания «скобочных» EDSL, о которых было сказано выше, в CL есть т.н. reader macros, которые позволяют изменить поведение reader-а, т.е. расширить синтаксис. Чтобы не конфликтовать со стандартным синтаксисом CL, reader macros обычно начинаются с «#», хотя могут начинаться и с любого другого символа. Например, #c(0, 1) — запись мнимой единицы одним из стандартных макросов CL. Есть примеры более развесистых макросов: запуск shell-команд прямо из CL, вызов C-функций, синтаксис для хеш-таблиц, расширенный синтаксис строк, и даже встраивание XML непосредственно в лисповый код. Однако, если, синтаксис вашего DSL конфликтует с синтаксисом S-выражений, то дело может оказаться труднее. В теории, ничто не мешает изменить стандартный reader на собственный, но как это будет на практике, и как на это отреагирует ваша IDE (например SLIME), пока не ясно. Похоже, никто серьёзно не занимался этим вопросом в CL.
  • Racket — бывшая PLT Scheme — совсем другое дело. Платформа «из коробки» поддерживает концепцию переключения между разными языками, и позволяет создавать новые языки, используя при этом генератор парсеров в стиле yacc. Есть примеры «не-скобочных» языков, созданных на Racket, по мотивам Prolog, Brainfuck и Algol 60. Среда (DrRacket) и её отладчик работают с этими языками. Более того, DrRacket написан на Racket же, что открывает возможности для модификации среды под язык и распространения её в качестве IDE для вашего DSL. Словом, Racket — очень перспективная платформа для создания DSL, и поддерживается в прекрасном состоянии.
  • Nemerle — статически типизированный язык для .NET с макросами и расширяемым синтаксисом. Возможности расширения, однако, ограничены. Есть проект Nemerle 2, в котором ограничения будут сняты, и который обещает стать чем-то вроде MPS для .NET (но без отказа от грамматик). Пока только обещает, впрочем.
  • Bigloo — ещё одна реализация Scheme, с упором на быстродействие. Так же, как и Racket, обладает средствами генерации парсеров, и поддерживает макросы, однако не имеет такого удобного механизма переключения языков и создания новых. Кроме того, не факт, что отладчик Bigloo и его IDE (основанное на Emacs) будут нормально работать с новыми языками, ибо, как и с CL, вряд ли кто-то специально занимался этим вопросом, а само собой ничего не делается.
  • Helvetia — уникальная в своём роде разработка на базе языка Smalltalk, в которой используется то обстоятельство, что вся среда Smalltalk (включая парсер) поддаётся изменению в рантайме. Helvetia — инструмент для произвольного расширения синтаксиса Smalltalk. С сохранением отладки. И даже с подсветкой и автодополнением! Что делает MPS не совсем уникальной средой. Примеры включают SQL, Brainfuck и что-то похожее на CSS. Единственная неприятность — автор Helvetia защитил по ней PhD, ушёл работать в Google и забросил своё творение. Helvetia работает только на Pharo Smalltalk версии 1.1, и не портирована ни на современную версию Pharo (1.3), ни на Squeak. Однако автор иногда что-то делает, всё-таки. Жаль, что кроме него, никто в разработке Helvetia не участвует. Кстати, автор в 2009 г. выбрал в качестве хост-языка Smalltalk (а не Lisp) по причине «однородности» языка и среды. Среды разработки Smalltalk написаны на Smalltalk, а про Scheme или CL такого сказать было нельзя. DrRacket в то время был наполовину написан на C++, и его переписали на чистом Racket только в феврале 2011.

Как видите, в принципе есть инструменты на любой вкус и под любые требования. В наше время создавать DSL стало не только полезно, но и приятно.

Автор признателен LOR-у за подсказки и Ф.А.Новикову за вычитывание черновика.

вторник, 20 сентября 2011 г.

Горькая правда о Питоне и его Global Interpreter Lock (цитата)

GIL не уберут никогда. Или, по крайней мере, в ближайший десяток лет. Сейчас никаких работ на эту тему не ведется. Если некий гений предъявит работающую реализацию без GIL, ничего не ломающую и работающую не медленней, чем существующая версия — предложению будет открыт зеленый свет. Пока же «убрать GIL» проходит по части благих, но невыполнимых пожеланий.
В Java и C# никакого GIL нет. Потому что у них иначе устроен garbage collector. Если хотите, он более прогрессивный. Переделать GC Питона, не сломав обратной совместимости со всеми существующими библиотеками, использующими Python C API — невозможно. Сообщество и так уже который год лихорадит в связи с переходом на Python 3.x. Разработчики не желают выкатывать второе революционное изменение, не разобравшись с первым. Ждите Python 4.x (которого нет даже в планах) — до тех пор ничего не поменяется.

суббота, 10 сентября 2011 г.

Искал работу

Для кого-то поиск работы — это унылое листание унылых вакансий, слащавые письма от слащавых рекрутеров, пробивание стен HR-отделов и т.п. Можно, конечно, и так. Но по факту — существуют хорошие работы, но не существует рынка хороших работ. Хорошие работы появляются тогда, когда кто-то хочет сделать что-то новое и крутое, вне устоявшегося положения вещей (нет, не веб-стартап, вы меня не так поняли). Из чего следует, что вакансии нерелевантны, рекрутеры не в теме, а HR вообще по жизни не в теме.

Общаться надо с теми самыми, которые (см. выше).

И вы никогда не пожалеете об этом, вне зависимости от своих карьерных планов.

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

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

пятница, 24 июня 2011 г.

О краткости языка (On the Conciseness of Language)

Английский язык более ёмкий, чем русский. Каждый русскоговорящий гражданин когда-то узнаёт это в первый раз. Лично я об этом прочёл в 8-м классе в детективе одной современной отечественной писательницы детективов (кажется, в нём же я узнал об асимметричной схеме шифрования с открытым ключом, которая подавалась авторшей как личное изобретение одного из преследуемых милицией злодеев). Впоследствии я не испытывал недостатка в подкрепляющих примерах. Многие английские слова короче своих аналогов на русском, а ещё короче комбинации «прилагательное+существительное». Чего стоят одни только «exit polls» — тележурналисты бросили пытаться их переводить и так и говорят, по-английски. Английский язык гибче и выразительнее, это был очевидный факт.

Шли годы.

Я узнавал больше о языке, и во мне росло сомнение.

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

Тексты

Часто так бывает: пишет человек письмо или статью на русском, а потом самостоятельно переводит на английский, и второй вариант получается компактнее. Потом оказывается, что у него «is» вместо «is being», «was» вместо «has been», «I will» вместо «I am going to», «for» вместо «in order to», «it’s» вместо «it is» (вариант с апострофом, вообще-то, является разговорным, и допускается только в прямой речи или сильно неформальном письме), артикли забыты в половине мест, и вообще такой текст носитель языка прочтёт лишь изрядно поморщившись, потому что фразы построены неверно. Или ещё бывает наоборот, над английским текстом автор постарается, дабы не уронить лицо, и напишет ясно и лаконично; а в русском варианте оставит разные там «исходя из вышеизложенного, совершенно очевидно, что».

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

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

Так вот: русский вариант содержит 15181 букв и цифр (пробелы и знаки препинания не в счёт), а английский — 16239. Разница в пользу русского, но в пределах статистической погрешности.

Возьмём фрагмент поменьше, из повести Пелевина «Зомбификация», глава «Бульдозер»:
Представим себе небольшое село, стоящее на холме — некоторые дома уже очень стары, другие, наоборот, построены по самым последним проектам, а большинство — нечто среднее между первым и вторым. Бок о бок стоят полузаброшенная церковь и недостроенный клуб. В одних окнах мигает керосиновая лампа, в других горит электричество, где-то чуть слышно играет балалайка, которую перекрывает радиомузыка со столба. Словом, обычная жизнь, остатки нового и старого, переплетенные самым причудливым образом.
Теперь представим себе бульдозериста, который, начитавшись каких-то брошюр, решил смести всю эту отсталость и построить новый поселок на совершенно гладком месте. Сырой октябрьской ночью он садится в бульдозер и в несколько приемов срезает всю верхнюю часть холма с деревней и жителями. И вот, когда бульдозер крутится в грязи, разравнивая будущую стройплощадку, происходит нечто совершенно неожиданное: бульдозер вдруг проваливается в подземную пустоту — вокруг оказываются какие-то полусгнившие бревна, человеческие и лошадиные скелеты, черепки и куски ржавчины. Бульдозер оказался в могиле. Ни бульдозерист, ни авторы вдохновивших его брошюр не учли, что когда они сметут все, что по их мнению устарело, обнажится то, что было под этим, то есть нечто куда более древнее.
Психика человека точно так же имеет множество культурных слоев. Если срезать верхний слой психической культуры, объявив его набором предрассудков, заблуждений и классово чуждых точек зрения, обнажится темное бессознательное с остатками существовавших раньше психических образований. Все преемственно, вчерашнее вложено в сегодняшнее, как матрешка в матрешку, и тот, кто попробует снять с настоящего стружку, чтобы затем раскрасить его под будущее, в результате провалится в очень далекое прошлое.
Перевод, выполненный вашим покорным слугой, был отредактирован и заверен расовым носителем британского английского. Перевод точный, ничего не упущено и не добавлено.
Let us picture a village standing on a hill, with some houses being very old and some others designed along up-to-date lines, while the majority is somewhere between the two. A semi-abandoned church stands side by side with an unfinished workers’ club. In some windows, kerosene lamps flicker, while in some others, electric light burns. Barely audible, a balalaika plays, drowned by music from a radio on a street pole. In sum, that is a piece of ordinary life, odds and ends of old and new, twisted around each other in a most cranky way.
Now, let us picture a bulldozer driver who, having overdosed on some brochures or other, has taken the decision to slice away all that backwardness and build up a new village from scratch. So in the middle of a raw October night he sits behind the wheel of his bulldozer and, bit by bit, cuts off the entire top of the hill along with the village and its inhabitants. And now, while the bulldozer is spinning in the mud, leveling out the new building site, something very unexpected happens: the bulldozer suddenly falls into a void and finds itself among half-rotten logs, skeletons of men and horses, some shards and rusty smithers. The bulldozer is caught in a grave. Neither the driver nor the authors of those brochures that inspired him had taken into account that as soon as they wipe away everything that is—in their opinion—obsolete, something deep crops out, something much more ancient.
Human psyche—in exactly the same way—has a lot of cultural layers. If one cuts off the upper layer of the culture, declaring it a pile of superstitions, delusions and class-alien points of view, then the darkness of the unconscious crops out together with some preexistent psychic traits. Everything is successive, yesterday nests into today, like one Russian doll inside another, and whoever takes the liberty of planing away the present in order to paint it with colors of the future, will only fall into a very distant past.
В оригинале 1469 букв, в переводе 1576. Разница опять в пользу русского и в пределах статистической погрешности.

Но это просто примеры. Может быть, есть какое-то теоретическое обоснование ёмкости английского языка перед русским? Мне о таком неизвестно. В английском нет падежей — зато есть частичка «to» и артикли, которые в русском очень редко являются обязательными, а в английском — очень часто. В английском проще словообразование — но при этом жёсткий порядок слов в предложении. В английском нет сложных окончаний — но есть довольно запутанная система «времён». Английский язык аналитический, а русский синтетический — но это ничего не говорит о ёмкости.

Речь

В английской речи допустимы сокращения, такие как «it’s», «I’ll», «you’d», «can’t». С другой стороны, английская речь должна звучать длинно. Чем длиннее, тем вежливее, это закон. В русском для вежливости полезна интонация, обращение на «вы» и перестановка слов — в английском интонация не играет большой роли, «вы» отсутствует, а перестановки слов редки. Когда на русском говорят «[сделайте ч.-л.], пожалуйста», на английском то же самое должно звучать как «could you please [do smth]». Короткое, но вежливо сказанное «нет» переводится как «I don’t think so». И так далее. Короткие фразы уместны в армии,  больнице и т.п. местах, где проволочки ни к чему. Бросаться же короткими фразами в бытовом общении — значит быть невежливым (в частности из-за этого хорошо воспитанных русских за границей считают плохо воспитанными).

Софт

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

При желании русский интерфейс можно сделать таким же кратким (например, Cut/Copy/Paste переводить как Режь/Множь/Клей). Это вопрос традиции, а не языка. Сложилась традиция писать длинно, вот и всё.

четверг, 16 декабря 2010 г.

О плагинах и линковке

В какой-то момент развития Indigo — кроссплатформенной C++ библиотеки для решения задач химической информатики — мы осознали, что любая нормальная библиотека такого рода должна позволять third-party расширения. Сказано-сделано. Indigo обрела поддержку плагинов. Обёртки на Java, Python и C# также расширяемы, при этом плагин на соответствующем языке является обёрткой бинарного модуля-плагина на C++. Всё это работает на Windows, Linux и Mac OS X. Дальше речь пойдёт о проблемах, с которыми мы столкнулись при линковке бинарных модулей, и о том как эти проблемы решить.

Постановка задачи

Есть библиотека на C++, назовём её «Core». В ней есть какое-то количество классов и некоторый интерфейс на C, который используется для «оборачивания» библиотеки в Java/Python/C# модули.

Есть ещё одна библиотека на C++, назовём её «Plugin». В ней тоже есть классы и C-интерфейс. Plugin имеет доступ к C++ классам Core и её глобальным функциям, тогда как Core не в курсе о существовании Plugin.

Core линкуется в динамическую библиотеку, назовём её libcore. Plugin тоже линкуется в динамическую библиотеку, назовём её libplugin. Эта библиотека не содержит в себе libcore.

Задача 1: libcore должна загружаться в адресное пространство программы на Java/Python/C# при инициализации соответствующей обёртки Core. Тип операционной системы (Windows/Linux/Mac OS X) и «битность» (32/64) должны определяться автоматически, т.е. нужно выбрать из коллекции сборок libcore для всех платформ правильную, и загрузить именно её. Сразу оговоримся, что обёртки поставляются в комплекте со всеми вариантами библиотеки, чтобы таким образом не нарушать «переносимость» и не причинять головную боль разработчикам, которые будут использовать обёртку.

Задача 2: libplugin должен загружаться в адресное пространство программы на Java/Python/C# при инициализации соответствующей обёртки Plugin. Само собой, и в этом случае ОС и битность определяются автоматически. Кроме того, libplugin должна «увидеть» уже загруженную libcore и подцепить из неё нужные C++ классы и глобальные функции.

Решение задачи 1

В Linux и gcc помогут ключи -m32 и -m64, с которыми собирается соответственно 32-битный и 64-битный код. Для кросс-компиляции, т.е. сборки под платформу, не соответствующую той, на которой выполняется компиляция, надо установить пакет gcc-multilib.

В Windows и Visual Studio дело делается установкой платформы Win32 или x64 в свойствах проекта. Чтобы платформа 64 появилась на 32-битных версиях VS, надо при инсталляции VS поставить галочку напротив «x64 Compilers and Tools», ну или доустановить потом отдельно.

На Mac OS X «битность» не является проблемой благодаря технологии универсальных бинарников. Не иначе как для компенсации этого удобства, бинарники, собранные для версии Mac OS X 10.6, не запускаются на 10.5, так что имеет смысл собирать для 10.5 и 10.6 отдельно. Или собирать только для 10.5 и рассчитывать, что они подойдут для 10.6, но мы это не пробовали.

Теперь пара слов о том, как определить платформу на этапе выполнения программы на Java/Python/C# и загрузить нужный модуль libcore.

Java

Узнать тип операционной системы можно с помощью System.getProperty("os.name"). «Битность», соответственно, через System.getProperty("os.arch"). Версия Mac OS X находится в System.getProperty("os.version").

После того, как платформа определена и путь к подходящему бинарнику libcore получен, загрузить бинарник можно вызовом System.load.

Python

Константа os.name равна "nt", если дело происходит на Windows, и "posix", если это Linux или Mac OS X. Последняя отличается тем, что platform.mac_ver() возвращает определённую структуру данных, из которой как раз можно узнать версию системы. Битность можно узнать с помощью platform.architecture()

Загрузку бинарного модуля в Python, как и дальнейшую работу с ним, проще всего осуществлять с помощью ctypes.

C#

Операционная система в данном случае однозначно Windows. (Нет, до Mono мы ещё не добрались.) Битность можно определить, например, проверкой IntPtr.Size. Для вызова C-функций из C# используется DllImport("core.dll"). При вызове любой функции, объявленной с таким атрибутом, CLR попытается загрузить core.dll из системных директорий в память, если библиотека с таким именем ещё не загружена. Поскольку наша core.dll лежит не в системной директории, и вообще существует в двух вариантах (32 и 64 бита), то мы должны загрузить её до того, как будет вызвана любая функция из Core. Для того, чтобы загрузить core.dll из произольной директории, нужно воспользоваться системным вызовом LoadLibrary, который, в свою очередь, доступен через DllImport("kernel32").

Решение задачи 2

Функции, а также методы C++ классов, которые то же самое что функции, в терминологии линковщика называются символами. Символы, которые библиотека «показывает» наружу, называются экспортируемыми символами. Символы, которые библиотека берёт из других библиотек, называются импортируемыми символами. Надо сделать так, чтобы символы libcore, которые используются в libplugin, экспортировались из libcore и импортировались в libplugin. В каждой ОС динамический линковщик работает по-своему, и рецепты соответственно для каждой ОС свои.

Linux

С экспортом и импортом принципиальных сложностей нет. линковщик (ld), который собирает объектные файлы в .so-модуль, все неопределённые (undefined) в библиотеке глобальные символы считает импортируемыми, а все определённые символы считает экспортируемыми (есть возможность экспортировать только избранные символы, но нам это не нужно). Динамический линковщик (который не ld, а часть операционной системы) при загрузке libcore запишет все экспортированные из неё символы, а при загрузке libplugin обнаружит в ней символы, ждущие импорта, и импортирует их в libplugin из libcore.

Есть ещё одна особенность. Символы могут быть помечены импортируемыми «откуда угодно», или импортируемыми из конкретной библиотеки.

Импорт символов из конкретной библиотеки

ld помечает символ импортируемым из конкретной библиотеки если при линковке указать .so-файл, из которого этот самый символ экспортируется. То есть, в libplugin.so будет чётко прописано, что такой-то символ импортируется из библиотеки libcore.so и только из неё. Кроме того, динамический линковщик будет пытаться при загрузке libplugin.so загрузить libcore.so из директорий, записанных в:

  1. Переменной окружения LD_LIBRARY_PATH. При этом, учитывается только значение этой переменной на этапе запуска программы. Фокусы с подменой LD_LIBRARY_PATH по ходу пьесы не работают. Для программ с setuid/setgid LD_LIBRARY_PATH и вовсе игнорируется.
  2. Кэше /etc/ld.so.cache
  3. /lib
  4. /usr/lib
(man ld-linux)

Если libcore.so не будет найдена ни в одной из указанных директорий, то загрузка libplugin.so не пройдёт успешно. Нетрудно понять, что для наших целей такой подход не годится, т.к. libcore мы распространяем в двух вариантах (32 и 64 бита) и обязательно вместе с самой программой, чтобы разработчики на Java и Python не терпели неудобств с непереносимостью своих программ из-за бинарных модулей.

Импорт символов из любой библиотеки

Если в ld при линковке libplugin.so не передавать libcore.so, то он пометит отсутвующие символы как импортируемые, но не укажет откуда именно. Динамический линковщик затем при загрузке libplugin.so не станет пытаться загрузить libcore.so, а попытается найти отсутствующий символ среди всех загруженных на данный момент библиотек (+в самой программе). Конечно, libcore.so будет среди них, т.к. мы инициализировали Core до того, как начали инициализацию Plugin. Всё очень хорошо, но есть ещё одна деталь.

На самом деле, ld перебирает не все загруженные на данный момент библиотеки, а только те из них, которые были загружены с флагом RTLD_GLOBAL (man dlopen). Те библиотеки, которые загружены с флагом RTLD_LOCAL, подходят только для импорта символов конкретно из них (см. предыдущий заголовок). Виртуальная машина Java и её System.load(), что бы вы думали, конечно загружает все библиотеки с RTLD_LOCAL, без вариантов. Но есть обходной путь! Он появился в glibc 2.2 (2000 г.) не иначе как специально для Java: это флаг RTLD_NOLOAD. После вызова System.load("/path/to/libcore.so") можно вызвать (уже не из Java, а из C):

dlopen("/path/to/libcore.so", RTLD_NOLOAD | RTLD_GLOBAL);
и символы из libcore.so, ранее «закреплённые» за libcore.so, станут доступны для импорта по схеме «откуда угодно». Динамический линковщик отдаст их в нужный момент в libplugin. Дополнительная изюминка заключается в том, что вызов dlopen для libcore.so с RTLD_NOLOAD можно оформить в самой libcore.so, в какой-нибудь функции инициализации. Будет работать.

Что касается загрузки .so-файлов в Python, то с ним всё гораздо проще, т.к. ctypes поддерживает произвольные флаги для загрузки библиотек, в т.ч. и RTLD_GLOBAL:

lib = CDLL("/path/to/libcore.so", mode=RTLD_GLOBAL);

Mac OS X

Правила линковки на Mac OS X такие же, как и в линуксе, есть только небольшие различия в терминологии и опциях линковщика. Схема с привязкой символов к библиотеке, описанная в предыдущем разделе, здесь имеет название: two-level namespace (man ld). Альтернативная схема, которая нам и нужна, называется flat namespace. На этапе компиляции libplugin надо выбрать, по какой схеме испортировать в неё символы. Для нас это означает, что надо передать в ld ключ "-flat_namespace".

Использование flat namespace, по замыслу разработчиков, не отменяет необходимости задания в командной строке ld всех зависимых .dylib-файлов (т.е. в нашем случае libcore.dylib). Это странно, но оправдано для более сложных случаев с косвенными зависимостями (indirect dynamic libraries), которые к нашей задаче не имеют отношения. Можно, тем не менее, попросить ld закрыть глаза на неразрешённые зависимости, передав ему ключ "-undefined suppress". В этом случае динамический линковщик будет разрешать их в рантайме, как в Linux.

Трюк с RTLD_NOLOAD | RTLD_GLOBAL в Mac OS X тоже актуален.

Windows

В Windows нет динамического линковщика.

Его там не может быть в принципе из-за отсутствия поддержки position-independent code, но от этого не легче. В Windows есть только загрузчик динамических библиотек (loader), который, мягко говоря, не совсем в курсе про динамическое связывание. Вся работа по динамическому связыванию делается самой загружаемой DLL на этапе инициализации. Компилятор и линковщик (link.exe) должны позаботиться о том, чтобы DLL сделала эту работу правильно. Программист, в свою очередь, должен позаботиться о том, чтобы компилятор и линковщик правильно поняли свою задачу.

Экспортируемые и импортируемые символы

По указанным выше причинам, в DLL не может быть неопределённых (undefined) символов. Никто не проверит на этапе загрузки DLL, какие символы в ней «defined», а какие «undefined». Такого вопроса просто не стоит. Все символы должны быть определены. В том числе и символы, которые импортируются из другой DLL.

Этот парадокс разрешается следующим образом: при компиляции модулей DLL, в которую импортируются символы (в нашем случае plugin.dll) компилятор на месте импортируемых символов создаёт функцию-"прослойку", которая

  1. Загружает в память DLL, в которой находится нужная функция (LoadLibrary("core.dll")). Большая удача, нет мороки с путями. Есди загрузить core.dll из нужной директори заранее (LoadLibrary("\path\to\core.dll")), то plugin.dll не станет искать core.dll в системных директориях, а просто «подхватит» уже загруженную копию.
  2. Получив указатель (handle) на загруженную DLL, ищет в ней нужную функцию (GetProcAddress) и вызывает её с теми параметрами, которые были переданы в неё саму, т.е. в прослойку.

link.exe не догадается сделать такую прослойку для всех C++ функций, которые не определёны в DLL (хотя догадается сделать это для C-функций, слабое утешение). Перед объявлением каждой из C++ функций, которую вы хотите импортировать в plugin.dll, надо писать __declspeс(dllimport).

Более того, link.exe не догадается экспортировать из DLL символы, которые в ней определены. Перед каждой функцией, которую вы хотите экспортировать из core.dll, надо писать __declspec(dllexport). Это касается и C, и C++ функций. При линковке любой DLL помимо собственно DLL возникает маленький файл с расширением .lib — он и содержит вышеуказанные «прослойки» для всех экспортированных функций.

Получается, что одни и те же C++ функции должны объявляться в Core как __declspec(dllexport), а в Plugin — как __declspec(dllimport). Объявляются эти функции в заголовочных файлах, которые одинаково включаются в Core и в Plugin. Самое время воспользоваться препроцессором:

#ifdef _WIN32
#ifdef PLUGIN
#define DLLEXPORT __declspec(dllimport)
#else
#define DLLEXPORT __declspec(dllexport)
#endif
#else
#define DLLEXPORT
#endif
и перед всеми функциями Core, которые нужны в Plugin, писать макрос DLLEXPORT. При сборке plugin.dll надо указать компилятору макрос PLUGIN.

Экспортирование C++ классов

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

Написав DLLEXPORT класса, у которого есть суперкласс, или неприватные поля — объекты каких-либо классов, или неприватные методы, возвращающие объекты каких-либо классов, то при компиляции вы заметите следующий варнинг:

warning C4251: *** : class *** needs to have dll-interface to be used by clients of class ***
Это, в принципе, правильное предупреждение. Если Plugin импортирует класс «CoreClass», определённый в Core, то он конечно будет использовать его публичные (public) методы и публичные поля. Или отнаследуется и будет использовать защищённые (protected) методы и поля. Все классы, возникающие в процессе работы с CoreClass, должны быть доступны через импорт так же, как и сам CoreClass. И всех их надо тоже пометить как DLLEXPORT, о том и речь в варнинге C4251. Иначе Plugin ждёт ошибка линковки.

Если бы в проектах Core и Plugin не было бы классов-шаблонов, то на этом бы наше повествование благополучно закончилось. Но шаблоны у нас есть. Нет, сами классы, которые надо экспортировать, не являются шаблонами, но среди их методов есть такие, которые принимают или возвращают объекты шаблонных классов. Вот на тему этих шаблонных классов и возникает C4251.

Можно, конечно, попытаться писать DLLEXPORT при объявлении шаблонов. В простых случаях это даже будет работать. Но на самом деле это лишено всякой логики. Шаблон — это не класс, он никогда не экспортируется. Экспортируется конкретный класс, который создаётся в тот момент, когда компилятор встречает инстанцированный шаблон. Выйдет так, что все классы-экземпляры «экспортируемого» шаблона будут экспортироваться из Core и импортироваться в Plugin. Это хорошо до тех пор, пока Plugin не воспользуется одним из шаблонов Core с параметром, которого нет в Core. (То есть, из Core попросту не экспортируется данный экземпляр шаблона). И даже тогда всё может быть в порядке; но рано или поздно окажется, что компилятор не в состоянии понять, что не надо импортировать данный экземпляр шаблона из Core, а надо его создать. В нашем случае это произошло, когда один «экспортированный» шаблон с неэкспортированным экземпляром инстанцировался в другом, тоже с неэкспортированным экземпляром. Дело закончилось ошибкой вида
error LNK2001: unresolved external symbol "__declspec(dllimport) ..."

Есть ещё один способ обойти C4251, под названием «explicit template instantiation», т.е. явное экспортирование экземпляра шаблона. Он подробно разбирается в этой статье, в применении к STL, с которой кстати приписать DLLEXPORT к объявлению шаблона нет возможности. Код получается немыслимо громоздким, а результаты — неутешительными. В статье отмечен тот факт, что экспортирование шаблонных классов в случае линковки нескольких (независимых друг от друга) DLL, содержащих одни и те же шаблонные классы, приведёт к ошибкам линковки вида

LNK1169: one or more multiply defined symbols found
Да, link.exe, в отличие своего коллеги ld, не умеет определять и отбрасывать дубликаты функций в разных библиотеках.

Короче говоря, экспортировать шаблоны нет смысла и вообще нельзя. Есть два выхода из ситуации с C4251:

  1. Игнорировать, благо это варнинг, а не ошибка. core.dll и plugin.dll просто будут иметь по пачке одинаковых методов для шаблонных классов. Конфликта при загрузке не будет, поскольку классы не экспортированы. Ошибки линковки тоже не будет, если все не-шаблонные классы имеют в своём заголовке DLLEXPORT.
  2. Не экспортировать свои классы, а экспортировать только их методы, с помощью того же DLLEXPORT в объявлении каждого метода. Как ни странно, варнинг при этом исчезает. Странно, потому что опасность-то остаётся. Опасность заключается в том, что core.dll и plugin.dll всё равно будут иметь независимые реализации одних и тех же шаблонных классов; и если окажется так, что plugin.dll была собрана с иной версией этих классов, бинарно несовместимой с той, с которой была собрана core.dll, то они не смогут вместе работать с этими классами. В статье по ссылке выше сказано, что именно это может произойти с классами STL, когда одна библиотека собрана под VS7, а другая под VS8.

Прочие проблемы с Windows

Ещё пара неприятностей, по числу которых Windows и так лидирует с большим отрывом:

  1. Чтобы собрать plugin.dll, надо поставить в проекте Plugin зависимость на проект Core. Однако, если появилась необходимость завести в проектах Plugin и Core конфигурации сборки «Static», и собирать их в plugin-static.lib и core-static.lib, то зависимость останется, и её не убрать. core-static.lib будет вкомпиливаться в plugin-static.lib, вместе со своими глобальными переменными. Проект, который зависит от обеих библиотке Core и Plugin, при сборке обретёт две копии Core, и работать скорее всего не будет из-за наличия двух копий глобальных данных. Придётся создавать отдельные проекты CoreStatic и PluginStatic. Даже тогда, не получится оставить Core и Plugin пустыми и просто поставить им зависимости соответственно от CoreStatic и PluginStatic: при линковке Core и Plugin не будут экспортироваться символы. Придётся дублировать в Core/Plugin и CoreStatic/PluginStatic одинаковые наборы файлов.
  2. В компиляторе VS есть опция /MT, что означает, что рантайм (CRT) будет «вкомпилен» в библиотеку или программу. Это очень удобно, с учётом того что даже в самые современные версии Windows эта CRT не включена! В Windows есть только очень старая версия, а VS линкует с новой, которая доступна для Windows либо в составе VS, либо в виде отдельного пакета. Так вот, опция /MT в случае двух связанных между собой DLL приведёт к ошибке во времени выполнения. Она неизбежно случится, когда например память выделена в одной DLL, а освобождается (или даже копируется) другой DLL. Остаётся использовать опцию /MD и обеспечивать затем присутствие «Redistributable Package» на машине пользователя.

вторник, 13 июля 2010 г.

Навигация в мире органических соединений

Сколько органических соединений вы знаете? А сколько вы знаете лекарств? Каждое лекарство, не считая тех, что производятся из растений, преставляет собой комплект из действующего вещества и оболочки/растворителя, в которой/ом оно проходит свой путь до усваивания организмом пациента. Действующее вещество в лекарственном препарате — это одно конкретное органическое соединение1. Количество известных органических соединений, которые можно добыть или синтезировать, превышает 30 миллионов, а количество лекарств на рынке — всего несколько тысяч. Создание любого нового лекарства занимает от 10 до 15 лет и является очень дорогостоящим. Расходы на программное обеспечение составляют в этой индустрии (как и почти в любой другой) весьма скромную долю.

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

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

Представим, что усилиями биологов в организме выявлен «нездоровый» белок, вызывающий болезнь или болезненные ощущения. Дело за малым — найти вещество, которое разрушит или заблокирует белок, не причинив вреда организму в процессе. Затруднение состоит в том, что на эту роль может годиться одна молекула из 30 миллионов. Или ещё не открытая молекула. Современные технологии массового синтеза (т.н. комбинаторная, или сочетательная химия) и массовых биохимических опытов (HTS), позволяют за короткие сроки получать сотни тысяч новых молекул и гигабайты экспериментальных данных.

Опишем карьеру лекарственного вещества от конца к началу. До того, как попасть на прилавки аптек, лекарство должно пройти клинические испытания (clinical trials) на пациентах, под присмотром врачей. Это представляет определённый риск, поэтому до пациентов доходят лишь немногие вещества, прошедшие доклинические тесты (preclinical testing) на животных. Их тоже берегут, поэтому доклиническим испытаниям предшествуют массовые опыты на отдельных живых клетках (in vitro, лат. «в стекле», т.е. в пробирке). Но и в пробирки не бросаются все молекулы подряд. Люди должны выбирать нужные (перспективные для лечения данной болезни) вещества и отбрасывать заведомо неподходящие, и без компьютера им этого не сделать2. На самом деле, и с ним не очень удобно. Эффективная система навигации по химическим содинениям пока ещё не создана; и о перспективах создания таковой сейчас пойдёт речь3.

Поиск химических соединений в базах данных

Состояние систем поиска химических соединений в наши дни, увы, примерно соответствует состоянию поисковых систем во всемирной паутине в 90-е годы4. Да, именно так. Примитивные алгоритмы поиска (и поиск ведётся далеко не по всем имеющимся источникам); весьма вялая поддержка языковой грамматики; никакого ранжирования результатов.

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

Например, амфетамин является подструктурой мезокарба, и оба препарата подавляют реакцию обратного захвата дофамина (dopamin reuptake) в мозге, что ведёт к повышению активности. Но мезокарб, в отличие от амфетамина, не вызывает тахикардии и повышения артериального давления.

Амфетамин и мезокарб

Вообще говоря, нередко случается так, что добавление или удаление небольшого фрагмента идёт молекуле (точнее, пациентам) на пользу. Чем добавлять и удалять всевозможные фрагменты вручную, проще запустить поиск по базе данных и найти все молекулы, содержащие данную как подструктуру или все молекулы, содержащиеся в данной. Соответствующие виды поиска называются «подструктурный поиск» (substructure search) и «надструктурный поиск» (superstructure search).

Подструктурный поиск в сервисе Bingo с амфетамином в качестве запроса.

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

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

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

Перечислим наиболее популярные поисковики химических соединений:

  • PubChem — база данных из 27 миллионов соединений с богатыми возможностями для поиска: по номеру, по названию, по структурной формуле, по подструктуре и по сходству. Химические свойства также можно задавать в качестве дополнительных критериев поиска (например, ограничиться только молекулами, молекулярная масса которых не превышает 120). Базу постоянно пополняют более 80 организаций.
  • ChemSpider содержит 25 миллионов соединений и имеет важное отличие от PubChem: добавлять молекулы и обновлять информацию о них здесь могут не только избранные организации, но и простые пользователи. Вместе с последними, список источников ChemSpider составляет почти 300 пунктов. Поиск соединений в ChemSpider не имеет такого количества опций, как в PubChem; в частности, отсутствует поиск по сходству.
  • eMolecules — компиляция из 7 миллионов соединений, собранных в 150 коммерческих каталогах. Возможности поиска минимальны; никакой информации о соединениях, кроме ссылок на каталоги, сайт не показывает. Это скорее платформа для продавцов химических веществ, нежели поисковая система для исследователей.

Поиск химических соединений по научным публикациям.

Пионерами поиска в научных работах по химии были создатели химической реферативной службы (Chemical Abstracts Service, CAS), существующей с 1907 года. В этой службе ведётся учёт всех известных химических соединений. Тысячи людей в течение десятков лет вручную составляют библиографические справки и заполняют базу данных SciFinder, отдельного продукта CAS для поиска публикаций. Аналогичная база данных, поддерживаемая издательством Elsevier, называется «Crossfire Beilstein». Сервисы PubChem и ChemSpider также выдают пользователю вместе с каждой найденной молекулой список публикаций, к которым данная молекула может иметь отношение; но возможности для поиска собственно публикаций в этих сервисах не очень развиты.

SciFinder

Как же, наконец, отправить на отдых тысячи «индексаторов», от рассвета до заката читающих статьи и заполняющих библиографические базы? Эта задача несколько сложнее, чем найти слово «парацетамол» по текстам статей. Во-первых, само вещество может иметь несколько названий (пример альтернативного названия парацетамола — «N-(4-гидроксифенил)ацетамид»). Во-вторых, лекарство, содержащие это вещество, может упоминаться под разными торговыми марками (в данном случае «Панадол», «Эффералган» и десяток других). В третьих, вещество может быть не написано, а нарисовано в статье. В растровом виде (в старых отсканированных статьях), или в векторном (начиная с 90-х годов). Программы по автоматическому распознаванию рисунков с молекулами сегодня находятся в плачевном состоянии. Вот наиболее известные из них:

  • CLiDE канадской фирмы SimBioSys
  • OSRA — проект с открытыми исходниками нашего соотечественника, работающего в США
  • ChemoCR— проект Марка Циммермана из института Фраунгофера в Германии
CLiDE — наиболее развитая программа из перечисленных, но она нередко ошибается, требует вмешательства человека и «не знает» многих особенностей молекул. OSRA активно развивается, но обладает на данный момент худшим качеством распознавания. ChemoCR, похоже, находится в перманентной закрытой разработке: эту программу никто никогда не видел, тем не менее доступно немалое количество публикаций по алгоритмам, используемых в ней. Указанные программы ещё менее пригодны к распознаванию более сложных химических объектов, как-то: химических реакций, таблиц с заместителями. Комбинированный семантический анализ текста и рисунков (например, «молекула на рис. 10a имеет показатель LD50 равный 5.6 г/кг для взрослых крыс») вообще нигде не реализован.

Планирование синтеза

Схема синтеза химического соединения представляет собой цепочку химических реакций.

Многоступенчатая реакция синтеза парацетамола

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

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

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

Reaxys

Заключение

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

В настоящий момент наиболее перспективная система, которая может в будущем стать «Гуглом и Википедией» химиков — это ChemSpider. Её создатели явно принимают в расчёт ключевые элементы успеха глобальных сервисов: кросс-платформенность (работает через броузер и даже с мобильных устройств), доступность для каждого, богатые возможности, «дружба» с многими другими сервисами (включая PubChem), право пользователей публиковать свой контент. ChemSpider имеет недостатки, как общие для индустрии, так и свои собственные, но движется в правильном направлении.

ChemSpider

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

Благодарности

Автор признателен Н. Велецкому и Д. Лушникову за ценные замечания по тексту статьи.

Сноски

1 В редких лекарствах их два, например в бисептоле.

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

3 На прочих стадиях роль вычислительных машин не менее важна; однако возникающие там задачи лежат за рамками данной статьи.

4 (до появления Google). Тогдашних «королей» информационного поиска (Altavista, Lycos, Rambler) мало кто помнит; в том числе оттого, что они были практически бесполезны.

5 Взаимодействие молекулы с белком тоже можно моделировать на компьютере. Этому посвящена отдельная область вычислительной химии под английским названием «docking».

Примеры неудовлетворительной работы OSRA

(Картинки выложены по просьбе Игоря в комментариях). На первых двух и на последней OSRA не выдаёт никакого результата, на 3-й, 4-й и 5-й выдаёт неверный результат.