int m[5][10]
m[2][9]
, расшифровывается как m[2*10 + 9]
int **a
a[1][5]
, расшифровывается как: в 1й ячейке массива лежит указатель на 1й строку, от него берется 5й элемент (2 обращения в память)При одинаковом синтаксисе доступа к элементу происходят совершенно разные вещи
Под малое количество данных лучше использовать статический массив (на стеке), под большое - динамический (на куче). Если размер массива заранее неизвестен, то лучше всего завести маленький статический массив и если его не хватит - выделить память динамически:
При малом размере данных - нет обращения в операционную систему
При большом обьеме данных - маленький статический массив погрешность
Место на стеке относительно бесплатное, по сравнению с кучей
arr
- это указатель на начало статического массива в памяти
Двигать указатели имеет смысл только в случает, когда в памяти лежат подряд много однотипных данных (например массив)
Проход по массиву: (Указатель за границы массива - это НЕ всегда плохо)
Сранение указателей имеет смысл только если они сделанны от одного обьекта в памяти (например массив) (тк разные обьекты могут быть разбросанны хрен знает как по памяти)
Такой указатель (
void *
) например возвращаетmalloc
.
Свойства
Такой указатель **нельзя ** сдвинуть или разьменовать! Потому что не понятно на сколько байт нужно сдвигать и как разьемновывать тк void
не существует. Можно сконвертировать и затем двигать и разьменовывать.
Такие указатели можно сравнивать.
Указатель на любой другой тип можно присвоить в указатель на void
Указатель на void
можно присвоить в любой другой указатель (ТОЛЬКО В СИ, из-за более сильной статической типиззации в языке си++, можно только с кастом)
new/delete
является типизированным выделением памяти, так что к нему не нужно применять каст, однако new/delete
это не функции, это **операторы **- часть языка!Кажется что более сильная статическая типизация противоречит концепциям ООП, действительно, так и есть, НО в си++ динамическая типизация обьектов (или Полиморфизм) достигается с помощью других инструментов, в том числе классов и наследования
Нулевой адрес обычно никуда не сопостовляют. (NULL
)
Обычные функции выделения памяти никогда не выделяют обьекты по адресу 0
, и очень часто возвращают 0
(NULL
) в случае ошибки
Ещё иногда в параметры функции можно отдать NULL
или адрес обьекта. В случае NULL
функция поймет, что в этой обьект не нужно писать информацию
Это удобно, тк есть значение, которое означает ошибку (указатель на несущетвующий, отсутвующий обьект)
#define NULL (0)
- этоNULL
. Сделали так для удобства чтения прграммы
В си++ есть
nullptr
, тоже ассоциирован с 0, но хитрее (несовместим сint
). Существует для извращенных перегрузок
int
**нельзя ** присвоить в int *
Указатель НЕ совместимы между собой. Даже указатель на int
не совместим с указателем на unsigned int
. То есть указатель в момент компиляции тесно связан с полным типом (не только размером типа). Несмотря на то, что в памяти храниться только адрес.
Если **создаем **указатель за границы массива, то ничего не происходит:
Если мы читаем сквозь него int b = *ptr;
, то в случае когда там ЕСТЬ память - мы получаем мусор
Если мы пишем сквозь него *ptr = 5;
, или там нет памяти, например NULL
(см прошлый пункт) - мы получаем Undefined behavior, полностью неопределенное поведение! (Программа может упасть или магическим образом измениться)
Сквозь NULL
нельзя ни читать ни писать, тк там нет памяти.
При обращении по адресу где нет памяти у процессора случается исключение, он переходит на обработчик исключений, его настраивает операционная система. Обычно самое простое и понятное решение для операционной системы - прибить процесс где вызвалось исключение.
В современных операционных системах работает механизм виртуальной памяти, где у каждого процесса своё адресное пространство в памяти. Сопоставлением логических адресов (указателей) с физичекой памятью занимается OC и оно своё у каждого процесса.
Указатели в си - это не физические адресса в памяти!
Сваливаемся мы тогда, когда мы обращаемся к той части памяти, для которого адреса не сопоставленно никакое место в физической оперативке (в табличке трансляции адресов). Данное сопоставление выборочное, то есть сопоставленны далеко не все 2^64 адреса (при 64 битной программе)
При динамическом выделении памяти операционаая система:
Выделяет нам адреса
Сопостовляет этим адресам место в физической оперативке
Иногда укзатели шифруют для защиты. Смысл таких защит в том, чтобы если атакующий всё же нашел дырку в программе и переполнил стек - чтобы программа просто упала, а сделала то действие, которые хочет атакующий
Одномерные массивы и указатели - штуки довольно взаимозаменяемые:
Теперь 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)
времени и памяти
break
к сожалению не имеет аргументов и выходит всегда на один уровень выше. => Существует реальная проблема выхода из тройного цикла, есть 2 решения:
Покрыть каждый уровень if
'ами (очевидно не удобно)
Использовать goto
(есть много нареканий со стороны других программистов)
На самом деле нарикания по поводу использования goto
относятся к совершенно другой эпохе, к похе раннего программирования и там были причины не использовать goto
лишний раз. Программы в то время были плотно покрыты goto
. Раньше:
после if
нельзя было поставить блок, можно было указать goto
в какое-то место (В ранних версиях языка Basic)
goto
прыгал не до метки, а до какой-то строки нашей программы (нумеровалиь с шагом 10, чтобы было можно что-то незначительное добавить сверху) (что очевидно крайне неудобно)
Про это и была статья Дейкстры goto is cansidled hardfull
(и то такое название сделанно для громкого названия)
goto
ещё полезно для обработки ошибок, проще всего сделать освобождение памяти и закрытие файлов в конце функции, поставить метку до и прыгать на неё в случае ошибки, это позволит не городить кучу кода в if
(по освобождению памяти и закрытию файлов)
Вообще с помощью goto
можно перепрыгныть через создание переменной, но, по моему это вещь НЕстандартная, и об этом точно предупредит компилятор (не понятно чтотогда ожидать от использования такой переменной)
Использовать для увеличения читаемости программы
В цикле for
могут отсутвовать все три условия:
Такая конструкция законна, означает бесконечный цикл без тела.
Бесконечные циклы можно использовать когда условия выхода сложное и его вычисление не хочеться ставить в условие
Это дополнительный язык, не си. Выполняется (во время компилируется) до си. К нему относятся команды которые начинаются с #
(первым непробельным символом). Команды закончиваются в конце строки и занимают ВСЮ строку (в отличие от си, в си например можно написать весь main
в одну строку).
Многие команды препроцессора на самом деле текстовые замены. Например команда #include <stdio.h>
работает просто с текстом, и вставляет всё содержимое файла stdio.h
в эту строчку. Это не магическое подключение библиотеки. В таких .h
файлах содержатся все нужные прототипы и обьявления функций.
В .h
очень часто находится так называемый include guard (или защита от повторного включения), выглядит так:
Или (по новому)
Нужна она для того, чтобы случайно не подключить в один исходный файл (.c
или .cpp
) несколько раз один и тот же .h
, тк его подключение может быть в том числе в другом .h
файле. Если он подключиться дважды то у компилятора (до линковки) случится ошибка, что функция обьявленна дважды.
файлы
.h
вообще не компилируются, только внутри.c
или.cpp
после вставки с помощьюinclude
Если же мы реализуем функцию в каком-то .h
файле и подключим его хотя бы в 2 исходных файла (.c
или .cpp
) то такая программа упадет на этапе линковки, тк у линковщика будет несколько реализации одной и той же функции, и он не сможет выбрать одну из реализаций.
Include Guard от этого не спасет! (тк include guard про защиту от повторного включения в ОДИН исходный файл, а не в сесь проект, он избаляет от ошибки на этапе компиляции, а не линковки)
Параметр у инклуда также можно указать через двойные кавычки - #include "my.h"
. Правило:
<...>
- ищет файл только в стандартных путях компилятора (этот вариант для файлов стандартной библиотеки)
"..."
- ищет файл только в рабочей папке (при компиляции), если не нашел, то в стандартных путях компилятора (этот вариант для остальных библиотек или своих .h
файлов)
Вообще инклуды это способ избавиться от копипасты простотипов функции в разные исполняемые файлы
Описание функций не генерирует никакого машинного кода!
Так называемое создание макроса. (на самом деле тоже обычная текстовая замена) Бывает в двух формах:
#define A B
. A
- слово (состоящее из букв и символов подчеркивания '_'), без пробелов, точек, скобочек и т.д. Означает все экземпляры слова A
заменить на B
. (B
до конца строки) (#define "A" B
- так не замениться A
)Препроцессор ничего не знает о си, о типах данных, переменных, функциях и т.д. Препроцессор работает с обычным текстом, он видит слово и заменяет его на другой текст.
С аргуменами: #define A(x, y) B
. Это не вызов функции, это до сих пор тупая текстовая замена, только с аргументами. Например:
По этому многие любят обносить дефайны скобками
Минус и плюс - то, что это не функция. Пример минуса выше, пример плюса в том, что дефайн ничего не знает о типах. С его помощью, например, можно легко сделать min
и max
:
Здесь используется так называемый тернарный оператор выбора
Так как в си больше нет тернарных операторов (три аргумента), то оператор выбора просто называют тернарный оператор
Тернарный оператор выбора: a ? b : c
, если a ==
1
(True), то b
0
(False, то c
Главное при использовании такого дефайна не забывать про типы!
#if
, #elif
, #else
, #endif
- предпроцессорные конструкции ветвления, аналог if
в си, отличия:
Происходит во время компиляции
Нужно закрывать командой #endif
Условие у него может быть любое, какое можно посчитать во время компиляции
Пример:
При компиляции llvm
эта функция будет принимать 2 аргумента
#if define A
очень часто сокращают до#ifdef A
.Eсть ещё
#ifndef A
- когда не задефайненA
Пример 2:
Предпроцессорные #if
имеют вложенность:
В отличие от многострочных комментариев (/* */
):
Предпроцессорным #if
очень удобно переключать 2 варианта кода:
Комментарии тоже обрабатываются препроцессором, не начинаются с #
, просто выкидывается из программы во время компиляции
exit(code)
- это команда которая завершает процесс с кодом code
. Это зло, тк оно не освобождает от закрытия файлов и освобождения памяти. Программа благодаря exit
может не падать с ошибкой, но это не значит, что её не происходит.
Full - C/C++, 5 лекция, 12.03.2022, Скаков