пятница, 4 октября 2013 г.

Мысли по поводу локализации строк и кодогенерации

Кратенько, в стиле Александра Люлина. Два момента:

а) мне понравилась идея Александра с локализацией, типа
type TLocString = record Key, Value: string; end;
и далее:
var SAskDelete = (Key: 'AskDelete'; Value: 'Удалить?');
и далее:
  if Ask(SAskDelete.Value) then
С одной стороны это удобно, я прям в коде вижу:
  - и переменную
  - и Key
  - и Value по-умолчанию
Но:
  - не очень удобно объявлять такие переменные
  - не очень удобно (и не наглядно) писать ".Value"
Ссылка на идею: (18-ть лет с Delphi - Что ещё умеет наш фреймворк?)

б) у нас используется такой вариант: все строковые переменные объявлены как обычно:
var SAskDelete: string;
инициализируются они (и реагируют на смену языка) в спец. примерно так:
procedure InitResStrings;
begin
  SAskDelete := ResGet('AskDelete', 'Удалить?');
end;
и конечно эта процедура подписывается на обновления
initialization
  ResReg(@InitResStrings);


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

А пока у меня вот какие мысли в голове. Во-первых, мы наконец-то перешли с Delphi 7 на Delphi 2010 (ну а значит и на новые версии делфей перебраться не проблема). Поэтому можем использовать все нововведения. Я думал, может как-то прикрутить атрибуты. Например так:
type
  TMyForm = class(TBaseForm)
  public
   [ResString('AskSelete', 'Удалить?')]
   SAskSelete: string;
  end;

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

В общем я по разному крутил. В идеале я хочу получить следующее:
- переменная (или функция, или свойство), которая возвращает ресурсную (локализуемую) строку должна быть только для чтения (записывать могут только спец. методы в момент инициализации/смены языка)
- обращение к переменной в коде должно быть прозрачным
- желательна привязка к контексту (у нас очень много форм, есть вероятность что в двух разных модулях будет переменная с одним и тем же именем)
- желательно ограничить область видимости переменной, например, как поле класса - доступ к переменной возможен только от имени экземпляра класса и при смене языка не надо инициализировать ВСЕ переменные, а только у созданных экземпляров
<эти два пункта у нас реализованы, но с ними можно поспорить>
- возможность по исходнику (либо из приложения в RunTime) сгенерировать файл локализации с языком по-умолчанию (для последующего его перевода).

И есть ещё момент, который может быть не принципиальным, но мы его реализуем. Склонение числительных. Например, в зависимости от кол-ва выделенных строк можно сформировать строку:
0 = 'Нет выделенных строк'
1 = 'Выделена 1 строка'
2 = 'Выделено 2 строки'
5 = 'Выделено 5 строк'
и т.п.

Причём в русском языке есть три формы (строк, строка, строки), а в английском - их две (row, rows).
Вот тут мы в коде пишем что-то типа такого:
  if Ask(SSelectedRows[Cnt] + '. ' + SAskDelete) then
Т.е. результат строки зависит от входного числового параметра.
(А есть ещё денежные суммы с рублями и копейками /долларами центами).


Сейчас у нас это всё реализовано тупо, выглядит примерно так:
[Russian]
SAskDelete = 'Удалить?'
SSelectedRows = ('Выделено %d строк', 'Выделена %d строка', 'Выделено %d строк');
[English]
SAskDelete = 'Delete?'
SSelectedRows = ('Selected %d rows', 'Selected %d row', 'Selected %d rows');
и далее генерится код. И есть функция, которая по числительному определяет форму:
- для русского языка сложнее: (0, 5, 11, 100, нет, много) строк, (1, 21) строка, (2, 3) строки;
- для английского проще: 0 rows, 1 row, >1 rows.

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

19 коммент.:

Всеволод Леонов комментирует...

>>type
>> TMyForm = class(TBaseForm)
>> public
>> [ResString('AskSelete', 'Удалить?')]
>> SAskSelete: string;
>> end;

Если написали "Удаить" вместо "Удалить".
Поняли потом, заказчик нашел.
Дальше как?

Извините, синтаксис конструкций яп проверяет компилятор.
Кто проверяет синтаксис естественного языка в коде?

Вообще, НЕ дело программиста выдумывать тексты сообщений.
И тексты ниже это явно доказывают.
>>SAskDelete = 'Удалить?'
>>SSelectedRows = ('Выделено %d строк', 'Выделена %d строка', 'Выделено %d строк');

Николай Зверев комментирует...

> Если написали "Удаить" вместо "Удалить".
> Поняли потом, заказчик нашел.
> Дальше как?
а) можно как обычно, fix and build. И не говорите, что это плохо, resourcestring не я придумал. И dfm, кстати, тоже хранит Caption'ы и прочие строки. (Delphi way?)
б) в контексте разговора о локализации - достаточно поправить в локализовочном файле. И приложение пересобирать не надо.

