Try   HackMD

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

Массивы

Различия массива массивов и матриц

  • Матрица - int m[5][10]
    • доступ к элементу m[2][9], расшифровывается как m[2*10 + 9]
  • Массив массивов int **a
    • доступ к элементу a[1][5], расшифровывается как: в 1й ячейке массива лежит указатель на 1й строку, от него берется 5й элемент (2 обращения в память)

При одинаковом синтаксисе доступа к элементу происходят совершенно разные вещи

Под малое количество данных лучше использовать статический массив (на стеке), под большое - динамический (на куче). Если размер массива заранее неизвестен, то лучше всего завести маленький статический массив и если его не хватит - выделить память динамически:

  • При малом размере данных - нет обращения в операционную систему

  • При большом обьеме данных - маленький статический массив погрешность

Место на стеке относительно бесплатное, по сравнению с кучей

Указатели

int arr[10]; // статический массив на 10 элементов

//Можно узнать размер этого СТАТИЧЕСКОГО массива
sizeof(arr)/sizeof(arr[0]); // размер arr: 40/4 = 10

arr - это указатель на начало статического массива в памяти

arr + 1; //это указатель сдвинутый на один элемент
//(сдвинется на размер типа указателя в байтах)

arr[3];     //одно и тоже
*(arr + 3); //одно и тоже

int *a, *b;
int c, d[10];

a = &c;     //указатель на переменную c
b = &d[3];  //указатель на 3й элемент массива d
b = d + 3;  //тоже самое, на 3й элемент массива d

*b = 2;     //d[3] = 2
*(b+1) = 5; //d[3 + 1] = 5

Двигать указатели имеет смысл только в случает, когда в памяти лежат подряд много однотипных данных (например массив)

Проход по массиву: (Указатель за границы массива - это НЕ всегда плохо)

a = &d[9]+1; //указатель на следующий байт после d
// не обращаемся сквозь него

for(;b < a; b++) //обрабатывать массив до его конца
{
    //*b - доступ к элементу
}

Сранение указателей имеет смысл только если они сделанны от одного обьекта в памяти (например массив) (тк разные обьекты могут быть разбросанны хрен знает как по памяти)


int arr[10];
int *a = &arr[1], *b = &arr[5];

a - b; // 4 - расстояние между указателями 
a + b; // что-то бредовое
a * 2 //  что-то бредовое

void *v; // указатель на неопределенный тип

Такой указатель (void *) например возвращает malloc.

Свойства

  • Такой указатель **нельзя ** сдвинуть или разьменовать! Потому что не понятно на сколько байт нужно сдвигать и как разьемновывать тк void не существует. Можно сконвертировать и затем двигать и разьменовывать.

  • Такие указатели можно сравнивать.

  • Указатель на любой другой тип можно присвоить в указатель на void

  • Указатель на void можно присвоить в любой другой указатель (ТОЛЬКО В СИ, из-за более сильной статической типиззации в языке си++, можно только с кастом)

    • В си++ new/delete является типизированным выделением памяти, так что к нему не нужно применять каст, однако new/delete это не функции, это **операторы **- часть языка!

Кажется что более сильная статическая типизация противоречит концепциям ООП, действительно, так и есть, НО в си++ динамическая типизация обьектов (или Полиморфизм) достигается с помощью других инструментов, в том числе классов и наследования


Нулевой адрес обычно никуда не сопостовляют. (NULL)

Обычные функции выделения памяти никогда не выделяют обьекты по адресу 0, и очень часто возвращают 0 (NULL) в случае ошибки

Ещё иногда в параметры функции можно отдать NULL или адрес обьекта. В случае NULL функция поймет, что в этой обьект не нужно писать информацию

Это удобно, тк есть значение, которое означает ошибку (указатель на несущетвующий, отсутвующий обьект)

#define NULL (0) - это NULL. Сделали так для удобства чтения прграммы

В си++ есть nullptr, тоже ассоциирован с 0, но хитрее (несовместим с int). Существует для извращенных перегрузок

int *ptr = 0; //Можно, 0 == NULL
//int *ptr = 1; //Не скомпилируется

int **нельзя ** присвоить в int *

Указатель НЕ совместимы между собой. Даже указатель на int не совместим с указателем на unsigned int. То есть указатель в момент компиляции тесно связан с полным типом (не только размером типа). Несмотря на то, что в памяти храниться только адрес.


Про память

  • Если **создаем **указатель за границы массива, то ничего не происходит:

    ​​int arr[10];
    ​​
    ​​int *ptr = arr + 1000;
    
  • Если мы читаем сквозь него int b = *ptr; , то в случае когда там ЕСТЬ память - мы получаем мусор

  • Если мы пишем сквозь него *ptr = 5;, или там нет памяти, например NULL (см прошлый пункт) - мы получаем Undefined behavior, полностью неопределенное поведение! (Программа может упасть или магическим образом измениться)

