Высокоуровневый Haskell Java интерфейс

2007 ВЕСТНИК САНКТ-ПЕТЕРБУРГСКОГО УНИВЕРСИТЕТА. Сер. 10. Вып. 3

ИНФОРМАТИКА

УДК 004.43+519.683 Д. Д. Еловков

ВЫСОКОУРОВНЕВЫЙ HASKELL JAVA ИНТЕРФЕЙС

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

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

В [1] предлагается модификация языка SML с целью тесной интеграции его с языком Java. В [2] описывается достаточно низкоуровневый подход к связи языка ФП Haskell и Java, который стал основой нашего решения. В [3] рассматривается, пожалуй, наиболее развитый подход к взаимодействию языков Haskell и Java, основывающийся не на модификации языка, а лишь на средствах системы типов языка Haskell. Однако подход, выбранный авторами в [3], отличается от используемого нами.

В данной работе предлагается механизм тесного межъязыкового взаимодействия между Java программой и программой на языке ФП Haskell. Наши основные достиже-

1) для Java вызовов из Haskell программы построена специальная монада, что повышает модульность программы и позволяет использовать богатый запас операций с монадами, например преобразования монад;

2) простота и статические гарантии корректности типов при вызове Java вычисле-

3) естественный и безопасный способ вызывать функции с нефиксированным числом параметров;

© Д. Д. Еловков, 2007

4) возможность строить Java вычисления произвольной сложности;

5) инкапсуляция обработки исключений;

6) часть, написанная на языке С, минимальна.

Дальнейший текст статьи разбит на части следующим образом: в п. 2 кратко описываются две взаимодействующие системы, виртуальная машина Java и Haskell система; в п. 3 формулируется задача; п. 4 составляет основную часть работы и содержит описание решения; в п. 5 мы подводим итоги.

2. Описание систем. Прежде чем перейти к описанию задачи и решения, опишем вкратце языки Haskell и Java и их исполняющие среды.

Начнем с Java. В силу общеизвестности данной платформы, не будем описывать сам язык. От виртуальной машины Java будем требовать только поддержки Java Native Interface (JNI) [4]. Данный интерфейс предоставляет низкоуровневый доступ к виртальной машине и позволяет делать ровно то же, что можно делать средствами языка Java, но, конечно, со значительно меньшим удобством. Таким образом, говоря о виртуальной машине Java, мы говорим о любой ее реализации, поддерживающей JNI.

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

Мы будем требовать от Haskell системы поддержки интерфейса Foreign Function Interface (FFI) [5]. Он является результатом ряда попыток построить стандартное средство для импортирования и экспортирования функций из других языков программирования в Haskell программе. В данной работе используется импортирование С функций. Рассматривается Haskell система, компилирующая в машинный код.

3. Постановка задачи. Требуется построить механизм взаимодействия Haskell и Java программ, основывающийся на интерфейсах FFI и JNI, удовлетворяющий следующим нечетким требованиям:

1) высокоуровневость - облегчает использование, скрывая детали;

2) минимум кода на низкоуровневом языке (в нашем случае С) - упрощает поддержку и уменьшает вероятность ошибок;

3) безопасность типов - дает статические гарантии и позволяет обнаруживать целый класс ошибок на этапе компиляции.

4. Решение. Рассмотрим реализацию обращений к Java из Haskell программы. Коль скоро мы ориентируемся на решение на основе интерфейсов FFI и JNI, совершенно естественно первым шагом обеспечить FFI объявления для функций интерфейса JNI. Однако функции JNI нельзя просто импортировать, так как они не объявлены как статические функции, а являются членами структуры. Чтобы вызвать такую функцию, нужно разыменовать имеющийся указатель на эту структуру:

(*env)->FindClass(env, "java/lang/String").

Вызовы такого вида не поддерживаются FFI, поэтому нужно ввести промежуточную С функцию вида