> Вообще, НЕ дело программиста выдумывать тексты сообщений.
а) с одной стороны да, согласен. Если бы все использовали UML, то варианты текстов логичнее хранить там же, рисуя варианты ветвления.
б) с другой стороны, Всеволод, давайте не будем идиализировать. Программист реализует бизнес-логику, кому, как не программисту, сформулировать сообщение пользователю в конкретной точке программы? Проектировщику? Но у нас, к сожалению, не используется UML (как у Александра Люлина). Однако строки хранятся в отдельном файле, имя которого совпадает с pas-файлом.

Анонимный комментирует...

Простейшее и общепринятое решение удовлетворяет приведенным требованиям. А именно:
Берется файл со строками вида
SAskDelete = 'Удалить?'
В коде пишется что-то вроде:
Ask(LStrs.get('SAskDelete'));
Вот этот LStrs и лезет в нужный файл за указанной строкой.
Можно даже так:
Ask(LStrs['SAskDelete']);

- записать туда что-то не получится
- обращение вполне прозрачно. Нормальные IDE могут даже показывать 'Удалить?' вместо LStrs['SAskDelete'].
- привязка к контексту возможна. Хоть для каждой формы свой файл.
- ограничить область видимости переменной LStr можно как угодно
- даже по исходнику можно сгенерировать файл локализации с языком по-умолчанию, если условиться в качестве идентификатора строки брать её значение по умолчанию
Склонение тоже можно прикрутить. Лучше универсальный склонятор написать. Тогда можно будет писать как-то так:
"Выделено %d %строка%"

Изменение всяких caption и text в вцловских компонентах также не составит труда.

> Кто проверяет синтаксис естественного языка в коде?
IDE. Давно уже. Синтаксис ЯП, кстати, тоже.

Александр Люлин комментирует...

А мне нравится идея с атрибутами. Мне вообще атрибуты нравятся.

Николай Зверев комментирует...

Анонимный
> Простейшее и общепринятое решение..
Замечательное решение! Без сарказма. Но лично мне в нём не нравится то, что я могу допустить опечатку внутри кавычек и написать:
Ask(LStrs.get('SAskDetele'));
и компилятор это скушает. Конечно, можно сказать так: в этом случае надо генерить Exception в RunTime, который должен словить тестировщик. Но это не в моём вкусе.

Александр
Мне тоже нравится. Но пока получается не очень практично...

Всем
Кстати вот парочка ссылок, если кто-то задаётся вопросами локализации, будет полезно:
WebDelphi - 23 решения для локализации и интернационализации приложений
GunSmoker - Локализация проектов Delphi - родной способ (ITE - Integrated Translation Environment)

Aleksey Timohin комментирует...

> Но:
> - не очень удобно объявлять такие переменные
> - не очень удобно (и не наглядно) писать ".Value"

Это-то как раз легко обходится с помощью своего инициализатор и operator overload.
Пример тут: https://gist.github.com/tdelphi/6833017

Николай Зверев комментирует...

Кстати про перегрузку операторов я запамятовал. Спасибо за мыслю :)

Torbins комментирует...

"Замечательное решение! Без сарказма. Но лично мне в нём не нравится то, что я могу допустить опечатку внутри кавычек и написать"
Ну так используйте enum-ы:
type
TStringKey = (..., sAskDetele, ...); // Defines enumeration range
var
LStrs: array[TStringKey] of string = (..., 'Удалить?', ...);

Николай Зверев комментирует...

Вот я рад, что про перечислители тоже вспомнили. Но и у них в данном случае есть минус. Когда строк очень много, то легко промахнуться, инициализируя массив в var секции... ну и с числовыми константами мне не всё нравится...

//Надо всё-таки мне не полениться, да и состряпать заметку о плюсах и минусах разных подходов…

Torbins комментирует...

"Когда строк очень много, то легко промахнуться, инициализируя массив в var секции..."
Думаю, вообще не стоит его руками трогать. Пусть он живет в отдельном юните, который автоматически будет генерировать специальный скрипт на основе, к примеру, таблички в БД.

Николай Зверев комментирует...

Вот-вот. Снова возвращаемся к кодогенерации...

На самом деле я рассматриваю два варианта:
1) без кодогенерации, но чтобы было максимально просто и удобно объявлять строки и использовать их. Думал, что на атрибутах можно будет получить желаемое. Пока не получилось.
2) с кодогенераций - реализация уже не имеет принципиального значения, главное чтобы из кода было удобно использовать.

Николай Зверев комментирует...

вот хочу что-то типа такого:
http://habrahabr.ru/post/190556/

сейчас у меня похоже, но без dll-ки..

Torbins комментирует...

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

Анонимный комментирует...

Можно так - создать отдельный модуль с константами:
const
msg_NoErr = 0;
....
msg_err_UnableToInitServer = 1000;
...

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