Сквозь NULL нельзя ни читать ни писать, тк там нет памяти.

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

В современных операционных системах работает механизм виртуальной памяти, где у каждого процесса своё адресное пространство в памяти. Сопоставлением логических адресов (указателей) с физичекой памятью занимается OC и оно своё у каждого процесса.

Указатели в си - это не физические адресса в памяти!

Сваливаемся мы тогда, когда мы обращаемся к той части памяти, для которого адреса не сопоставленно никакое место в физической оперативке (в табличке трансляции адресов). Данное сопоставление выборочное, то есть сопоставленны далеко не все 2^64 адреса (при 64 битной программе)

При динамическом выделении памяти операционаая система:

  • Выделяет нам адреса

  • Сопостовляет этим адресам место в физической оперативке

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


Про массивы

Одномерные массивы и указатели - штуки довольно взаимозаменяемые:

int arr[10];
int *ptr = &arr[0]; //или int *ptr = arr;

Теперь ptr и arr ПОЧТИ одно и тоже:

Одинаковое поведение:

  • доступ одинаковый arr[4] == ptr[4]

  • значение одинаковое arr == ptr


Разное поведение:

  • разный смысл:

    • Адрес ptr будет указывать на ячейку в памяти (&ptr != ptr) (где храниться наш указатель)

    • Адрес arr будет равен адресу массива arr (&arr == arr), но у них будут разные типы: arr - тип массив, &arr тип адрес массива

  • Разные при создании

    • указателя он указывает на случайное место в памяти (в нём мусор)

    • У массива память сразу выделена

  • Менять

    • указатель можно (например ptr = NULL;)

    • массив НЕЛЬЗЯ (так не скомпилируется //arr = NULL;)

  • Разный размер

    • sizeof(ptr) - даст 32 или 64 размер указателя (в зависимости от битности программы)

    • sizeof(arr) - даст 40, размер массива в байтах

Указатель - это отдельная ячейка памяти, которая хранит адрес

Массив - неявный неизменяемый указатель на начало области в памяти, которая сама выделяется и освобождается (происходит это на стеке)


Передать массив в функцию (через аргументы) нельзя! При передаче он превратиться в обычный указатель. Для честного копирования (передачи) массива в функцию его нужно обернуть в структуру, но такой метод всё равно займет O(n) времени и памяти

Циклы

goto

break к сожалению не имеет аргументов и выходит всегда на один уровень выше. => Существует реальная проблема выхода из тройного цикла, есть 2 решения:

  • Покрыть каждый уровень if'ами (очевидно не удобно)

  • Использовать goto (есть много нареканий со стороны других программистов)

На самом деле нарикания по поводу использования goto относятся к совершенно другой эпохе, к похе раннего программирования и там были причины не использовать goto лишний раз. Программы в то время были плотно покрыты goto. Раньше:

  • после if нельзя было поставить блок, можно было указать goto в какое-то место (В ранних версиях языка Basic)

  • goto прыгал не до метки, а до какой-то строки нашей программы (нумеровалиь с шагом 10, чтобы было можно что-то незначительное добавить сверху) (что очевидно крайне неудобно)

Про это и была статья Дейкстры goto is cansidled hardfull (и то такое название сделанно для громкого названия)

goto ещё полезно для обработки ошибок, проще всего сделать освобождение памяти и закрытие файлов в конце функции, поставить метку до и прыгать на неё в случае ошибки, это позволит не городить кучу кода в if (по освобождению памяти и закрытию файлов)

Вообще с помощью goto можно перепрыгныть через создание переменной, но, по моему это вещь НЕстандартная, и об этом точно предупредит компилятор (не понятно чтотогда ожидать от использования такой переменной)

Использовать для увеличения читаемости программы

for

В цикле for могут отсутвовать все три условия:

for(;;);

Такая конструкция законна, означает бесконечный цикл без тела.

Бесконечные циклы можно использовать когда условия выхода сложное и его вычисление не хочеться ставить в условие

Препроцессор

Это дополнительный язык, не си. Выполняется (во время компилируется) до си. К нему относятся команды которые начинаются с # (первым непробельным символом). Команды закончиваются в конце строки и занимают ВСЮ строку (в отличие от си, в си например можно написать весь main в одну строку).

include

Многие команды препроцессора на самом деле текстовые замены. Например команда #include <stdio.h> работает просто с текстом, и вставляет всё содержимое файла stdio.h в эту строчку. Это не магическое подключение библиотеки. В таких .h файлах содержатся все нужные прототипы и обьявления функций.


В .h очень часто находится так называемый include guard (или защита от повторного включения), выглядит так:

#ifndef FILE_NAME_H
#define FILE_NAME_H //принято именовать этот define названием файла
// вместо точки ставиться '_'

//обьявления

#endif //FILE_NAME_H 

Или (по новому)

#progma once

//обьявления

Нужна она для того, чтобы случайно не подключить в один исходный файл (.c или .cpp) несколько раз один и тот же .h, тк его подключение может быть в том числе в другом .h файле. Если он подключиться дважды то у компилятора (до линковки) случится ошибка, что функция обьявленна дважды.

файлы .h вообще не компилируются, только внутри .c или .cpp после вставки с помощью include


Если же мы реализуем функцию в каком-то .h файле и подключим его хотя бы в 2 исходных файла (.c или .cpp) то такая программа упадет на этапе линковки, тк у линковщика будет несколько реализации одной и той же функции, и он не сможет выбрать одну из реализаций.

Include Guard от этого не спасет! (тк include guard про защиту от повторного включения в ОДИН исходный файл, а не в сесь проект, он избаляет от ошибки на этапе компиляции, а не линковки)


Параметр у инклуда также можно указать через двойные кавычки - #include "my.h". Правило:

  • <...> - ищет файл только в стандартных путях компилятора (этот вариант для файлов стандартной библиотеки)

  • "..." - ищет файл только в рабочей папке (при компиляции), если не нашел, то в стандартных путях компилятора (этот вариант для остальных библиотек или своих .h файлов)


Вообще инклуды это способ избавиться от копипасты простотипов функции в разные исполняемые файлы

Описание функций не генерирует никакого машинного кода!

define

Так называемое создание макроса. (на самом деле тоже обычная текстовая замена) Бывает в двух формах:

  • простой: #define A B. A - слово (состоящее из букв и символов подчеркивания '_'), без пробелов, точек, скобочек и т.д. Означает все экземпляры слова A заменить на B. (B до конца строки) (#define "A" B - так не замениться A)

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

  • С аргуменами: #define A(x, y) B. Это не вызов функции, это до сих пор тупая текстовая замена, только с аргументами. Например:

    ​​#define A(f, s) f + s
    ​​int a = A(4, 5)2; //x == 56, 4 + 52
    ​​
    ​​#define B(f, s) ((f) + (s)) //правильно
    ​​int b = B(4, 5)2; //b == 14, ((4)) + (5))2
    

По этому многие любят обносить дефайны скобками

Минус и плюс - то, что это не функция. Пример минуса выше, пример плюса в том, что дефайн ничего не знает о типах. С его помощью, например, можно легко сделать min и max:

#define MAX(x, y) (x > y ? (x) : (y))
#define MIN(x, y) (x < y ? (x) : (y))

int main(void)
{
    int a = 5, b = 6;
    MIN(a, b); //5
}

Здесь используется так называемый тернарный оператор выбора

Так как в си больше нет тернарных операторов (три аргумента), то оператор выбора просто называют тернарный оператор

Тернарный оператор выбора: a ? b : c, если a ==

  • 1 (True), то b

  • 0 (False, то c


Главное при использовании такого дефайна не забывать про типы!

#define MAX(x, y) (x > y ? (x) : (y))

signed int a = -1;
unsigned int b; //любой
MAX(a, b) == UINT_MAX; //порядка 4 миллиарда, при любом b
#define MAX(x, y) (x > y ? (x) : (y))

int a=2;
int b=4;
int c = MAX(a--, b++); //c = 5, a = 1, b = 6
//int c = ((a--)>(b++)?(a--):(b++))

Дерективы условной компиляции

#if, #elif, #else, #endif - предпроцессорные конструкции ветвления, аналог if в си, отличия:

  • Происходит во время компиляции

  • Нужно закрывать командой #endif

Условие у него может быть любое, какое можно посчитать во время компиляции


Пример:

void f(int x
#ifdef __llvm__ //if define __llvm__
 , float y
#endif
)
{
 ...
}

При компиляции llvm эта функция будет принимать 2 аргумента

#if define A очень часто сокращают до #ifdef A.

Eсть ещё #ifndef A - когда не задефайнен A

Пример 2:

#if A == 5    //A - дефайн
//code
#endif

Предпроцессорные #if имеют вложенность:

#ifdef DEBUG
#if DEBUG == 1
    printf("OK\n");
#endif
#endif

В отличие от многострочных комментариев (/* */):

/* комментарий
/* до сих пор комментарий
*/  // - закрыли комментарий
*/  // - ошибка компиляции, неоткрытый комментарий

Предпроцессорным #if очень удобно переключать 2 варианта кода:

#define DEBUG 1

void f(void)
#if DEBUG == 1
{
    printf("debug\n");
}
#else
{
    printf("realese\n");
}
#endif

Комментарии тоже обрабатываются препроцессором, не начинаются с #, просто выкидывается из программы во время компиляции

Как не надо


exit

exit(code) - это команда которая завершает процесс с кодом code. Это зло, тк оно не освобождает от закрытия файлов и освобождения памяти. Программа благодаря exit может не падать с ошибкой, но это не значит, что её не происходит.


Нельзя закрыть файл, который не открылся


Нельзя закрыть файл, если переменную не инициализировали


Оглавление

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