Try   HackMD

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

Типы с плавующей точкой

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

Сравнивать можно только через eps

if (a==b)               // неправильно

if (fabs(a-b) < eps)     // правильно

Структуры

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

Структуры - это новый тип данных, его описание

Создание

Обычное использование:

struct A
{
    int x;        // один int
    float y;      // один float
    char z[4];    // массив 4х char
};

struct A a; //"struct A" - название нового типа
//"a" - название переменной типа "struct A"

Внутри структур могут быть массивы, другие структуры. Можно делать массивы структур и т.д.

Здесь не создается никакой обьект, как с typedef (создавал новый тип), создаем новый тип, который можно использовать для создания новых переменных. Такой тип использовать немного неудобно, тк нужно всегда указывать struct (только в си) перед именем структуры.

Обратим внимание на ; после структуры, она тут нужна потому что после структуры можно сразу содать обьекты типа нашей структуры.

struct     // Структуру можно даже не называть 
// (не рекомендуется)
{
    int x;        // один int
    float y;      // один float
    char z[4];    // массив 4х char
} a; //можем сразу создать обьект такого типа

Структуру можно даже не называть, не рекомендуется тк есть спец эффекты, например при отладке имя структуры не покажется

Часто можно встретить: (Чтобы не писать struct)

typedef struct A    // 'A' можно не писать
{
    int x;
    float y;
    char z[4];
} B;

B b;

ИЛИ

struct A    // 'A' нужно писать
{
    int x;
    float y;
    char z[4];
};

typedef struct A B;

B b;

У структур нет модификаторов доступа, в си такого нет, появляется только в си++

Обращение к полям структур

A a, b = {1, 2}; //в порядке сверху вниз (в самой структуре)

a.x;        //доступ к переменной x
a.z[1];     //доступ к 1 элементу массива z

a = b;        // можно скопировать поэлементно

Структуры можно скопировать поэлементно, что существенно отличает структуры от массивов. Если мы напишем так про массивы (a = b, где a и b массивы) - то ничего хорошего не произойдет, массивы будут интерпретированны как указатели и при компиляции нам скажут "что за ужас здесь творится". Чтобы скопировать массивы надо либо писать цикл либо вызвать специальную функцию копирования памяти (из одного места в другое). (либо же обернуть их в структуры)

Массивы внутри структур ведут себя как структуры (копируются полностью)

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

Можно иниц обьект при создании:

typedef struct
{
    int x;
    int y;
} A;

A a = {.y=2, .x=1};  //a.x == 1  a.y == 2
//чтобы иниц. не по очереди

A b = {1, 2};        //b.x == 1  b.y == 2 
A c = {1};           //c.x == 1  c.y == 0
A d = {};            //d.x == 0  d.y == 0
//не все компиляторы понимают
A e; // Мусор и в 'e.x' и в 'e.y'

A a = {.y=2, .x=1}; Начиная с C99, нет в C++
A d = {}; - компилятор инициализирует всё нулям, некоторые компиляторы не любят пустые {}

Как хранится в памяти

Элементы структуры в памяти хранятся ровно в том порядке в котором их описали в структуре. Переставлять их компилятор не имеет права. Но это не означает что они лежат в памяти плотно.

struct A
{
    // первая переменная можно считать начитаеся с адреса 0
    int x;     // 4 байта 
    // дырка 4 байта      
    double y;  // 8 байт
    char z[4]; // 4 байта
    // дырка 4 байт - для выравнивания при создании массива структур
    
    // всего 24 байта
};

Это так называемое Выравнивание на родную границу. Железкам очень нравится когда данные храняться по адресу кратному размеру данных

Для x86 архитектуры железок (x64 частный случай x86) практически не важно есть выразвнивание или нет (иногда небольшое замедление). В других железках программа может даже упасть, тк железо может не уметь читать невыравненные данные, либо может быть дикое замедление.

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

Первый int начаниется в начале структуры и начало структуры достаточно хорошо выразвненно для ВСЕХ элементов (можно считать что начинается с 0 адреса)

Чтобы избежать дырок в структуре - рекомендуется описывать структуры в порядке уменьшения рамера базового типа (double -> int -> char[4]), но можно обойтись и без этого