jclass findClass(JNIEnv *env, const char *name) { return (*env)->FindClass(env,name);

>.

Теперь JNI функцию FindClass можно опосредованно импортировать с помощью следующего объявления:

foreign import ccall "findClass" findClass' :: JEnv -> CString -> 10 JClass.

Рассмотрим его. Здесь приведенная выше С функция findClass импортируется как Haskell функция findClass'. Мы объясним такое изменение имени ниже. Остановимся подробнее на типе этой функции. Интерфейс JNI, ориентируясь на языки C/C++, оперирует их типами. Все Java типы определяются в нем через С типы. Например,

typedef unsigned char jboolean; typedef unsigned short jchar; typedef short j short;

typedef _jobject *jobject; typedef _jclass *jclass; typedef _jthrowable *jthrowable.

Поэтому можно было бы в объявлениях импортированных функций также использовать С типы, точнее их Haskell представление:

foreign import ccall "findClass" findClass' :: Ptr () -> CString -> 10 (Ptr ()).

Однако, имея в своем распоряжении систему типов языка Haskell, будет неправильно не воспользоваться ее преимуществами. Ниже приводятся объявления типов, соответствующих типам Java. Мы опираемся на их отображение в С типы, описанное в jni.h:

type JInt = CLong type JLong = CLLong type JChar = CUShort

type JBoolean = CUChar jTrue =

type JustPtr = Ptr

newtype JEnv = JEnv

newtype JClass = JClass (JustPtr) deriving Show newtype JObject = JObject (JustPtr) deriving Show.

Типы, объявленные с помощью слова type, являются просто синонимами типов в правой части объявления. Такие типы, например JLong и CLLong, полностью взаимозаменяемы в Haskell программе. Слово newtype, напротив, вводит новый тип. Он структурно эквивалентен типу в правой части, но не взаимозаменяем с ним. Таким образом, типы JEnv, JClass, JMethod и т. д. не взаимозаменяемы, хотя все являются указателями. Это дает нам дополнительную защиту от ошибок, так как при попытке передать значение типа JMethod там, где ожидается JClass, программа не скомпили-руется.

Таким образом, для каждой JNI функции определяем вспомогательную функцию на языке С, которая делает указатель на структуру JNI параметром, и импортируем ее в Haskell программу средствами FFI. Все вспомогательные С функции включаются в единый файл, который представляет собой промежуточное низкоуровневое звено. Положительной чертой такого решения является то, что данное звено совершенно примитивно и не включает в себя никакой специальной логики. Это хорошо, потому что на низкоуровневый и небезопасный язык С желательно возлагать как можно меньшую часть решения. Коль скоро в Haskell программе уже есть примитивный фундамент в виде множества импортированных JNI функций, дальнейшее развитие можно вести исключительно средствами языка Haskell, что позволяет добиться высокого уровня абстракции и безопасности.

Построенное соответствие между функциями JNI и их Haskell прототипами будет являться фундаментом предлагаемого нами механизма. Однако один важный момент не вписывается в приведенную схему. Речь идет о вызове Java методов. Дело в том, что JNI функции для вызова методов имеют нефиксированное количество аргументов. Это совершенно естественно, так как единая JNI функция служит для вызова любых Java методов из некоторой группы. Более того, методы с одним и тем же именем могут быть определены в классе с разными типами и количеством параметров, т. е. методы могут быть «перегружены».

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

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

В дальнейшем будет предложено значительно более элегантное решение данной задачи, но пока сосредоточимся на другом. Допустим, уже есть набор FFI объявлений, который дает доступ к интерфейсу JNI: либо ко всему, либо к достаточному его подмножеству. Например, мы достигли этого предложенным только что несовершенным способом. Коль скоро JNI дает полный контроль над виртуальной машиной Java, этого уже достаточно для того, чтобы такой контроль имела Haskell программа. В следующем фрагменте определяется функция, возвращающая значение данного поля в данном классе, где поле и класс описываются своими именами:

getStaticLongField jenv className fieldName = do classNameC <- newCString className cl <- findClass' jenv classNameC fieldNameC <- newCString fieldName f <- getStaticFieldID' jenv cl fieldNameC "J" val <- getStaticLongField' jenv cl f return val.

Однако при таком методе работы с JNI возникает необходимость на каждом шаге передавать параметр JEnv, контроллировать возникновение Java исключений (в данном примере этого не делается), осуществлять преобразования между Haskell типами и С типами аргументов FFI объявлений, например String и CString, что является серьезным его недостатком.

Обратим внимание на свойства, общие для всех Java обращений. Они все зависят от окружения JEnv и могут вызывать Java исключения. Последовательность Java вызовов можно рассматривать как один составной Java вызов. Последнее свойство уже автоматически отражено в типе Haskell прототипов JNI функций, так как все они возвращают значение в монаде 10. Однако монада 10, конечно же, никоим образом не связана с первыми двумя свойствами.

Совершенно естественно в данном случае определить новую, более специфичную монаду Java вычислений, которая скроет детали, свойственные всем обращениям к Java. То есть обращения к Java будут возвращать свои значения не в монаде 10, а в специальной монаде.

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

data J а = J (JEnv -> 10 а).

Таким образом, уже в типе Java вычислений заключена их зависимость от JEnv. Это позволяет описывать вычисления в монаде J таким образом, что они не будут зависеть явным образом от JEnv. Параметр JEnv отражает лишь операционную сторону вычисления, в то время как остальные параметры, идентификаторы объектов, методов, имена классов, действительно, являются параметрами Java вычисления и составляют его семантическую сторону. Например, простейшие вычисления в монаде J, соответствующие функциям JNI, могут быть определены следующим образом:

getLongField :: JObject -> JField -> J JLong

getLongField cl f = J

findClass :: String -> J JClass

findClass str =

J (\jenv -> do { cstr <- newCString str; findClass' jenv cstr } ).

Здесь обе функции определяются через свои более низкоуровневые аналоги (имена со штрихом), FFI объявления JNI функций. Именно этим объясняется то, что мы помечали их имена штрихом.

Как видно, функция getLongField зависит только от идентификаторов объекта JObject и поля JField и имеет значение типа J JLong, т. е. возвращает JLong в монаде J.

Также можно видеть, что если функция getLongField просто выносит за скобки параметр JEnv, а в остальном полностью полагается на newObject ', то f indClass еще и осуществляет преобразование Haskell строки в строку языка С, так как именно С строки являются параметрами JNI функций. То есть дополнительный уровень абстракции, предоставляемый переходом от импортированных JNI функций к их аналогам в монаде, дает возможность реализовать дополнительные механизмы для того, чтобы сделать удобнее использование монады.

Однако вычисления в монаде J сами по себе, строго говоря, не приводят к какому-либо результату, так как значение любого такого вычисления есть функция от JEnv. Очевидно, не хватает функции computeJ, которая, принимая значение JEnv, инициировала бы фактические действия по вычислению результата и возвращала бы его. Тип computeJ должен быть:

computeJ : : JEnv -> J а -> 10 а,

т. е. для конкретного значения JEnv и Java вычисления computeJ возвращает результат этого вычисления, причем уже в монаде 10. Определим функцию computeJ:

computeJ jenv (J f) = do

r <- f jenv

JThrowable jthrw <- exceptionOccurred jenv

if

then ioError (userError $ "java exception " ++ (show jthrw)) else return r.

Функция computeJ, помимо того, что вызывает данное Java вычисление с данным значением JEnv, затем проверяет, не было ли сгенерировано исключение. Для этого служит JNI функция exceptionOcurred. И если исключение было, computeJ вызывает Haskell функцию ioError, цель которой - сообщать об ошибках в 10 вычислениях. В таком случае важно то, что вызов этой функции прекращает текущее вычисление. Дело в том, что если исключение возникает в виртуальной машине Java не в нормальном режиме, а во время JNI обращений, оно не будет сразу автоматически обработано, и исполнение кода за пределами Java (в рассматриваемом случае это Haskell программа) не будет прервано. Но если случилось исключение, дальнейшие вызовы виртуальной машины делать бессмысленно и небезопасно. То есть данное Java вычисление нужно прервать.

Определим теперь три монадические операции для J:

instance Monad J where

j1 j2 = J (\jenv -> (computeJ jenv jl) » (computeJ jenv j2)) (»=) j с =

J (\jenv -> (computeJ jenv j) »= (\v -> computeJ jenv (c v)) ) return v = J (\jenv -> return v).

Они совершенно тривиальны. Это обусловлено тем, что монада J является лишь надстройкой над монадой 10. Функция computeJ осуществляет своего рода преобразование из J в 10, и монадические операции, вызывая ее для каждого промежуточного Java вычисления, сводятся к монадическим операциям в 10. Следует отметить, что, вызывая computeJ для каждого внутреннего вычисления, мы гарантируем выполнение необходимых действий на каждом шаге составного Java вычисления. На данный момент такое необходимое действие только одно - обработка исключений, но потенциально computeJ может скрывать и другие операции и проверки.

Приведем пример использования монады J:

getStaticLongField className fieldName = do el <- findClass className f <- getStaticFieldID cl fieldName "J" val <- getStaticLongField cl f return val.

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

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

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

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

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

func True 4 = "True/4"

func False 'a' () True = "False/'а'/О/True".

Средством системы типов, которое поможет нам, будут классы типов. Применяемый метод не является новым, однако его использование в контексте FFI для обращения к импортированным функциям описывается впервые. В частности, он и другие подобные методы прекрасно освещены в работах О. Киселева, например [6]. Рассмотрим определения:

class Func a b where

fun : : String -> a -> b func : : a -> b func = fun ""

instance (Show a) => Func a String where fun pre a = pre ++ show a

instance (Func a String,Func b c) => Func a (b -> c) where fun pre a = fun (fun pre a ++ "/").

Здесь первое определение вводит класс Func следующим образом: пара типов а и b принадлежит классу Func (или, иначе, типы а и b находятся в отношении Func), если определена функция fun : : String -> а -> b, которая, принимая строку и значение типа а, возвращает значение типа Ь. Также в описываемом классе присутствует функция func, но определять ее для конкретных типов нет необходимости, так как она уже выражена через функцию fun.

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

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

В последнем определении заключена суть данного приема. В нем говорится о том, что если тип а находится в отношении с типом String, а типы b и с - в отношении друг с другом, то тип а - в отношении с типом функций b -> с. Иными словами, говоря в терминах функции func, а не fun, если есть функции func : : а -> String (начальный случай) и func : : b -> с (что тоже может быть начальным случаем, коль скоро с может быть String), то функция func : : а -> (b -> с) определена через функции func для пар типов a,String и b.c. Здесь прием заключается в том, что мы даем возможность системе типов для каждого допустимого типа параметра (в данном случае это типы из класса Show) вывести как функцию, имеющую своим значение строку (первое instance определение), так и функцию, имеющую своим значением другую функцию, также способную принимать произвольное число параметров.

Теперь, если мы напишем func True 4 (3,'а') False :: String, то система типов выведет следующие типы для подвыражений:

func True 4 (3,'а') :: Bool -> String

func True 4 ::

func True :: Int ->

func :: Bool -> ( Int -> ( (Int.Char) -> ( Bool -> String ))).

Искомая функция построена. Отметим, что принципиальное отличие такого способа определять функции с переменным числом аргументов от того, как это делается в языке С и, в частности, в интерфейсе JNI, заключается в том, что здесь остаются статические гарантии системы типов. Если тип не является членом класса Show, попытка вызвать для него func приведет к ошибке на этапе компиляции. Более того,

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

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

jobject NewObject(JNIEnv *env, jclass clazz,jmethodID methodID, ...);

jfloat CallFloatMethod(JNIEnv *env, jobject obj,jmethodID methodID, ...);

jint CallNonvirtualIntMethod(JNIEnv *env,

jobject obj,jclass clazz, jmethodID methodID, ...);

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

К счастью, каждая из этих JNI функций также существует в варианте, принимающем аргументы в виде массива значений типа j value, например

jchar

CallCharMethodA (JNIEnv *env, jobject obj .jmethodID methodID, j value *args) ;

Тип jvalue определен в jni.h как тип, который может содержать любой Java тип. Для такой функции значительно проще построить Haskell эквивалент, подобный функции func. Наше решение выглядит следующим образом:

foreign import ccall "callVoidMethodA" callVoidMethodA' :: JEnv -> JObject -> JMethod -> Ptr JValue -> 10

class JArgs a b where

inv :: [JValue] -> JEnv -> JObject -> JMethod -> a -> b callVoidMethod : : JEnv -> JObject -> JMethod -> a -> b callVoidMethod = inv []

instance (JType a) => JArgs a (10 ()) where inv pre jenv obj m a = do

let args = pre ++ [pack a] ptr <- mallocArray $ length args pokeArray ptr args callVoidMethodA' jenv obj m ptr

instance (JType a, JArgs b c) => JArgs a (b -> c) where

inv pre jenv obj m a = inv (pre ++ [pack a]) jenv obj m.

Если в случае функции func накапливалась строка, то здесь накапливается список аргументов. В последний момент, когда рекурсия заканчивается (первое instance определение), накопленный список преобразуется в С массив и вызывается JNI функция. В примере с func ограничением на тип была принадлежность Show. Здесь же тип

должен принадлежать классу JType, в котором определена одна операция упаковки значения в j value.

Теперь можно вызывать Java методы следующим образом:

obj <- newObject cl init

m <- getMethodID cl "callback" "(IILjava/lang/Object;II)V"

J ( \ jenv -> callVoidMethod jenv obj m argl arg2 obj arg2 argl).

Единственным неудобством остается то, что в рассматриваемом случае не удается заранее сделать вызов вычислением в монаде J и приходится переводить его в монаду при использовании. Это связано с тем, что прием, показанный на примере func, не работает для функций, скрытых в каком-то типе данных (здесь случай J). Однако преобразование вызова метода в монадическое значение занимает лишь несколько символов и является достаточно низкой ценой за возможность вызывать любые Java методы как Haskell функции.

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

Summary

Elovkov D. D. A high-level Haskell Java interface.

The Haskell type system is employed for building a high-level, type-safe, monadic interface to Java virtual machine for Haskell programs. A type-class based method for calling functions with a variable number of arguments in a natural way is provided.

Литература

1. Benton TV., Kennedy A. Interlanguage working without tears: Blending SML with Java // ICFP '99: Proc. of the fourth ACM SIGPLAN Intern, conference on Functional programming. 1999. P. 126-137.

2. Reinke C. Towards a Haskell/Java connection // IFL '98: Selected Papers from the 10th Intern. Workshop on Implementation and Application of Functional Languages. 1999. P. 200-215.

3. Meijer E., Finne S. Lambada, Haskell as a better Java // Electronic Notes in Theoretical Computer Science. 2001. Vol. 41, N 1. 20 p.

4. Sun Microsystems, Inc. Java Native Interface Specification // http://java.sun.com/j2se/ 1.4.2/docs/guide/jni/spec/jniT0C.html. 2003. 50 p.

5. Chakravarty M. The Haskell 98 Foreign Function Interface 1.0 // http://www.cse.unsw. edu.au/ chak/haskell/ffi/ffi.pdf. 2002. 35 p.

6. Kiselyov O., Larnmel R., Schupke K. Strongly typed heterogeneous collections // Haskell '04: Proc. of the ACM SIGPLAN workshop on Haskell. 2004. P. 96-107.

Статья рекомендована к печати членом редколлегии проф. А. Н. Тереховым.

Статья принята к печати 22 февраля 2007 г.