Описать простую функцию:
function GetLocSrt(TextCode: integer; Lng: string = "ru_RU"): string
begin
//где можно описать любой алгоритм получения строки под нужный язык.
//можно в лоб, описать case, но для каждого языка будет свой блок case, что не очень удобно.
case TextCode of
msg_NoErr: result := 'Все ок';
msg_err_UnableToInitServer: result := 'что-то не так';
end;
// Поэтому куда лучше, получать данные из файла или какого-нибудь класса, который можно предварительно инициализировать данными из файла.
// В общем как угодно.
end

Теперь в коде можно получить текст сообщения, просто, наглядно и универсально:
GetLocSrt(msg_NoErr)
Все прелести компилятора в нашем распоряжении, и полный список сообщений "msg_" и отдельно список ошибок "msg_err_" и проверка синтаксиса.

Анонимный комментирует...

Отредактировал:

Можно так - создать отдельный модуль с константами:
const
msg_NoErr = 0;
....
msg_err_UnableToInitServer = 1000;
...

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

function GetLocSrt(TextCode: integer; Lng: string = "ru_RU"): string
begin
//где можно описать любой алгоритм получения строки под нужный язык.
//хоть в лоб, описать case, но для каждого языка будет свой блок case, что не очень удобно.
case TextCode of
msg_NoErr: result := 'Все ок';
msg_err_UnableToInitServer: result := 'что-то не так';
end;
// Поэтому, куда лучше, получать данные из файла или какого-нибудь объекта, предварительно инициализированного данными из файла.
// В общем как угодно.
end

Теперь, в коде можно получить текст сообщения, просто, наглядно и универсально:
GetLocSrt(msg_NoErr)
Все прелести компилятора в нашем распоряжении, и полный список сообщений "msg_" и отдельно список ошибок "msg_err_" и проверка синтаксиса.

Николай Зверев комментирует...

Числовые константы в коде - да. Но тогда в файле ресурсов будут "магические" числа.

Я же хочу, чтобы и в коде я ссылался на строку через некую мнемоническую ссылку, и в файле ресурсов строка описывалась такой же ссылкой. Это чисто для удобства и наглядности не только компилируемого кода, но и самого файла (который можно отдать переводчику, к примеру).

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

Анонимный комментирует...

Ну так не проблема :)
msg_NoErr = 0 можно заменить на msg_NoErr = "msg_NoErr".
Или как-то так: msg_NoErr = "0:msg_NoErr", чтобы код тоже хранить.
В файле можно прописать соответствия на всю строку целиком "0:msg_NoErr" или на часть строки "msg_NoErr", если нет потребности менять числовой код сообщения из файла.

Fr0sT комментирует...

GetText из Unix среды использует подход текстовых ключей. Там идентификатор - фраза на базовом языке (обычно английский, но м.б. и другой), ему сопоставляются переводы. Если перевода не найдено, возвращается строка-ключ.

MsgBox(Transl('Hello world'));

и никто не мешает вынести их в константы

const
SHello = 'Hello world';
MsgBox(Transl(SHello));

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

А вот Android пошел по пути строковых идентификаторов

MsgBox(Transl('str_hello_world'))

при этом ищется строка в текущем переводе, а если ее нет - то в дефолтном.

Что касается множественных чисел (plurals), это должно реализовываться внутри функции перевода на основе специальных меток, типа подстановки Format

const
SDelQuery = 'Hello, %s! Really delete {plural(record|,s,s)}? You'll have {plural(record|,s,s)} left.';

MsgBox(TranslPlural(SDelQuery, [SelRecs, Count-SelRecs], [UserName]));
// TranslPlural сначала запоминает исходную строку в качестве ID, потом получает её перевод, затем выполняет поиск подстановок множ. форм, заменяет их на основе параметров на соотв. форму, а затем уже переправляет в Format для подстановки остальных значений.

А перевод на русский тогда будет выглядеть так
SDelQuery = 'Привет, %s! Точно удалить {plural(запис|ь,и,ей)}? Останется {plural(запис|ь,и,ей)}.';

Правила же склонения множественных чисел стандартизованы и доступны открыто.

Fr0sT комментирует...

Причем правила склонения включают также алгоритмическое выражение по преобразованию любого числа в одну из форм.
К примеру, в английском plural(21) <> plural(1), но = plural(11), тогда как в русском plural(21) = plural(1) и <> plural(11).
Обвязка из доков Андроид
https://developer.android.com/guide/topics/resources/string-resource.html#Plurals
Правда, они задают целую строку для каждой формы, что вроде бы расточительно, но более универсально.

Вот стандарт на множ. формы от GetText
http://docs.translatehouse.org/projects/localization-guide/en/latest/l10n/pluralforms.html
Здесь они просто задают кол-во форм, а формула определяет маппинг числа на одну из них.
http://www.gnu.org/software/gettext/manual/gettext.html#Plural-forms

Отправить комментарий

.

.