Try   HackMD

02 лекция - C, подробно

За жизнь

Rust - язык без стандарта, свой компилятор языка без стандарта создать невозможно, тк то, как работает компилятор разработчика языка и является фактически стандартом.

Переменные и Операторы

int a, b = 5; // переменная a не инициализируется

Инициализуется только переменные те у которых стоит =

Локальные переменные без инициализации содержат мусор (рандомное значение), тк под локальные переменные место на стеке выделяется, но ничем не заполняется, может случиться 0, может не 0, из разных запусков - может случиться разное, из разных компиляций (release/debug) может случиться разное. Вообще, ошибки обращения к неиницилизированным переменным - это очень частые ошибки.

Как искать обращение к неинициализированным переменным

  1. Статические ситемы (анализаторы кода в онлайне, привязан к среде разроботки, замедляет её)
  2. Динамические ситемы (может быть не привязан к среде, проверка при запуске под ним, бешенное замедление работы программы при запуске под ним, для больших программ можно не дождаться) например Intel Inspector

int main(void)
{
    int a, b = 0, c;    // b - инициализируется
    // Выделилось место на стеке для a, b и c
    return 0;   
}

// и /// - это комментарий, начиная от этого места до конца строки (до перевода строки), компилятор игнорирует, можно писать что угодно

если пишите комментарии на русском, надо посмотреть, чтобы кодировка файла в среде (текстовом редакторе) была UTF-8

    a = b + 3;    //a == b + 3
    a += b; // полный синоним a = a + b; //результат a == b + 3 + b
    // (как и многие другие бинарные операции просто короткая запись) 
    // выдает результат в си значение a, после операции

-многие вещи в C/C++ имеют результат, некое значение, например операция = (присваивания) тоже имеет результат, почему можно писать a = b = c? (что это значит?)

a = b = c;  // эквивалент a = (b = c) или b = c; a = c;

то есть операция = вместо того, чтобы просто присваивать ещё и отдает значение которое она присвоила.

a = b;, a, слево от = - то куда присваиваем, b, справо от = - значение, которое мы присваиваем, операция присваивания всегда левостороняя.

a = b = c, тоже самое a = (b = c); - всегда читается справа налево!

(a = b) = c;    // в си НЕ СКОМПИЛИРУЕТСЯ
                // НЕльзя в значение записать значение, например 5 = 3;

В значение нельзя записать значение, можно только в обьект (переменную) Разница между объектом и значением видна только еcли мы туда пишем, если нет - нет

В си++ - в результате операции присваивания = отдается обьект, то есть (a = b) = c; будет эквивалентом a = b; a = c;, но a = b = c всё равно юудет читаться как в си, справа налево!

Кажется, что это довольно глупая операция, ведь значение b сразу затрётся, но это если a и b простые типы, а в C++ всё может быть гораздо хитрее. Можно переобозначить операцию =, и он может выполнять совсем нетривиальные вещи.


Надо поподробнее остановиться на a++ (постфиксный инкремент) и ++a (префиксный инкремент)

    a++;        // - постфиксная запись a = a + 1;
    ++a;        // - префиксная запись a = a + 1;

С точки зрения a разницы никакой (в си), а вот отдают они разное значение.

a++; результат - значение a до увеличения
++a; результат - значение a после увеличения

    a = 2;
    b = a++; // a=3, b=2    // сначала отдает значение, затем увеличивает
    a = 2;
    b = ++a; // a=3, b=3    // сначала увеличивает, затем отдает значение

Класс - обьединение переменных и функций, как структуры в си, только ещё со своими функциями, методами.

В C++ если обьекты обычные (простые типы) - разници нет никакой, если это какие-то классы, то для них могут быть переопредленны операторы a++ или ++a, и они могут означать разное. В плюсах a может оказаться классом, а если так - a++ будет создавать временный обьект, копию класса, что может сильно замедлять программу, например цикл. В двух словах: для сложных объектов в C++ вполне возможно, что будет оверхед (i++ может создать временный объект), для простых int'ов – нет, для отдельно стоящего i++ точно такой же код, как и ++i.

+, =, ++ и прочие операторы в C++ можно перопределить! (нельзя переопределить . и :)

На что обратить внимание?

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

c = a - b; //вычитание, бинарный контекст 

a = -a; //инвертирование знака, унарный контекст 

У * вообще три разных контекста, бинарный, унарный и при создании переменной.

c = a * b; //умножение, бинарный контекст
с = *ptr;  //разыменовка, унарный контекст
int *d;    //создание указателя, контекст при создании переменной

