Математические выражения строятся по тому же принципу, что и "на бумаге", а операции подчиняются стандартным математическими правилам.
Операции #
Присвоение #
int a;
a = 10;
// здесь a равно 10, a == 10
Операция присвоения "возвращает" саму переменную, то есть:
b = (a = 123);
// a == 123 и b == 123
Скобки кстати не нужны, по приоритету операций такие присвоения будут самостоятельно выполняться справа налево:
a = b = c = d = 123;
// все равны 123
Простая математика #
Одной из основных функций процессора является выполнение вычислений - как с числами напрямую, так и с переменными. Начнём с простых действий и операторов:
+
- сложение-
- вычитание*
- умножение/
- деление%
- остаток от деления
1 + 1; // == 2
5 * 3; // == 15
26 % 3; // == 2
int a = 10;
int b = 20;
int c = a + b; // c == 30
int d = a * b; // d == 200
d = d / a; // d == 20
c = c * d; // c == 600
Сущности, к которым применяется оператор, называются операндами: a + b
- здесь +
это оператор, a
и b
- операнды
Математическая операция выполняется между двумя значениями и возвращает результат, то есть например в выражении a = b + c + d
сначала будет выполнено a + b
, затем результат сложится с c
, результат уже этой операции запишется в переменную через =
.
Круглые скобки и приоритет операций работают по обычным математическим правилам: сначала выполняется то, что в скобках, а умножение и деление имеют приоритет перед сложением и вычитанием: a = b + (c + d) * e
- сначала c + d
, затем результат умножится на e
и прибавится к b
.
Знак минус также может применяться к значению для того, чтобы взять его с обратным знаком, например:
a = b + -10; // корректная запись
a = -a; // поменять знак переменной
Допускается использовать операторы +
и -
отдельно:
+3 + -3; // == 0
+3 - -3; // == 6
+3 - + - + - -3; // == 6
+3 - - - -3; // == 6
Скорость вычислений #
Как говорилось в предыдущих уроках - скорость вычислений зависит от процессора: от его рабочей частоты и наличия аппаратных вычислительных блоков. Аппаратное сложение должно быть у всех процессоров, а вот умножения и деления может не быть - они реализуются программно при помощи сложения, поэтому выполняются медленнее. Также логично предположить, что чем больше размер типа данных - тем медленнее производится операция.
Отдельная тема - тип данных с плавающей точкой: на "мощных" процессорах стоит отдельный блок для вычисления таких чисел - FPU, а если его нет - вычисления выполняются опять же через сложение и деление. Вот очень наглядная табличка с временем операций (микросекунды) на МК AVR ATMega328p 16MHz (Arduino NANO):
Тип | + , - |
* |
/ , % |
---|---|---|---|
int8_t |
0.44 | 0.625 | 14.25 |
uint8_t |
0.44 | 0.625 | 5.38 |
int16_t |
0.89 | 1.375 | 14.25 |
uint16_t |
0.89 | 1.375 | 13.12 |
int32_t |
1.75 | 6.06 | 38.3 |
uint32_t |
1.75 | 6.06 | 37.5 |
float |
8.125 | 10 | 31.5 |
Порядок вычисления #
Если операнды представляют собой какие-то выражения или функции, то вызываться и вычисляться они могут в произвольном порядке, а не справа налево:
x = foo() + bar(); // bar() может быть вызвана до foo()!
Поэтому не нужно располагать в таких вычислениях функции и выражения, которые как-то взаимосвязаны.
Составные операторы #
Для случаев, когда переменная участвует в расчёте своего собственного значения, например a = a + b
, можно пользоваться составными операторами:
+=
- составное сложение:a += 10
равносильноa = a + 10
-=
- составное вычитание:a -= 10
равносильноa = a - 10
*=
- составное умножение:a *= 10
равносильноa = a * 10
/=
- составное деление:a /= 10
равносильноa = a / 10
%=
- остаток от деления:a %= 10
равносильноa = a % 10
Как и присвоение, всё выражение с составным оператором возвращает результат, то есть им можно пользоваться как новым значением. Это может ухудшить читаемость программы, но работает отлично:
int a = 10, b = 20;
int c = (a += 1) + (b += 1);
// a == 11, b == 21, c == 32
Инкремент и декремент #
Очень часто в различных алгоритмах бывает нужно изменить значение переменной на 1
, то есть уменьшить или увеличить. Для краткости существуют операторы инкремента ++
(увеличение) и декремента --
(уменьшение). Оператор может стоять как перед значением переменной (пред-инкремент, пред-декремент), так и после неё (пост-инкремент, пост-декремент) - это будут разные операции:
++a
равносильно(a += 1)
, то есть сначала изменит переменную, затем вернёт результатa++
изменит переменную, но вернёт её старое значение в рамках текущей инструкции
Инкремент и декремент имеют более высокий приоритет, чем остальные операции - выполняются сначала они
a = 10;
b = ++a;
// a == 11, b == 11
a = 10;
b = a++;
// a == 11, b == 10
a = 10;
b = 1 + ++a + 1;
// a == 11, b == 13
a = 10;
b = 1 + a++ + 1;
// a == 11, b == 12
В общем случае рекомендуется использовать пред-инкремент и пред-декремент - они выполняются чуточку быстрее, так как пост-операция приводит к созданию "временной" переменной со старым значением, а пред-операция сразу меняет переменную
Переполнение #
Как было показано в уроке про типы данных, переменная может переполняться в рамках своего типа данных:
uint8_t val = 255; // тип данных 0.. 255
val++; // тут val == 0
val -= 10; // а тут из нуля станет 246
val = 525; // переполним! Останется 13
val = -20; // и обратно: val == 236
Особенности вычислений #
Преобразование типов #
Перед выполнением операции оба значения автоматически приводятся к одному типу данных по возрастанию размера и наличию плавающей точки - меньшее целочисленное значение преобразуется к типу большего, а целочисленное преобразуется к типу с плавающей точкой. Результат выражения, как и ожидается, соответствует общему типу операндов:
100 * 100L; // int и long: int->long, результат long
100 * 1LL; // int и long long: int->long long, результат long long
100L * 100.0f; // long и float: long->float, результат float
100.0f * 100.0 ; // float и double: float->double, результат double
Если у целочисленных операндов отличается знаковость, то signed
будет приведена к unsigned
. В противном случае останется как было:
100 * 100; // int и int: останется как есть
100 * 100L; // int и long: int->long
100 * 100u; // int и unsigned int: int->unsigned int
100 * 100ul; // int и unsigned long: int->unsigned long
100u * 100ul; // unsigned int и unsigned long: unsigned int->unsigned long
Примечание: вычисления происходят таким образом, что процессору неважно, какой у числа знак. Поэтому выражение -100 * 100u
имеет результат 55536
типа unsigned int
. Но если преобразовать его к int
- получатся искомые -10000
, потому что 55536 unsigned
это то же число, что и -10000 signed
:
int a = -100;
unsigned b = 100;
int res = a * b; // res == -10000
unsigned ures = a * b; // ures == 55536
Переполнение #
Вычисление производится в ячейке памяти фиксированного размера и типа - того, в который преобразовались оба операнда. Если это тип с плавающей точкой - вычисление будет выполнено с соответствующей типу точностью. У целочисленных типов всё немного иначе: если результат не поместится в ячейку операции - произойдёт её переполнение, лишние старшие байты будут отброшены, как при присвоении:
uint16_t a = 500, b = 500;
uint32_t res = a * b; // res == 53392, ячейка вычисления 16 бит переполнилась!
res = a * 500; // res == 53392 при 2-байтном int, например на AVR Arduino
// При 4-байт int переполнение не случится
float resf = a * b; // 53392.0 - сначала переполнилось, потом записалось в float
Для решения этой проблемы можно вручную преобразовать один из операндов в более старший тип, чтобы вычисление происходило в более крупной ячейке:
uint16_t a = 500, b = 500;
uint32_t res = (uint32_t)a * b; // res == 250000 - правильно
res = a * 500ul; // res == 250000 - правильно
float resf = (float)a * b; // 250000.0 - правильно
resf = a * 500.0; // 250000.0 - правильно
Деление #
Деление чисел с плавающей точкой выполняется в рамках точности типа:
5 / 9.; // ~ 0.5555555820
25 / 8.; // ~ 3.1250
Деление целого числа на целое всегда происходит с округлением вниз, точнее дробная часть просто "отбрасывается" - она даже не предусмотрена в процессе вычисления:
int a;
a = 5 / 9; // a == 0
a = 25 / 8; // a == 3
float f;
f = 5 / 9; // f ~ 0.0
f = 5 / 9.0; // f ~ 0.55
Если нужно целочисленное деление с округлением вверх, его можно реализовать так:
вместо x / y
записать (x + y - 1) / y
Более того, вычисления выполняются слева направо попарно, поэтому в программировании от перестановки мест множителей результат может меняться:
5 / 6 * 10; // == 0 * 10 == 0
5 * 10 / 6; // == 10 / 6 == 8
Точность float #
Из-за особенности самой модели "чисел с плавающей точкой" вычисления иногда производятся с небольшой погрешностью:
1.1 - 1.0; // ~ 0.100000023 !!!
1.5 - 1.0; // ~ 0.500000000
Будьте очень внимательны при сравнении float
чисел - результат может быть некорректным!
Округление float #
Если целочисленной переменной нужно присвоить результат вычисления в ячейке с плавающей точкой или просто float
число, то есть 4 варианта:
- Просто присвоить - дробная часть отсечётся, аналогично функция
trunc(f)
- Округлить вниз при помощи функции
floor(f)
(пол) - Округлить вверх при помощи функции
ceil(f)
(потолок) - Округлить математически (>= 0.5 вверх, < 0.5 вниз) при помощи функции
round(f)
int a;
a = 3.14; // a == 3 - отбросить
a = trunc(3.14); // a == 3 - отбросить
a = floor(3.14); // a == 3 - округлить вниз
a = ceil(3.14); // a == 4 - округлить вверх
a = round(3.14); // a == 3 - округлить "математически"
a = round(3.74); // a == 4 - округлить "математически"
Для использования этих функций нужно подключить библиотеку #include <math.h>
Математические функции #
В стандартной библиотеке присутствует целый набор различных функций, смотрите справочник.