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

В исследовании приняли участие nrow(dataset) человек. Среди них sum(dataset$Пол == "Мужчина") мужчин и sum(dataset$Пол == "Женщина") женщин. Средний возраст респондентов составил round(mean(dataset$Возраст), 1) лет (в диапазоне от min(dataset$Возраст) до max(dataset$Возраст) лет).

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

В английском языке правила образования множественного числа довольно просты: если числительное больше единицы (\(n > 1\)), то употребляется множественное число (в большинстве случаев просто добавляется окончание «s»). В русском языке употребление множественного числа с числительными гораздо сложнее. Например:

  • 1, 21, 151 год/день/респондент — употребляется форма единственного числа
  • 2, 3, 4, 22, 152 года/дней/респондента — употребляется форма множественного числа;
  • 5, 11, 18, 26, 158 лет/дней/респондентов — употребляется форма множественного числа

Обратите внимание, что единственное число употребляется так же в тех случаях, когда число оканчивается на единицу, а множественное число может иметь две формы (года, лет). Также стоит отметить, что не все существительные имеют две формы множественного числа, например: 2 испытуемых и 5 испытуемых.

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

  • если число оканчивается на 1, но при этом это число не 11, то употребляется единственное число;
  • если число оканчивается на 2, 3, 4, но при этом это число не 12, 13, 14, то употребляется первая форма множественного числа;
  • во всех остальных случаях употребляется множественно число.

Итак, выразим сформулированные правила на языке R и поможет нам в этом операция деления с остатком. Это позволит нам работать со сколь угодно большими числами, отсекая не значимую для определения формы множественного числа часть. Для этого мы будем делить исходное число на 10 и 100 и смотреть остаток от деления. Предложенный ниже вариант используется при определения множественного числа в русском языке в утилите gettext, предназначенной для перевода ПО на разные языки.

plural_form <- function(n) {
    if (n %% 10 == 1 && n %% 100 != 11)
        plural <- 1
    else if (n %% 10 >= 2 && n %% 10 <= 4 && (n %% 100 < 10 || n %% 100 >= 20))
        plural <- 2
    else
        plural <- 3
    return(plural)
}

Итак, в первом условии мы проверяем следующее правило: если остаток от деления на 10 равен 1 и остаток от деления на 100 не равен 11, то мы используем единственное число. Во втором условии: если остаток от деления больше 2 и меньше или равен 4, и при этом это число меньше 10 или больше 20, то используем первую форму множественного числа. Во всех остальных случаях применяем вторую форму множественного числа.

Проверим, как работаем наша функция (для наглядности я добавлю исходные числа к полученному результату):

x <- 1:30
res <- sapply(x, plural_form)
names(res) <- x
res
#>  1  2  3  4  5  6  7  8  9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 
#>  1  2  2  2  3  3  3  3  3  3  3  3  3  3  3  3  3  3  3  3  1  2  2  2  3  3  3  3  3  3

Наша функция возвращает числа 1, 2 или 3, но при работе с текстом это не совсем удобно, поэтому немного доработаем нашу функцию plural_form():

plural_form <- function(n, msg1, msg2, msg3 = NULL) {
    # Немного проверок «от дурака»
    stopifnot(is.numeric(n))
    stopifnot(is.character(msg1))
    stopifnot(is.character(msg2))
    # Округляем число в меньшую сторону
    n <- trunc(n)
    # Правила определения формы множественного числа
    if (n %% 10 == 1 && n %% 100 != 11)
        plural <- msg1
    else if (n %% 10 >= 2 && n %% 10 <= 4 && (n %% 100 < 10 || n %% 100 >= 20))
        plural <- msg2
    else if (is.null(msg3))
        plural <- msg2
    else
        plural <- msg3
    return(plural)
}

Теперь наша функция возвращает один из трёх вариантов существительного, в зависимости от того, какое число мы ввели в качестве первого аргумента. msg3 = NULL был добавлен для случая, когда существительное имеет только одну форму множественного числа.

Проверим нашу функцию на конкретных примерах:

x <- c(51, 115, 211, 1542)
res <- sapply(x, plural_form, "мужчина", "мужчины", "мужчин")
names(res) <- x
res
#>        51       115       211      1542 
#> "мужчина"  "мужчин"  "мужчин" "мужчины"

Теперь проверим на примере существительного, имеющего только одну форму множественного числа:

x <- c(51, 115, 211, 1542)
res <- sapply(x, plural_form, "испытуемый", "испытуемых")
names(res) <- x
res
#>           51          115          211         1542 
#> "испытуемый" "испытуемых" "испытуемых" "испытуемых"

Всё работает корректно. Пришло время заняться нашим отрывком текста. Первоначальный вариант отрывка будет выглядеть следующим образом:

В исследовании приняли участие nrow(dataset) plural_form(nrow(dataset), "человек", "человека", "человек") Среди них sum(dataset$Пол == "Мужчина") plural_form(sum(dataset$Пол == "Мужчина"), "мужчина", "мужчины", "мужчин") и sum(dataset$Пол == "Женщина") plural_form(sum(dataset$Пол == "Женщина"), "женщина", "женщины", "женщин") Средний возраст респондентов составил round(mean(dataset$Возраст), 1) plural_form(mean(dataset$Возраст), "год", "года", "лет") (в диапазоне от min(dataset$Возраст) до max(dataset$Возраст) plural_form(max(dataset$Возраст), "года", "лет", "лет")).

Также можно воспользоваться функцией sprintf(). В некоторым случаях это может быть более удобно.

sprintf(
    "В исследовании приняли участие %d %s. Среди них %d %s и %d %s. Средний возраст респондентов составил %1.1f %s (в диапазоне от %d до %d %s).",
    nrow(dataset), plural_form(nrow(dataset), "человек", "человека", "человек"),
    sum(dataset$Пол == "Мужчина"), plural_form(sum(dataset$Пол == "Мужчина"), "мужчина", "мужчины", "мужчин"),
    sum(dataset$Пол == "Женщина"),  plural_form(sum(dataset$Пол == "Женщина"), "женщина", "женщины", "женщин"),
    mean(dataset$Возраст), plural_form(mean(dataset$Возраст), "год", "года", "лет"),
    min(dataset$Возраст), max(dataset$Возраст), plural_form(max(dataset$Возраст), "года", "лет", "лет"))
#> [1] "В исследовании приняли участие 101 человек. Среди них 40 мужчин и 61 женщина. Средний возраст респондентов составил 33.9 года (в диапазоне от 13 до 58 лет)."

%d, %s и %f используются для подстановки результата выражений. Подробнее с этими понятиями можно ознакомится в справке к функции sprintf().

Получилось немного громоздко, но зато мы теперь можем быть уверены в том, что наш текст будет согласен с динамическим контентом.