Очень часто есть хвостовое выравнивание для массивов из структур

Чтобы избежать свостового выравнивания при создании массива струкр - можно передалать логику работы алгоритма на структуру массивов! (вместо массива структур)

Как отключить выравнивание

Зачем - например, для работы с файлами можно описать структуру чтобы её внутреннее устройство соответствовало один в один расположению данных в файлике. Читать так файли можно не поэлементно а одной операйией чтения. (Расположение данных должно точно совпадать) (Быстрее обычного способа)

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

Для VS (Microsoft)

#programa pack (push, 1) //без выравнивания
// (Пушим старое выравнивание на стек)

//code - описываем структуры 
// которое хотим чтобы плотно упаковывались

#programa pack (pop) //выравнивание структур на дефолтное

Для Clang/GCC

struct A __attribute__ ((packed))
{
//code
};

Про размер в си

Размер какого-то обьекта - типа, переменной, массива в C/C++ можно узнать с помощью оператора sizeof, например: (в байтах)

int a;
sizeof a == 4;

sizeof(unsigned int) == 4;

int arr[10];

sizeof(arr) == 40;

Скобки можно иногда не писать, это не функция!

Полученное число - это размер типа выражения в скобках в байтах!


К слову:

struct A
{
    int x;
    double y;
    char z[4];
} a[10];

struct B
{
    double y;
    int x;
    char z[4];
} b[10];

struct C
{
    int x[10];
    double y[10];
    char z[40];
} c;

sizeof(a) == 240; //байт
sizeof(b) == 160; //байт
sizeof(c) == 160; //байт

Тип выражения - typeof, есть в некоторых компиляторах, предлагают добавить в стандарт C23. По сути typeof уже существует, тк до того как посчитать размер в памяти нужно почитать тип выражения.


Union

Синтаксически - тоже самое, что и структура

Разница только в одном - в том, как он хранится в памяти.


Это структура в которой всё элементы побайтово лежат поверх друг друга. Размер union - sizeof максимального элемента. Может использоваться для создания переменных с разными типами. (обычно вместе со структорой, которая хранит активный тип) (ещё можно прочитать битики floata, напрмер))

Все элементы начинаются с адреса 0, массив считается одним элементом, структура тоже будет считаться одним элементом, со всеми своими дырками

Нужны не очень часто, но там где их нет, но они нужны - уровень извращений зашкаливает, и он будет гораздо выше чем здесь.

Указатели

В интернете есть много картинок про сравнение перехода с C на Python (например, и наоборот). Во многом легкость изучения других языков после C/С++ обуславливается тем, что здесь приходится вручную управлять памятью (=> существуют указатели).

Когда говорят что на Java можно писать эффективный код - имею ввиду, что для этого программист должен обладать высшей калификацией, так как чтобы писать эффектывный код нужно понимать что язык прячет от вас и как он это длает, C, меньше С++ прячут значительно меньше чем Java => присать быстрый код на них проще (несмотря на то, что писать просто код на них сложнее)

В последнее время почему-то чем быстрее удаётся накодить программу - тем лучше (лучше выпустить на неделю раньше, чем исправить пару багов), такой подход как раз и провоцирует использование простых, однако неэффективных языков, например Python, Java

Указатели - идейно они довольно похожи на ярлыки в windows, если создать кучу ярлыков и один удалить, данные никак не поменяются. Ярлыки не содержат данные, только информацию как эти данные найти. На си тоже самое, только указатели строго типизированны, в указателе зашито то, на что он указывает. Указатель на int может указывать только на int. Сами обьекты не содержат информации о типе обьекта, int - это просто 4 байта в памяти. Информация о типе есть только в момент компиляции. По этому в C/C++ в момент компиляции указатель знает информация о типе.

Занимают в памяти столько, какая разрядность программы (32 или 64 бита)

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

int x;         // int
int *p, a;     // указатель на инт, и обычный инт

Есть религиозный момент, где ставить * рядом с int или рядом с именем переменной. Тк при создании переменной указатель относиться только к этой пеменной, а не типу, то следует её ставить рядом с переменной.

* может относиться к типу, но это редкость (в основном при создании указателя на функцию)

От того, где в C/C++ стоят пробелы - ничего не меняется!