Хитрых операций много, а вот символов нет, в те времена (написания си) даже большие/маленькие буквы не везде различали

У операций есть приоритет! При одинаковом приоритете операции выполняются в нужном порядке, например для +- это слева направо, для = справа налево. То есть порядок зависит ещё и от контекста. (правила - Operator Precedence

-a+++++a что это означает в си?

(a++)+(++a), НЕ ((a++)++) + a потому что в си операция ++ отдает значение, а не обьект!

В C++ i++ и ++i могут вернут объект

Не надо писать такc = (a++)+(++a); Нельзя в одной строке менять 2 раза переменную, выдаст неопределенное значение (зависит от порядка в моменте), тк порядок выполнения в си неопределён!

Никогда не меняйте в выражение дважды одну переменную!

Для большинства бинарных операций порядок выполнения не определен

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

c = f() + g(); никто не обещает какая функция будет выполнена первой!

int sum(int a, int b)
{
    return a + b;
}

int print(int a)
{
    printf("%d\n", a);
    return a;
}

int main(void)
{
    int result = sum(print(4), print(3)); //result == 7
    // неопределенно в какой порядке напечатается на экран
    // неопределенно вызовется сначала print(4) или print(3)
    // гарантируется выполнение print(4) и print(3) до непосредственного вызова sum()
    
    int result_2 = print(4) + print(3);
    // в такой записи порядок тоже неопределён
    //  гарантируется выполнение print(4) и print(3) до сложения
}

Деление

В си все операции происходят без изменения типов (делим целое на целое - получаем целое)

При делении целового на дробное - целое будет приведено к дробному, рузультат дробный

Округление к 0 (в отличие от Python)

int a =  7/3;    // a = 2
int a = -7/3;    // a = -2

Правило: a/b * b + a%b == a

int a =  7%3;    // a = 1
int a = -7%3;    // a = -1

Проверка на нечетность a%2 == 1 только для полож
a%2 != 0 для любых

При деление на 0 - Undefined Behavior (Может быть всё, что угодно)

Побитовые операции

Побитовое и (AND)

Бинарная a & b a = 12 & 6; // a == 4

1100 //== 12
&
0110 //== 6
==
0100 //== 4
----
3210 - степень двойки за которое овечает бит
----
8421 - число за которое отвечает бит
----

Таблица истинности:

0 0 -> 0
0 1 -> 0
1 0 -> 0
1 1 -> 1

Побитовое или (OR)

Бинарная a | b a = 12 | 6; // a == 14

1100 //== 12
|
0110 //== 6
==
1110 //== 14

Таблица истинности:

0 0 -> 0
0 1 -> 1
1 0 -> 1
1 1 -> 1

Побитовое отрицание (NOT)

Унарная ~a a = ~12; // a == 3

~
1100 //== 12
=
0011 //== 3

Таблица истинности:

0 -> 1
1 -> 1

Побитовое Исключающее ИЛИ (XOR)

Бинарная a ^ b

a = 12 ^ 6; // a == 6

1100 //== 12
^
0110 //== 6
==
0110 //== 6

Таблица истинности:

0 0 -> 0
0 1 -> 1
1 0 -> 1
1 1 -> 0

Штрих Шеффера (NAND - NOT AND - НЕ И)

Бинарная

`a = ~(12 & 6); //a == 3

Таблица истинности:

0 0 -> 1
0 1 -> 1
1 0 -> 1
1 1 -> 0

Стрелка Пирса (NOR - NOT OR - НЕ ИЛИ)

Бинарная

`a = ~(12 | 6); //a == 1

1100 //== 12
^
0110 //== 6
==
0110 //== 6

Таблица истинности:

0 0 -> 1
0 1 -> 0
1 0 -> 0
1 1 -> 0

Подробнее про побитывые операции

Проверка на нечетность (a&1) == 1 (если без "()" будет a&(1 == 1))

1100 == 12
&
0001 == 1
=
0000 == 0
1101 == 13
&
0001 == 1
=
0001 == 1

К сожалению в стандарте приоритеты побитывых операций не те - которые хотелось бы, по этому можно часто встретить скобки рядом с такими операциями

Логические операции

Правило конвертирования:

  • 0 - false, не 0 - true
  • true - 1, false - 0

Логическое и

a = 0 && 0; // a == 0 a = 0 && 6; // a == 0 a = 12 && 0; // a == 0 a = 12 && 6; // a == 1

Логическое или

a = 0 || 0; // a == 0 a = 0 || 12; // a == 1 a = 6 || 0; // a == 1 a = 6 || 12; // a == 1

Логическое не

a = !true; // a == false

Подробнее про логические операции

Приведение к bool

!!a (Если a = 0, то 0. есть a!=0, то 1) (два раза логическое не)

Порядок вычисления аргументов

Сокращенное вычисление Логическое и/или имеют определенный порядок вычисления аргументов!

При логических операциях гарантируется:\

  • f() && g() выполнение f вначале и проверка f не false
    (если f() - FALSE, то g() считаться не будет! (g() не будет вызванно))

  • f() || g() выполнение f вначале и проверка f не true
    (если f() - TRUE, то g() считаться не будет! (g() не будет вызванно))

Целочисленные простые типы

  • В стандарте int определен как не хуже чем - (-32767 ... 32767) (2^16-1), именно в десятичной системе счисления, такой диапазон, не важно сколько битов в одном байте
    В реальности int соответвует диапазону - -2^32 ... 2^32-1

  • Модификатор (типа) short int по стандарту не шире int (32 бита)
    по факту - 16 бит

  • Модификатор (типа) long int по стандарту не меньше int (32/64 бита)
    по факту - 32/64 бита (Козлы тут Microsoft, под win при компиляции под 32-64битную программу long int соответсвует 32 битам, в Linux - при компиляции под 32 - 32, под 64 - 64)

  • Модификатор (типа) long long int по стандарту не меньше long int (64 бита)
    по факту - 64 бита

"long long long" is too long for gcc


  • Модификатор (типa) unsigned int, беззнаковое int, может применятся с любыми другими
    (по факту (для int) - 0 ... 2^32 - 1)

int можно опустить в любых модификаторах для int


  • char, целочисленный тип, может быть знаковым или нет (-128..127 / 0..255, 1 байт или 8 бит)
    unsigned char -> 0..255
    signed char -> -128..127 (ключивое слово signed в си имеет смысл только тут)
    • Значения от 0 до 127 полностью кодируют ASCII, вторую часть бита занимает текущая кодовая страница системы.
    • signed можно применять к любым целочисленным переменным (но все кроме char по умолчанию - signed)

  • _Bool занимает 1 БАЙТ (минимально адресуемая ячейка памяти), можно написать bool подключив заголовочный файл #include <stdbool.h> (также получите true и false)
    • В си изначально не было bool, true и false - из-за ненадобности

В стандарте Си нет ничего про true и false, только про (1 и 0)

Создание своих типов

(создание синонимов типов)

typedef unsigned int uint;    //задать тип с названием uint, и смыслом unsigned int

uint a, b;     //можно использовать как полный синоним unsigned int

отличается от define тем, что это не простая текстовая замена, а создание нового типа, например:

#define X int // X заменяет на int unsigned X a; //скомпилируется //-------------------------------------// typedef int y; //unsigned y b; //не скомпилируется

К typedef новому типу (синониму) нельзя применить никакие модификаторы (unsigned, long и др.)

Операции сдвига

Применяется только по целочисленным переменным

Модулярная арифметика (целочисленная) по стандарту определенна только в беззнаковых числах

Сдвиг налево << все битики сдвинуть на один влево, старший (самый левый) стереть, младший (самый правый) в 0, - эквивалентен умножению на 2

  • В беззнаковых числах гарантируется модулярная арифметика
  • В знаковых числах ничего не гарантируется модулярная арифметика

Сдвиг направо >> все битики сдвинуть на один вправо, младший (самый правый) стереть, сдвигов >> (направо) есть 2:

  1. Логический сдвиг направо - эквивалентный беззнаковому делению на 2 с округ вниз (беззнаковые числа) - старший бит (самый правый) делает 0
  2. Арифмитический сдвиг направо - эквивалентный знаковому делению на 2 с округ вниз (знаковые числа) (проблема в том, что первый бит так или иначе используют для знака и непонятно куда его девать, сдвигать или нет) - старший бит (самый правый) делает оставляет

Так происходит из-за представления чисел в компьютере

5 >> 1;  // == 2  //101 >> 1 = 10 == 2
-5 >> 1; // == -3 //11111011 >> 1 = 10111101 == -3 (Арифметический сдвиг)

То есть если мы напишем int a = -140; a /= 8; - компилятор не имеет права съоптимизировать это до a >>= 3;, тк округление у >> идет вниз!

В стандарте операция побитового сдвига направо для отрицательных чисел не определенна! (в 23 стандарте скорее всего поправять, тк признают числа - в качестве формы дополнение до 2х), то есть компилятор может посмотреть что здесь есть Undefined Behavior и просто выкинуть эту строчку.

Переполнение

Знаковые переполнение в стандарте неопределенно:

for(int i = 5; i > 3; i++)
{
    ///code...
} 

Скомпилировав это под gcc с оптимизациями можно легко получить бесконечный цикл!


Беззнаковое переполнение в стандарте четко определенно:

unsigned int a = -1; //эквивалент = (-1)%(2^32), по математическому модулю

в дополнение до 2x вообще не будет проблем, там просто присвоятся все биты


Кроме деления на 0 есть ещё одни грабли, Int.MIN / -1 - это Undefined Behavior. Происходит, тк диапазон значений (в дополнение до 2х) больше на 1 в отрицательных значениях.
-Int.MIN == Int.MIN

Модуль int

if (a < 0)
    a = -a;     // a может быть Int.MIN

(В стандарте знаковое переполнение не определенно! a = -a; - эквивалент инверт биты + 1)

-128 == 1000 0000
    //инверт биты
0111 1111
    // + 1
1000 0000 == -128    
0 == 0000 0000
    //инверт биты
1111 1111
    // + 1
0000 0000 == 0   
-127 == 1000 0001
    //инверт биты
0111 1110
    // + 1
0111 1111 == 127   

Правильно

int a;
unsigned int b;

if (a < 0)
{
    b = a;
    b = -b; //эквивалент инверт биты и +1, 
            //только без Undefined Behavior 
            //(случался из-за переполнения) 
}
else
{
    b = a;
}
-128 == 1000 0000 //в форме доп до 2х
    //инверт биты
0111 1111
    // + 1
1000 0000 == 128 //напрямую    

Ввод-вывод

Кодовая страница - обычно один байт, от 0 до 128 - ASCII, вторая половина (-128 до 0 или 128 до 255) - набор символов по текущей кодовой странице (обычно второй язык) (cp-866/cp-1256/ - русские, cp-65001 - UTF-8)

\n - символ перевода строки
\t - символ табуляции
\ - спецсимвол строк (char *) си, который используется для спец символов вроде перевода строки, чтобы написать просто символ обратного слеша - \\

Если писать пути в коде программы - нужно удваивать \\

Путь к файлу C:\Users\My\Desktop - выдаст ошибку при компиляции (непонятно что такое \U, \M и \D) Путь к файлу на си C:\\Users\\My\\Desktop либо C:/Users/My/Desktop

// char path[] = "C:\the_programm\no_response.exe"; //НЕправильно // не выдаст ошибку при компиляции // тк \t - символ табуляции, \n - символ перевода строки char path[] = "C:\\the_programm\\no_response.exe"; // правильно char path[] = "C:/the_programm/no_response.exe"; // или так

Пути можно писать через /, и их НЕ нужно удваивать, это не спец. символ. (в линуксе только / в путях), рекомендуется так писать в include

\ спец символ везде, не только в printf/scanf, если поставить \ перед концом строки - значит перевода строки не существует

printf("a = ", a); - что выведется на экран?

-выведется "a = "

Чтобы вывести значение какой-либо перемменной через printf есть специальный спецсимвол: % - спецсимвол printf (есть и другие спец. сиволы чтобы печатать)

если \ спецсимвол во всех строках в си, то % спец символ только в семействе функций scanf/printf

Чтобы напечать символ \ нужно его удвоить - printf("\\");
Чтобы напечать символ % нужно его удвоить - printf("%%");

В чем различие с std::cin и std::cout?

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

cin, cout самые медленные
scanf, printf сильно быстрее
ios_base cin.tie(0); cout.tie(0); ещё быстрее
двоичный ввод/вывод - самое быстрое

В printf/scanf четко указывается тип значения/переменной которая будет печататься/считываться (через %), если написать переменную другого типа - она будет интерпретированна как переменная типа которого вы указали через %.

int a = 5, b = 4, c;
c = a + b;
printf("a + b = %i", c); //выводит переменную типа int

Чем %i и %d отличается от друг от друга?

В printf - ничем, выводит int, в scanf %i может считать ещё значения в 8/16-ричной системе счисления, надо поставить 0 или 0x перед числом соответственно (%d - считывает только десятичные числа) (0123 - восмиричное число 83)

Сишные функции - это функции с переменном числом аргументов, так называемые vararg функции.

scanf возвращает кол-во прочитанных переменных (прочитанных %).
printf возвращает кол-во фактически напечатанных символов.
К вопросу о том, как проверить считалось/напечаталось ли что-то на самом деле.

scanf пропускает пробелы при считывании (всё что считается пробельным символов в си - ' ','\n', '\t')

Оглавление

Full - C/C++, 2 лекция, 19.02.2022, Скаков