Склонение слов и инициалов в Delphi/Freepascal
Добрый, предновогодний день всем! В этой статье я бы хотел рассказать, как мне пришлось вернуться в legacy-проект на паскале, причем буквально перед тем, как навсегда распрощаться и с ним, и с лазарусом, и с отсутствием темной темы из коробки.
В прошлый раз я объяснял, что не являюсь программистом по роду профессиональной деятельности, но использую любимое хобби для автоматизации всего, что попадается под руку в работе юриста. Я уверен, что 90% всей юридической волокиты может быть успешно автоматизировано: ведение разнообразных баз и карточек, составление документов по шаблонам, контроль сроков выполнения задач, использование любых вспомогательных сервисов, уже имеющих свои api, для прикручивания автоматизации на конкретном рабочем месте и т.д. К этому нужно стремиться, чтобы по заветам Айзека Азимова высвободить время юриста для реализации основной задачи - размышлять над условиями договора и разводить демагогию в суде.
Так вот, много лет назад я сделал очень большой проект для облегчения своей офисной работы. Он собирал все данные по товарным знакам и патентам (а их несколько сотен), контролировал сроки оплаты патентных пошлин, формировал платежные поручения, договоры, заявления, и, разумеется, выдавал разнообразные отчеты. Собственно, почему в прошедшем времени? Проект вполне рабочий. Вот только разработан был по всем возможным антипаттернам, со всеми велосипедами и костылями, какие только обнаружили на Земле. Возвращаться в этот ролтон (или доширак) код, чтобы его отрефакторить, ой, как не хотелось, ведь здесь идеально подходит мем "А давайте всё перепишем на..."
Но, к сожалению, переписывать все заново на новом языке для новой платформы или даже нескольких - дело долгое и затягивающее, а документы штамповать нужно здесь и сейчас. Поэтому к паскалю мне приходится время от времени возвращаться.
В один из прекрасных дней весь проект был пересобран на 64-разрядной платформе, и, к удивлению моему, упала самая любимая часть: генерация договоров и заявлений с полной автоматизацией всей грамматики и морфологии - склонение инициалов, должностей и прочих слов по нужным падежам, а также учет единственного/множественного числа. Все дело в том, что старинная дельфийская и проприетарная библиотека padegUC, перестала быть бесплатной в своей 64-битной версии.
Предложение разработчика использовать 32-битную сборку мне совсем не подошло, и к тому моменту я уже нашел несколько альтернатив. Все они существуют как библиотеки для других языков или как самостоятельные сервисы.
Чтобы хоть как-то приукрасить ту мешанину паскалевского кода, я решил восстановить упавшую фичу по всем модным стандартам разработки. А значит, мне первым делом нужен был интерфейс:
IInitialsMorpher = interface
function GetInitials(Initials: string): CasesResponse;
function GetWordsCase(Words: string): CasesResponse;
function GetGenderAndInitials(Initials: string; var Gender: TGender): CasesResponse;
end;
Для универсальности интерфейс объявляет три функции, возвращающих примерно одно и то же, но по-разному: как инициалы (т.е. с заглавными буквами), как обычные слова, что подходит, например, для склонения должности или наименования объекта, и инициалы с дополнительным определением рода, чтобы выбирать между который и которая и т.п. Структура CasesResponse представляет собой обычный строковый массив с названием падежа в качестве индекса, а Gender - литеральное перечисление:
TWordCase = (Nominative, Gentitive, Dative, Accusative, Instrumental, Prepositional);
TGender = (Male, Female, UnrecognizedGender);
CasesResponse = array[TWordCase] of string;
Для реализации интерфейса я рассматривал нескольких кандидатов. В итоге остановился на следующих трех, и сразу укажу на достоинства и недостатки:
Сервис | Достоинства | Недостатки |
- Отличная документация и идеальные примеры - Корректная работа с инициалами с учетом национальных особенностей народов России - Определение рода | - Склонение ФИО - платный сервис - не меняет число | |
- MIT лицензия, в оригинале существует как библиотека python - Мощнейший морфологический анализатор на словарях Corpora (правильно разберет любой новояз) - Масса информации на выходе, в т.ч. род, число, разбор на морфемы и лексемы, словарное ядро корня и т.д. | - работает только в отношении отдельного слова, не словосочетания, т.е. не учитывает контекст | |
- MIT лицензия, в оригинале существует как библиотека php - Корректная работа с инициалами - Определение рода | - для меня нет, но для некоторых может быть важен беспорядок слов в запросе |
Я поэкспериментировал со всеми этими сервисами, но для своих целей остановился на использовании Morphos, а подстановку единственного/множественного числа в нескольких участках текстов реализовал простыми строковыми шаблонами.
Поскольку все сервисы представляют собой rest api, а выдача результата чаще всего происходит в формате json, функцию, взаимодействующую с сервером по http, сразу выносим в общие утилиты:
generic function JSONfromRestUri<T>(Uri: string): T;
var
HTTPSender: THTTPSend;
JSONStreamer: TJSONDeStreamer;
Json: TJSONObject;
begin
HTTPSender := THTTPSend.Create;
JSONStreamer := TJSONDeStreamer.Create(nil);
HTTPSender.Clear;
Result := T.Create;
if not HTTPSender.HTTPMethod('GET', Uri) then
raise EInOutError.Create(RESTOUT_ERROR);
JSON := GetJSON(HTTPSender.Document) as TJSONObject;
JSONStreamer.JSONToObject(JSON, Result);
FreeAndNil(JSON);
FreeAndNil(JSONStreamer);
FreeAndNil(HTTPSender);
end;
Функция использует библиотеки Freepascal Synapse и Fpjson, поэтому соответствующие модули (httpsend, fpjson, fpjsonrtti) должны быть установлены и включены в uses. Десериализация ответа сервера из json в объект происходит с использованием rtti, т.е. все свойства такого объекта, во-первых, должны объявляться в секции published, во-вторых, иметь не самые сложные типы (примитивы, массивы, списки), и в-третьих, называться идентично полям в json. Скорее всего, декораторы и аннотация в стиле @SerializedName здесь не завезена, ну или я не нашел.
Строка запроса для сервиса Morphos выглядит следующим образом:
MORPHOS_URL = 'http://morphos.io/api/inflect-name?name=%s&_format=json';
Ответ сервера представляет собой json с массивом из шести строк, внутри - слово/словосочетание из запроса, склоненное по всем падежам русского языка в стандартном порядке ИРВДТП, само слово из запроса в поле name и определенный род в поле gender:
{
"name": "Иванов Иван",
"cases": [
"Иванов Иван",
"Иванова Ивана",
"Иванову Ивану",
"Иванова Ивана",
"Ивановым Иваном",
"об Иванове Иване"
],
"gender": "m"
}
Приступим же к конкретной реализации интерфейса IInitialsMorpher для сервиса Morpher. Сначала объявим класс, в который данные из json будут автоматически десериализоваться библиотеками Fpjson (в документации говорится, что классы должны быть потомками TPersistent):
TMorphosResponse = class(TPersistent)
private
fCases: TStrings;
fGender: string;
fName: string;
public
constructor Create;
destructor Destroy; override;
published
property name: string read fName write fName;
property cases: TStrings read fCases write fCases;
property gender: string read fGender write fGender;
end;
Добавляем класс реализации интерфейса:
TMorphosImpl = class(TInterfacedObject, IInitialsMorpher)
public
function GetInitials (Initials: string): CasesResponse;
function GetWordsCase (Words: string): CasesResponse;
function GetGenderAndInitials(Initials: string; var Gender: TGender): CasesResponse;
end;
Основной алгоритм сосредоточен в одной функции, а две другие лишь паразитируют на ней:
function TMorphosImpl.GetGenderAndInitials(Initials: string; var Gender: TGender): CasesResponse;
var
inf: TWordCase;
i: integer = 0;
response: TMorphosResponse;
begin
response := specialize JSONfromRestUri<TMorphosResponse>
(Replacetext(Format(MORPHOS_URL, [Initials]), ' ', '+'));
for inf in TWordCase do begin
Result[inf] := response.cases[i];
inc(i);
end;
case response.gender of
'm': Gender := Male;
'f': Gender := Female;
else Gender := UnrecognizedGender;
end;
FreeAndNil(response);
end;
Это функция, возвращающая массив склоненных ФИО и род в отдельном свойстве. Не трудно догадаться, что для реализации первой функции (только ФИО) нужно вызвать её же, но отбросить род, а для реализации другой - результат первой функции привести к нижнему регистру:
function TMorphosImpl.GetInitials(Initials: string): CasesResponse;
var
MokeGender: TGender = UnrecognizedGender;
begin
Result := GetGenderAndInitials(Initials, MokeGender);
end;
function TMorphosImpl.GetWordsCase(Words: string): CasesResponse;
var
inf: TWordCase;
begin
Result := GetInitials(Words);
for inf in TWordCase do
Result[inf] := UTF8LowerString(Result[inf]);
end;
И на этом можно было остановиться, если бы мне не хотелось еще большего. Как я уже сказал, изначально тестировались три разных сервиса. Логично оставить возможность менять их в зависимости от погоды или капризов разработчиков. Инверсию зависимости в паскале можно делать все теми же способами, что и в других языках, ну может, чуть дольше и без кучи готовых DI-фреймворков - с помощью шаблона "стратегия" или фабрики.
Для себя я выбрал полу-фабрику полу-фасад, чтобы уже в этом классе добавить еще одну фичу (на самом деле не так уж и нужную, но вдруг, интернет все-таки кончится?) - кеширование результатов на диск для сокращения накладных расходов на http-запросы. И если для Morphos это не особо актуально, то для DaData даже очень, поскольку каждый запрос стоит 10 копеек.
Подробное описание реализации кэша я здесь не стану приводить, поскольку оно выходит за рамки темы. Скажу лишь, что для себя выбрал хранение значений в json файле и обработку их в памяти с помощью НashMap-типа из Generics.Collections - библиотеки, портированной в freepascal из delphi. Ключом при этом являются сами инициалы, и потенциально использование хэш-массива даст оптимальную скорость поиска на больших данных (от которых по идее нужно держаться подальше с помощью разных вариантов авто очистки кэша, ведь иногда и http-запрос может отработать быстрее, чем загрузка огромного словаря в память из файла и дальнейший поиск).
В конечном итоге использование имплементации Morphos выглядит так:
Morpher := TMorphFabric.Create(MORPHOS);
//...
response := Morpher.GetInitials(Text)
StringList.AddStrings(response);
Для всех заинтересовавшихся свою получившуюся библиотеку с тестовым оконным приложением для win я буду хранить в открытом репозитории. Дорабатывать, скорее всего, я уже в ней ничего не стану, поскольку сейчас погрузился в открытое море мобильной разработки (kotlin) и python для реализации того самого мема "переписать всё на..."
Всех с наступающим Новым 2020+'1' годом!