p = &x; // взять адрес в памяти переменной x 
//и присвоить указателю p

// (унарный контекст &)

Указатель - чтобы работать с данными, другой смысл *: (унарный)

int y = *p;    // обращение сквозь указатель 
//(разьименовка указателя), y == x
//чтение сквозь указатель

*p = 2;        // x == 2
//запись сквозб указатель

int **q=&p;    // указатель на указатель
**q = 3;       // x == 3, *q на p, **q на x

Тип указателя позволяет их коректно разименовывать

В си ссылок нет! (int &a;) (есть в си++)

Пример пользы указателей - scanf, мы даем адрес переменной, а функция туда записывает данные.

Про память

Обычные переменные, аргументы функции и т.д. создаются на стеке (храняться на стеке)
Выделение памяти - проиходит на куче.

  • стек - ± 2 мегабайта на процесс
  • куча - сколько есть операт пам

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

Глобальные переменные

Опишем переменную вне функции:

int g = 2;

int f(int a)
{
    
    return a;
}

int main(void)
{
    f(8);
    return 0;
}

g будет глобальной переменной. Такие переменные глобальные:

  • Одни на всю программу всегда
  • Видно из всех функций
  • Время жизни - глобальное
  • По умолчанию - видимость только в файле
    • обьявить в другом файле (строго без иниц):
      • int g; - C
      • extern int g; - C++,
    • ::g - обратиться в С++ (когда имя скрыто локальной переменной)
  • Создается до запуска main
  • Уничтожается после окончания main
  • Создается в секции данных (на куче)
  • Размер должен быть известен в момент компиляции

Минусы глобальных переменных:

  • Ломают многопоточность
  • рекурсию
  • очень сложно отлаживать (могут измениться в неожиданном месте)

Глобальные переменные лучше не использовать без Острой необходимости

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

static по глобальной переменной ограничивает видимость текущим файлом.

Static локальные переменные

Выглядит как локальная:

  • видимость - локальная
  • время жизни - глобальное

int f(void)
{
    static int a = 5; // будет выполненно один раз (первый)
    a++;              // будет выполненно Каждый раз
    return a;         // будет выполненно Каждый раз
}

int main(void)
{
    //здесь a не видно
    f();    // == 6
    f();    // == 7
    f();    // == 8
    return 0;
}

Тоже самое, что и глобальная переменная.

Из минусов осталось:

  • Ломает многопоточность

Секции

Три секции:

  1. секция кода - код
  2. секция данных - глобальные переменные (static локальные тоже)
  3. секция данных на чтение - глобальные константы

  1. Stack - локальные переменные
  2. Free RAM (Heap) - куда происходит выделение памяти (обычно называют кучей)

И всё это лежит в оперативной памяти.

ПОДРОБНЕЕ

ПОДРОБНЕЕ 2

Выделение памяти

Для выделения памяти нам и нужны указатели:

#include <stdio.h> #include <stdlib.h> int main (void) { int w = 1000; //int a[1000]; - создание обычного статического массива // так не надо при больших количествах информации // тк локальные масивы храняться на стеке (+- 2мб на программу) int *a = malloc(sizeof(int) * w); //создание массива с выделением памяти if(a == NULL) // проверять рекомендуется при выделении большого размера { printf("Memory was not allocated!\n"); return 1; } a[999]; // доступ такой же free(a); // освободить память // функции free можно отдавать NULL }

malloc, возвращает void *, принимает количество байт которые нужно попросить выделить систему

Религиозный вопрос про проверку. Проверять рекомендуется при выделении большого размера в памяти, тк при выделении маленького - есть огромная веротность что уже всё остальное тоже сломалось.

В си НЕ нужно кастовать указатель из void * к int * (в ++ нужно, по религиозным причинам)

Освобождать память нужно точно таким же способом (семейство функций), каким и выделили (на будущее). Так проиходит из-за того, что у разных функций выделения - разные вспомогательные данные, из-за этого ещё если мы попросим выделить 0 байт, то место в оперативной памяти такая штука займет больше 0.

В системе существует много разных способов (функций) выделить память, для разных нужд:

  • просто выделение
  • выделить очень много
  • чтобы была возможность отдать другому процессу
  • и т.д.

Доступ

