View Categories

Математические операции

Математические выражения строятся по тому же принципу, что и "на бумаге", а операции подчиняются стандартным математическими правилам.

Операции #

Присвоение #

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>

Математические функции #

В стандартной библиотеке присутствует целый набор различных функций, смотрите справочник.

0 0 голоса
Рейтинг статьи
Подписаться
Уведомить о
guest

0 комментариев
Старые
Новые Популярные
Межтекстовые Отзывы
Посмотреть все комментарии
Прокрутить вверх