Цели: изучение средств для выполнения элементарных вычислений на языке C++, части содержимого стандартного заголовочного файла cmath — соответствие между простейшими математическими формулами и программами; изучение синтаксиса конструкций выбора альтернатив (if-else, switch-case, “тернарного оператора”), некоторых способов возвращения более одного значения из функции.
В данном задании акцентируется важность детального изучения различных возможных (пусть и маловероятных) случаев, комбинаций значений параметров задачи.
Критерии полноты
Перед выполнением задания рекомендуется изучить справочный материал по числам с плавающей точкой и стандартным функциям, определённым в заголовочном файле cmath.
Ключевые слова if и else позволяют организовать последовательную проверку группы альтернативных условий и выполнение действия при выполнении некоторого из этих условий или невыполнения всех условий (ветка else).
if ( /* поместите сюда условие, круглые скобки вокруг условия обязательны */ )
{
// код, который выполнится в случае истинности условия
}
else // else-ветка не является обязательной
{
// код, который выполнится в случае ложности условия
}
Пример каскадного if-else.
if (day == 1)
cout << "понедельник";
else if (day == 2)
cout << "вторник";
else if (day == 3)
cout << "среда";
else if (day == 4)
cout << "четверг";
else if (day == 5)
cout << "пятница";
else if (day == 6)
cout << "суббота";
else if (day == 7)
cout << "воскресенье";
else
cout << "(неверный номер дня недели)";
Предыдущий пример каскадного if-else относится к случаям применимости инструкции switch-case, позволяющей перейти на метку case с целочисленным значением, равным значению выражения, указанного в круглых скобках после ключевого слова switch. Ключевое слово default задаёт метку, переход на которую осуществляется, если среди case-случаев нет подходящей константы. Так как выполнение внутри switch просто продолжается с соответствующей метки, часто требуется ставить ключевое слово break для явного выхода из конструкции switch-case.
switch (day)
{
case 1:
cout << "понедельник";
break;
case 2:
cout << "вторник";
break;
case 3:
cout << "среда";
break;
case 4:
cout << "четверг";
break;
case 5:
cout << "пятница";
break;
case 6:
cout << "суббота";
break;
case 7:
cout << "воскресенье";
break;
default:
cout << "(неверный номер дня недели)";
}
Попробуйте убрать часть break и выполнить этот код для разных значений переменной day.
С практической точки зрения приведённый выше код логичнее выразить в виде отдельной функции, которая “переводит” номер дня в строку (название дня). Куда её выводить (в cout или нет) пусть решает пользователь функции. Использование return делает break избыточными.
const char* day_of_week(unsigned day)
{
switch (day)
{
case 1:
return "понедельник";
case 2:
return "вторник";
case 3:
return "среда";
case 4:
return "четверг";
case 5:
return "пятница";
case 6:
return "суббота";
case 7:
return "воскресенье";
default:
return "(неверный номер дня недели)";
}
}
Оператор ?:, также известный как “тернарный оператор” (благодаря тому, что принимает три операнда), позволяет включить выбор одного значения из двух по логическому условию непосредственно в выражение.
/* условие */ ? /* значение в случае истинности */ : /* значение в случае лжи */
При этом гарантируется, что условие вычисляется и проверяется до альтернатив, и что только одна альтернатива вычисляется.
Простой пример: реализация функции “модуль числа”. Через if-else:
double abs(double x)
{
if (x < 0.0)
return -x;
else
return x;
}
С помощью тернарного оператора её можно записать короче:
double abs(double x)
{
return x < 0.0? -x: x;
}
Тернарный оператор допускает вложение. Например, функция “сигнум” (знак числа):
int sgn(double x)
{
return x < 0.0? -1:
x > 0.0? +1:
0;
}
Тернарный оператор можно применить, чтобы заменить switch
в примере с функцией day_of_week выше на выборку из массива. Это не означает, что подобная замена всегда удобна или практически осмысленна — иначе бы не было в языке инструкции switch. Здесь важен тот факт, что перебираемые значения занимают сплошной диапазон 1–7.
const char* day_of_week(unsigned day)
{
// Заранее заданный массив значений.
static const char* name[8] =
{
"(неверный номер дня недели)",
"понедельник",
"вторник",
"среда",
"четверг",
"пятница",
"суббота",
"воскресенье"
};
return name[day < 8? day: 0];
}
Последнюю функцию логично изменить так, чтобы отделить данные от логики: внутренний массив можно вынести во внешнюю структуру, собирающую все необходимые параметры, характеризующие национальные и языковые особенности. Перевод интерфейса на другой язык потребует лишь замену соответствующих языковых данных (которые можно загружать из файла) без изменения собственно кода программы.
// Описание "культуры": национальные и языковые особенности.
struct Culture
{
// Названия дней недели.
const char* day_of_week[8];
// ...
};
const char* day_of_week(unsigned day, const Culture &culture)
{
return culture.day_of_week[day < 8? day: 0];
}
Пусть дано линейное уравнение ax + b = 0, a и b из ℝ. Как можно оформить функцию C++, решающую это уравнение для произвольной пары параметров a, b?
double solve_linear(double a, double b)
{
// Неужели так просто?
return -b / a;
}
Можно поэкспериментировать с этим кодом, вводя разные a и b. Интересно, что произойдёт, если ввести a = 0? А если и b = 0?
Очевидно, что решений у конкретного уровнения с конкретными значениями параметров может и не быть, а может быть бесконечно много (случай a = 0 и b = 0). Простейшим подходом будет заявить “наша функция возвращает решение уравнения, если оно существует и единственно”, но это означает перекладывание задачи по определению возможности решения уравнения на пользователя программы. В то же время, мы же решаем уравнение? Кому как не нам в таком случае разрешать возможные “плохие случаи”? Поэтому более удобной для пользователя будет функция, которая проверяет параметры и помимо корня способна возвращать описание ситуации (например, “корень получен”, “корней не существует”, “(почти) любое число подходит в качестве корня”). Этого можно добиться, возвращая числовой код ситуации, либо характеристику “количество корней”. При этом будем толковать отрицательное количество как ситуацию “почти любое число подходит в качестве корня”.
Однако, если функция возвращает количество корней, то нужно решить каким образом она будет возвращать одновременно и сам корень, если он существует… C++ предлагает несколько способов решения данной проблемы. Предпочтительный способ зависит от конкретной ситуации. Мы выберем простейший с точки зрения требуемого объёма знаний способ — передачу переменной-получателя дополнительного значения в функцию по ссылке. Синтаксически это оформляется дописыванием знака “амперсанд” & после типа параметра, передаваемого по ссылке.
// Передача параметра по значению.
void by_value(int n)
{
// n -- локальная переменная функции by_value
n = 42; // это изменение не выйдет за пределы вызова
}
// Передача параметра по ссылке.
void by_reference(int &n)
{
// n -- псевдоним внешней переменной
n = 23; // это изменение внешней относительно функции переменной
}
// Тест.
int main()
{
int x = 0;
by_value(x);
cout << x; // > 0
by_reference(x);
cout << x; // > 23
}
Теперь вы можете вернуть из функции сразу несколько значений “за один раз”, но под них придётся заранее завести переменные. Функцию, решающую линейное уравнение перепишем так, чтобы в качестве основного результата она возвращала количество корней (тогда его удобно будет сразу проверять в условии инструкций if или switch), а вычисленный корень записывала в переменную, переданную по ссылке в качестве параметра.
// Особое значение "бесконечное количество корней".
const int INFINITE_ROOTS = -1;
// Функция возвращает "количество корней".
// Корень записывает по ссылке root.
int solve_linear(double a, double b, double &root)
{
if (a == 0)
return b == 0? INFINITE_ROOTS: 0;
root = -b / a;
return 1;
}
Усложним задачу. Будем решать произвольное квадратное уравнение ax2 + bx + c = 0. Все коэффициенты могут принимать любые значения, соответственно, у нас может быть 0, 1, 2 корня или все действительные числа могут быть корнями. При этом случай a = 0 мы уже умеем решать с помощью функции solve_linear. И как раз удобно, что она возвращает количество корней, поэтому можно просто записать вызов solve_linear в инструкции return.
Итак, функция solve_quadratic возвращает до двух корней, записывая их в переменные, переданные по ссылкам. При этом root2 (второй корень) записывается только в случае наличия двух разных корней.
int solve_quadratic(double a, double b, double c, double &root1, double &root2)
{
if (a == 0) // сводится к линейному
return solve_linear(b, c, root1);
// ниже a != 0
const double d = b * b - 4.0 * a * c;
if (d < 0) // нет корней
return 0;
if (d == 0) // один корень
{
root1 = -b / (2.0 * a);
return 1;
}
// два корня
const double ds = sqrt(d);
root1 = (-b - ds) / (2.0 * a);
root2 = (-b + ds) / (2.0 * a);
return 2;
}
Ключевое слово const (константа) следует использовать в тех случаях, когда значение создаваемой переменной не предполагается изменять. Да, формально это всё равно “переменная”, хотя и считается, что после инициализации её значение неизменно. Компилятор постарается проверить, пытаемся ли мы изменить значение этой переменной, и выдаст ошибку компиляции, если обнаружит такую возможность. Эта ошибка компиляции будет означать ошибку в наших рассуждениях или коде. Кроме того, const часто помогает компилятору производить более эффективный машинный код.
Рассмотрим пример, напоминающий варианты из задания. Пусть f(x, a, b, c) = 1 + sin(ax + |logb c|). Положим f(x) = 0 и формально выразим x через значения параметров a, b, c. Получим x = loga (–|logb c| – π/2 + 2π n), где n ∈ ℤ (здесь достаточно взять одну из веток решений).
Анализируя полученную формулу, выражающую x, заключаем следующее.
Полностью функция, решающая рассмотренное уравнение с учётом указанных выше упрощений, может выглядить следующим образом
// Решение уравнения f(x) = 0.
int solve_f(double a, double b, double c, double &root)
{
if (a < 0.0 || b <= 0.0 || b == 1.0 || c <= 0.0)
return 0; // нет корней
if (a == 0.0 || a == 1.0) // потенциально почти все возможные x -- корни
return is_almost_zero(f(a, b, c, 1))? INFINITE_ROOTS: 0;
// Счётное число корней, получим один из них.
const double
expr_part = abs(log(b, c)) + Half_Pi, // часть выражения
n = 1.0 + ceil(expr_part / Double_Pi), // номер корня
log_arg = Double_Pi * n - expr_part; // аргумент логарифма в формуле корня
assert(log_arg > 0.0); // всегда должен быть положительным по построению
root = log(a, log_arg);
return 1;
}
Здесь INFINITE_ROOTS уже является признаком не просто бесконечного числа корней, но несчётно большого числа корней (например, все действительные числа кроме нуля), это особые “вырожденные случаи”.
Используемая в тексте функция is_almost_zero — вспомогательная функция, проверяющая значение на близость нулю. Она заменяет проверку на равенство, которая может плохо работать на практике из-за погрешности машинных вычислений. Функция is_almost_zero определена следующим образом (tolerance — допустимое значение отклонения значения от нуля).
// Проверка значения на близость нулю.
bool is_almost_zero(double x, double tolerance = Tolerance)
{
return abs(x) <= tolerance;
}
Кроме is_almost_zero в коде определена функция log двух параметров (в стандарте такой нет), вычисляющая логарифм по произвольному основанию как отношение натуральных логарифмов.
Стандартные математические функции
Кувшинов Д.Р. © 2015