Доступ к выделенной памяти такой же как и у одномерных массивов, тк синтаксис [] это ни что иное, как разьменование и перемещение указателя. Обычные одномерные массивы почти тоже самое, что указатель, за исключением того, что:

  1. указатель не знает рамер массива
  2. массив нельзя переопределить (указатель можно заставить указывать на другое место в памяти)
  3. нельзя создать указатель на массив, он будет тем же самым указателем

Передача массива в функцию


int sum(int x[3]) // -> (int *x) автоматически будет преобразованно
{
    return x[0] + x[1] + x[2];
}


int main(void)
{
    int a[3] = {1, 2}; //1, 2, 0
    return sum(a);
}

Массивы в аргументах функций никогда не бывают массивами, они автоматически преобразуются до указателя. (альтернативная запись указателя - int x[])

=> массивы никогда не копируются в функцию, любое изменение которые мы сдлаем с массивом внутри функции будет видно там, где этот массив создавали.

Разница массива массивов и двумерного массива

  1. Массив массивов (int **m;)
    • хранится массив указатей на строки, а затем разбросанно по памяти строки
    • строки могут быть разного размера
    • два обращения в память (медленнее)
    • строки могут поменены местами за O(1) (очень быстро)
  2. Двумерный массив (int m[h][w];)
    • храниться в памяти подряд
    • строки единого размера
    • одно обращение в память (быстрее)
    • строки можно поменять местами минимум за O(w) (довольно медленно)

Разницу нужно понимать, тк обращение к этим двум обьектам выглядит идентично - m[i][j]

В двумерном массиве нужный указатель вычисляется с помощью умножение, фактически, двумерный массив - это одномерный массив

Массив массивов

Массив массивов может выделяться 3мя разными способами:





Двумерный массив

Двумерный массив выделяется так:

int g[5][5]; //В секции данных, фикс размер int main(void) { int h = 8, w = 10; //Могут быть введены пользователем int a[5][5]; //На стеке, мало памяти и фикс размер int *m = malloc(sizeof(int) * h * w); //h - высота, w - ширина g[2][3]; a[2][3]; m[2*w + 3]; //неудобный доступ к элементам free(m); return 0; }

VLA

Есть ещё один способ сделать двумерный массив ТОЛЬКО В СИ, выделить на куче, и с нормальным доступом: (трудный для понимания):

int main(void)
{
    int h = 8, w = 10; //Могут быть введены пользователем
    //h - высота, w - ширина
    
    int (*m)[w] = malloc(sizeof(int) * h * w); //VLA указатель
    
    m[2][3];
    
    free(m);
    
    return 0;
}

В этом примере мы использовали понятие массива с неконстантной длинной, VLA - variable length array Не все компиляторы это умеют. Дело в том, что добавили это только в стандарт C99, но убрали из обязательной части стандарта в оптиональную в C11, те компиляторы, которые не поддерживают стандарт C99, не умеют в VLA (например microsoft), даже в VLA указатели (данный пример)

VLA массивом называют локальный или глобальный массив с неконстантным размером. Многие утверждаю, что это зло - так и есть, потому что такие массивы

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

VLA называю злом из-за того, что "Тупые хомячки" используют его для создания динамического массива на стеке (VLA массив), вместо создания хитрого типа переменного размера (VLA указатель). На самом деле идея очень хорошая, но только в качестве VLA указателей.

В 23 стандарте хотят вернуть часть VLA (указатели) в обязательную часть стандарта


Как выглядит VLA массив (зло)

Как НЕ надо писать

int w = 5;
const int w_const = 5;  

int g[5]; //НЕ VLA array - Так НАДО
int g_vla[w]; //VLA array
int g_also_vla[w_const]; //VLA array

int main (void)
{
    int w_in_main = 5;
    const int w_in_main_const = 5;
    
    int a[5]; //НЕ VLA array - Так НАДО
    int a_vla[w]; //VLA array
    int a_also_vla[w_const]; //VLA array
}

Передача VLA указателя в функцию

Нужно сначала передать в функцию размер массива:

void f(int h, int w, int a[h][w]) // первая размерность всегда превратиться в указатель 
{
    
}

//или

void f(int w, int a[][w]) // первая размерность всегда превратиться в указатель 
{
    
}

Оглавление

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