О построении корректных текстовых сообщений, содержащих числа
Нередко программисту требуется создать текстовое сообщение, адресованное пользователю, содержащее информацию о количестве чего-либо. Например, о количестве оставшихся дней, в течение которых тот может пользоваться программой бесплатно.
В этом случае в тексте после числа оставшихся дней нужно поставить слово "день" в корректной форме, а именно, в требуемом числе и требуемом падеже. Например: "21 день", "35 дней", "44 дня". В этой статье мы поговорим о том, как верным способом сформировать требуемую форму существительного.
На первый взгляд (да и на любой другой) задача совершенно пустяковая, достойная обсуждения лишь на одном из первых уроков программирования в средних классах школы. Действительно, решается она достаточно легко. Даже новичок в программировании справится с ней за несколько минут. Но дело в том, что мы введём в условие задачи дополнительные ограничения, после чего она перестанет быть такой уж тривиальной, и для её решения человеку, начинающему свой путь в программировании, уже придётся поломать голову.
Статья адресована новичкам, но и тот, кто уже имеет некоторый опыт в создании программ, сможет найти в ней, с моей точки зрения, ряд интересных мыслей.
Весь программный код, приведённый в статье, написан на языке C99.
Предварительные рассуждения и постановка задачи
На протяжении всей статьи мы будем, для определённости, работать именно с формами слова "день". Очевидно, корректная форма этого слова определяется только последней цифрой стоящего перед ним числа. Все возможные варианты (а их всего 3) несложно свести в таблицу:
Последняя цифра числа | Форма слова "день" |
1 | "день" |
2, 3, 4 | "дня" |
5, 6, 7, 8, 9, 0 | "дней" |
Традиционный подход заключается в следующем. Создаётся массив строк, содержащих все три формы слова "день". Например, таким образом:
const char* const strings[] = {"дней", "день", "дня"};
Далее строится программная конструкция, которая по заданной последней цифре числа формирует индекс элемента массива, содержащего соответствующую форму слова "день". Таким образом, по сути, происходит преобразование одного однозначного числа в другое в соответствии со следующей таблицей:
Исходное число | Результирующее число |
0, 5, 6, 7, 8 | 0 |
1 | 1 |
2, 3, 4 | 2 |
Пожалуй, способ решения задачи, который приходит в голову сразу, — это использование условного оператора if else или тернарного оператора ?. Но при этом, очевидно, нам придётся использовать условный или тернарный оператор дважды, поскольку условий, при которых формируется тот или иной индекс, всего 3.
А теперь давайте зададимся целью решить нашу задачу без использования любых условных конструкций. Уже есть над чем задуматься, не так ли?
Ниже я предлагаю 2 способа решения поставленной задачи.
Первый способ
Мы попробуем подобрать ряд математических операций, которые "превратят" однозначные числа в соответствующие индексы. Посмотрите на следующую таблицу:
|
sqrt | +0,3 | round | %3 |
1 | 1 | 1,3 | 1 | 1 |
2 | 1,41… | 1,71… | 2 | 2 |
3 | 1,73… | 2,03… | 2 | |
4 | 2 | 2,3 | 2 | |
5 | 2,23… | 2,63… | 3 | 0 |
6 | 2,44… | 2,74… | 3 | |
7 | 2,64… | 2,94… | 3 | |
8 | 2,82… | 3,12 | 3 | |
9 | 3 | 3,3 | 3 | |
0 | 0 | 0,3 | 0 |
В первом столбце таблицы записаны все 10 однозначных чисел. Второй столбец содержит квадратные корни из них; столбец озаглавлен по имени стандартной функции языка C99, возвращающей квадратный корень из аргумента. В третьем столбце содержатся результаты добавления к числам второго столбца слагаемого 0,3. Четвёртый столбец получен из третьего округлением чисел, находящихся в его ячейках, до ближайшего целого; именно таким округлением занимается стандартная функция языка C99 round(), в честь которой назван четвёртый столбец.
Наконец, находим остаток от деления чисел, находящихся в четвёртом столбце, на 3, а результаты помещаем в 5-й столбец. И что мы видим? В 5-м столбце теперь находятся нужные нам индексы элементов массива strings!
Идея данного способа получения индексов состоит в извлечении корня с последующим округлением до ближайшего целого. Но, чтобы корни из чисел 2, 5 и 6 округлились в нужную сторону, предварительно ко всем корням пришлось добавить 0,3 (можно было добавить, например, и 0,4 с таким же успехом). Такая добавка никак не повлияла на округления остальных корней. А взятие остатка потребовалось для "превращения" всех шести троек в нули.
А теперь давайте напишем программу, тестирующую предложенный нами способ решения задачи.
Программа для тестирования первого способа
Вот код нашей программы:
1.#include <stdio.h> 2.#include <stdlib.h> 3.#include <math.h> 4. 5.int main() 6.{ 7. const char* const strings[] = {"дней", "день", "дня"}; 8. for (int i = 0; i < 25; i++) 9. { 10. int r = rand(), k = r % 10; 11. printf("%d\t%s\n", r, strings[(int) round(sqrt(k) + 0.3) % 3]); 12. } 13. return 0; 14.}
Программа генерирует 25 псевдослучайных чисел и выводит их на консоль, печатая после каждого числа слово "день" в требуемой форме.
Нам нужно подключить заголовочные файлы stdlib.h (стр. 2), чтобы воспользоваться функцией round(), генерирующей псевдослучайные числа, и math.h (стр. 3), чтобы обращаться к математическим функциям round() и sqrt().
Итак, создаём уже упомянутый ранее массив строк strings (стр. 7). На каждой итерации цикла for (стр. 8-12) генерируем псевдослучайное число, помещаем его в переменную r, а его последнюю цифру — в переменную k (стр. 10). Затем выводим на печать само число, а также элемент массива strings с индексом, вычисленным в соответствии с принципом, изложенном в предыдущем разделе, разделённые символом табуляции (стр. 11).
Обратите внимание на приведение к типу int результата, возвращённого функцией round(). Это приведение позволяет, получить затем остаток от деления этого результата на 3. Сам компилятор в генерируемый код такое приведение, к сожалению, не вставляет.
Результат работы программы зависит, разумеется, от реализации генератора псевдослучайных чисел. Я получил следующий консольный вывод:
41 день 18467 дней 6334 дня 26500 дней 19169 дней 15724 дня 11478 дней 29358 дней 26962 дня 24464 дня 5705 дней 28145 дней 23281 день 16827 дней 9961 день 491 день 2995 дней 11942 дня 4827 дней 5436 дней 32391 день 14604 дня 3902 дня 153 дня 292 дня
Как мы видим, все слова корректно сочетаются с числами, а это значит, что придуманная нами схема сработала успешно.
Второй способ
Перед тем, как изложить второй способ, немного потеоретизируем. Если читатель изучал теориею вероятностей и ему знакомы понятия "случайное событие", "алгебра событий", "сумма событий", "индикатор события", то материал, приведённый ниже, ему будет воспринять достаточно легко. Несмотря на то, что прямого отношения к этому разделу математики он (материал) не имеет, читатель заметит аналогию рассматриваемых далее понятий тем, список которых я привёл в предыдущем предложении.
Читателю, знакомому с алгеброй высказываний, будет также будет легко разобраться с информацией, приведённой ниже.
Но даже если упомянутого опыта у читателя нет, ничего страшно. Материал, который я собираюсь изложить, основан на логике, и его нельзя назвать сложным даже для людей, знакомство с математикой которых ограничилось лишь школьным курсом этого предмета.
Итак, начнём!
Рассмотрим множество некоторых условий. Условие для нас сейчас — это некий абстрактный объект, который всегда находится в одном из двух состояний: "выполнено" или "не выполнено".
Также нам понадобиться понятие суммы условий. Рассмотрим набор двух или более условий. Суммой этих условий называется условие, заключающееся в том, что хотя бы одно из условий, входящих в набор, выполнено. Например, сумма условий "a > 0" и "a < 0" является условие "a > 0 или a < 0" или, что то же самое, "a ≠ 0". Суммы условий будем записывать традиционно с использованием знака "+". Его в некотором смысле можно рассматривать как заменитель слова "или".
Каждому условию поставим в соответствие число 1, если условие выполнено, или число 0 в противном случае. Такое число будем называть индикатором условия. Индикатор условия X0 обозначим как I(X0). Если в выражении I(X) под X понимается произвольное (а не фиксированное) условие, принадлежащее множеству условий, то это выражение можно рассматривать как числовую функцию, определённую на данном множестве.
Пусть даны 3 условия A, B и C, являющиеся попарно несовместными, т. е. такими, что никакие два из них не выполняются одновременно. Рассмотрим выражение
и найдём все его возможные значения в зависимости от того, какие из условий A, B, C выполнены. Возможны следующие варианты:
- Ни одно из условий A, B, C не выполнено. Тогда каждое из трёх слагаемых равно 0, и сумма также равна 0.
- Выполнено только условие C. Тогда первые два слагаемых — нулевые, а третье равно 1. Сумма равна 1.
- Выполнено только условие B. Тогда только первое слагаемое равно 0, а каждое из остальных равно 1. Сумма равна 2.
- Выполнено только условие A. Тогда каждое из трёх слагаемых равно 1, а сумма равна 3.
Таким образом, мы получили:
А теперь предположим, что одно из условий A, B, C всегда выполняется. Тогда третье слагаемое равно 1 и последнее равенство может быть переписано в виде
Избавимся теперь от единицы в левой части равенства:
А теперь возвращаемся к нашей задаче. Пусть k — это последняя цифра числа, рядом с которым мы хотим записать слово "день" в корректной форме. Обозначим:
- A — условие, заключающееся в том, что k = 1 (т. е. после исходного числа должно стоять слово "день").
- B — условие, заключающееся в том, что k ≥ 5 или k = 0 (т. е. после исходного числа должно стоять слово "дней").
- C — условие, заключающееся в том, что 2 ≤ k ≤ 4 (т. е. после исходного числа должно стоять слово "дня").
Заметим, что условия A, B и C попарно несовместны и одно из этих условий всегда выполняется. Таким образом, все полученные нами формулы, в которых фигурируют суммы индикаторов, справедливы.
Что это означает? А то, что если мы зададим массив strings так:
const char* const strings[] = {"дня", "дней", "день"};
то сумма индикаторов I(A) + I(A + B) будет равна индексу элемента массива strings, содержащего ту форму слова "день", которая должна стоять после нашего исходного числа!
Давайте теперь переведём это в плоскость программирования. Условиям, о которых мы рассуждали в этом разделе, в языке C99 соответствуют условные выражения, т. е. такие выражения языка C, при вычислении значений которых последней выполняемой операцией является операция сравнения или логическая операция.
Например, если последняя цифра исходного числа содержится в переменной k, то условию А будет соответствовать условное выражение k == 1, а условию B — условное выражение k > 4 || !k.
А как нам вычислить индикатор условия с помощью инструментов, имеющихся в языке C99? Очень просто! Условное выражение может быть либо истинным, либо ложным. В первом случае его значением является 1, а во втором — 0. Таким образом, значение условного выражения можно рассматривать как индикатор соответствующего выражению условия.
Так что в нашем случае сумму индикаторов I(A) + I(A + B) можно вычислить как значение суммы соответствующих условных выражений, т. е. как значение выражения (k == 1) + (k == 1 || k > 4 || !k).
Разумеется, соответствие между буквами А, B, С и обозначаемыми ими условиями может быть любым, но мы использовали именно то, которое приводит к наибольшей компактности данного выражения.
А теперь можно переходить к написанию программы.
Программа для тестирования второго способа
Совсем несложно перейти от программы, тестирующей первый способ, к программе, тестирующей второй:
1.#include <stdio.h> 2.#include <stdlib.h> 3. 4.int main() 5.{ 6. const char* const strings[] = {"дня", "дней", "день"}; 7. for (int i = 0; i < 25; i++) 8. { 9. int r = rand(), k = r % 10; 10. printf("%d\t%s\n", r, strings[(k == 1) + (k == 1 || k > 4 || !k)]); 11. } 12. return 0; 13.}
Мне кажется, что всё настолько очевидно, что комментарии не нужны.
При желании, можно придраться к тому, что в строке 10 значение выражения k == 1 вычисляется дважды. Чтобы этого избежать, можно заменить строки 9 и 10, например, следующими строками.
9. int r = rand(), k = r % 10, t = (k == 1); 10. printf("%d\t%s\n", r, strings[t + (t || k > 4 || !k)]);
В независимости от того, какие именно 2 строки использовать, консольный вывод данной программы (после её компиляции и запуска) совпадёт с предыдущим.
Задача напоследок
У новичков в программировании на C может сложиться стереотип, заключающийся в том, что условные выражения могут использоваться лишь в круглых скобках, следующих за ключевыми словами for, while, if, или в начале тернарного оператора ?. Но это не так. Поскольку значение условного выражения — это целое число, то такое выражение всегда может находиться в любом месте кода, в котором ожидается (или допускается) значение целого типа, например, внутри арифметического выражения.
Этим, кстати, условные выражения в языке C отличаются от условных выражений в языке Java. В Java значение условного выражения всегда имеет тип boolean, несовместимый с целым типом. По этой причине приём, описанный в предыдущих двух разделах, на языке Java реализовать невозможно.
И напоследок хочу предложить задачу на использование условных выражений. Нередко работу тернарного оператора ? демонстрируют на примере нахождения модуля числа. Предположим, что переменные a и b имеют тип double, причём первая из них инициализирована. Поместить в переменную b модуль значения переменной a можно с помощью следующей конструкции:
b = a >= 0 ? a : -a;
А теперь, внимание, вопрос! Как можно сделать то же самое, не прибегая ни к оператору ?, ни к условным операторам, ни к операторам цикла, ни к вызовам стандартных библиотечных функций?
Догадались? А вот моя версия:
b = ((a > 0) - (a < 0)) * a;
Обратите внимание на то, что в качестве сомножителя a выступает выражение, значение которого представляет собой знак a. Напомню, что знаком числа a, часто обозначаемым как sign(a), называется число 1, если a > 0, число −1, если a < 0 и число 0 в случае a = 0. И, кстати, действительно выполняется равенство
|a| = sign(a) · a.
Так что мы получили заодно и способ вычисления знака числа. Если в предыдущем случае нахождение модуля a с помощью условных выражений по компактности программной конструкции проигрывает вычислению его посредством оператора ?, то теперь всё будет наоборот из-за того, что на этот раз оператор ? придётся применять дважды.
Пусть, например, мы хотим поместить знак a в переменную c типа int. Сравните 2 способа. Этот:
c = a ? a > 0 ? 1 : -1 : 0;
И этот:
c = (a > 0) - (a < 0);
И ещё раз обращу ваше внимание на то, что такие трюки с условными выражениями в языке Java "не пройдут".
Заключение
В этой статье мы рассмотрели решение достаточно простых и стандартных проблем нестандартными способами. Я не утверждаю, что предложенные мной способы лучше стандартных. Цель статьи — немного развлечь читателя забавными кунштюками и, возможно, заставить его "поломать" голову над предложенными задачками. Кроме этого, надеюсь, я смог продемонстрировать читателю нетипичные варианты использования условных выражений.