Skip to content

Instantly share code, notes, and snippets.

@yhaskell
Created October 2, 2017 15:07
Show Gist options
  • Save yhaskell/916543c767e01829aa2d0c0322f54562 to your computer and use it in GitHub Desktop.
Save yhaskell/916543c767e01829aa2d0c0322f54562 to your computer and use it in GitHub Desktop.

Язык Си

Типы в языке Си

В языке Си есть несколько типов данных:

  • Целые числа: int, char, long, short,
  • Числа с плавающей точкой: float, double

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


Обозначения для грамматики определений

  1. В квадратных скобках стоит то, что может быть, а может и не быть описано:

    [меня можно писать]

  2. Звёздочкой помечено то, что можно писать 0, 1, 2, или любое другое количество раз

Создание переменной

Синтаксис определения переменной такой:

 тип_переменной имя_переменной [ = начальное_значение]
                [, имя_переменной [ = начальное_значение]]* ;

Пример:

int x;
float y, z = 42.0;
char a = 'a';

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

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

Описание функции:

тип_возвращаемого_значения имя_функци(аргументы) { тело_функции }

где:

  • тип_возвращаемого_значения -- это либо тип, либо void
  • аргументы: [тип_аргумента имя_аргумента [, тип_аргумента имя_аргумента]*]
  • тело_функции -- это непосредственно инструкции

Пример:

int foo() { return 0; }

Когда программа запускается, компьютер загружает код нашей функции по какому-то адресу.

Выполнение функции происходит вот так:

  • Сначала компьютер запоминает, откуда мы вызвали функцию. Это нужно для того, чтобы было понятно, куда возвращаться
  • Потом он выделяет память под stack frame: место в памяти, в котором лежат все локальные переменные нашей функции
  • Теперь он начинает выполнять нашу функцию "построчно" (на самом деле, по операциям -- одна строчка может компилироваться в несколько разных операций)
  • После того как мы встретили return, либо когда функция закончилась, К. продолжает выполнение с того места, откуда мы вызвали нашу функцию.
Класс памяти, в котором выделяется место под локальные переменные, называется автоматическим.

Внутренности функций

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

Создание переменных.

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

Expression statement

Это -- всякие выражения:

  • Присваивание переменной
  • арифметическое выражение
  • вызов функции
  • и любое комбинирование этих вариантов.

Пример:

a = 42;
b = a * 5 - 8 * f(3); // комбинирование присваивания, ар.выр. и вызова функции
printf("Hello World!\n"); // вызов функции

Block statement

Это набор других инструкций, заключённых в фигурные скобки.

Пример:

{
    int a = 0;
    a = 42;
}

Scoping

Если внутри фигурных скобок была объявлена переменная, то вне этих фигурных скобок она видна не будет.

Условный оператор.

Синтаксис условного оператора:

if (условие) 
    действие_успеха 
[else 
    действие_провала]
// здесь действия -- это инструкция.

Пример:

if (a[i] == 0) {
    return 42;
}

Цикл while

Синтаксис цикла while такой:

while (условие) 
    инструкция

Цикл for

Синтаксис цикла while такой:

for (инициализатор; условие; итератор) 
    инструкция

При выполнении цикла for:

  • сначала выполняется инициализатор

    Это объявление какой-то переменной с присвоением ей какого-то значения, либо expression statement.

  • потом, в цикле проверяется условие. Если оно верно, то выполняется сначала инструкция, а потом итератор.

Инициализатор, условие и итератор могут быть пустые.

Пример:

for (;;) {} // пустой бесконечный цикл

for (int i = 0; i < N; i++) {
    printf("%d\n", i);
}   // вывод чисел от 0 до N, не включая N

Цикл do while

Синтаксис цикла такой:

do инструкция while (условие);

Делаем действие. Если условие верно, переходим к началу цикла.

Пример:

char read[4];
do {
    printf("Введите yes/no");
    scanf("%s", read);
} while (strcmp(read, "yes") != 0 && strcmp(read, "no") != 0);

Оператор switch

Оператор switch позволяет выбрать ветку по значению какого-то выражения.

Его ситаксис такой:

switch (выражение) {
    [case значение: 
      инструкция* ]*
    [default:
      инструкция* ]*
}

Сначала вычисляется выражение.

Затем среди веток case выбирается та, значение которой совпадает с вычисленным.

После этого начинается выполнение кода до первого встречного break, включая перепрыгивание в другие case.

Если для значения не найдена ветка case, то прыгаем в default (если он есть). Если нету, просто идём дальше.

Пример:

int N;
scanf("%d", &N);
switch (N) {
    case 10:
        printf("Вы ввели 10\n");
        break;
    case 8:
    case 4:
    case 2:
        printf("Вы ввели степень двойки, меньшую 10\n");
        break;
    default:
        printf("Вы что-то ввели. Молодец!\n");
        
}

Новые типы данных

В языке Си есть 5 способов создать новый тип данных.

Структура

Структура -- это способ объединить несколько переменных в одну.

Синтаксис:

struct имя_структуры {
    объявления_полей
};
объявления_полей -- это:
[тип_поля имя_поля[, имя_поля]*;]*

Пример:

struct point_t {
    double x, y;
}; // точка с запятой обязательная!!!!!!!!!!!!

После этого "struct point_t" -- это новый тип, который можно использовать наряду с перечисленными в начале этого документа.

Работать с этим можно так:

struct point_t p; // создали две переменных типа point_t

Теперь, внутри какой-то функции, p.x и p.y -- это нормальные переменные, которые можно использовать:

scanf("%lf %lf", &p.x, &p.y);
p.x = p.x * 2;
p.y = p.y * 2;
printf("Double sum of {x, y} coords of p equals to %lf", p.x + p.y);

type aliases (typedef)

С помощью оператора typedef можно присвоить новое имя существующему типу.

Пример:

typedef struct point_t point_t;
// теперь можно не писать struct point_t, а писать просто point_t

Union types

Не надо использовать union types до того, как понятно вообще всё остальное.

Массивы

Массивы -- это проиндексированные натуральными числами последовательности элементов.

Чтобы создать массив, при определении переменной к самому имени переменной добавляется количество элементов в квадратных скобках.

Начальное значение для массива записывается перечислением значений в фигурных скобках. Если значение для какого-то индекса не указать, оно будет равно 0.

Примеры:

int A[100];
int B[100], C[42];
int zeros[42] = {}; // 42 нуля в массиве
int numbers[6] = { 4, 2, 1, 3, 5, 8};

Работать с массивами очень просто. Если A -- имя переменной-массива, а x -- какое-то целочисленное значение, то A[x] -- это x-ый элемент массива.

Примеры:

int A[4] = {};
A[0] = 42;
A[3] = A[0] + A[1];
scanf("%d", &A[2]);

Можно воспринимать A[x] как своеобразное имя переменной. Если куда-то можно подставить имя переменной в коде, то туда можно подставить и вот такую штуку.

Здесь A -- имя массива, x -- индекс.

Указатели

Для простоты можно считать, что память комьютера -- это линейный массив элементов типа char (1-байтовых чисел). поэтому у каждой ячейки в памяти есть уникальный соответствующий этой ячейке номер.

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

    1б 1б 1б 1б 1б 1б   <-- размер -- 1 байт
   *-----------------*
   |42|76|-7|11|98|01|  <-- какие-то значения
   *-----------------*
 ...42 43 44 45 46 47   <-- номера ячеек

Почему типовая информация важна:

  • Если x -- char, то в памяти x := 42 (1 байт)
  • если x -- short, то в памяти x := 0042 (2 байта)
  • если x -- int, то в памяти x := 00000042 (4 байта)
  • если x -- long, то в памяти x := 0000000000000042 (8 байт)

Поэтому наши указатели имеют типы.

Для работы с адресами в памяти есть две операции:

  • Взятие адреса: Если var -- это переменная, то &var -- это адрес этой переменной
  • Разыменование: Если ptr -- это указатель, то *ptr -- это значение по адресу, на который этот указатель указывает.

Соответственно теперь можно создавать переменные-указатели:

T *x; // x -- это указатель на адрес памяти, значение по которому будет рассматриваться как переменная типа T для произвольного типа T

Теперь мы в этот самый x можем положить какой-то адрес (к примеру, взять его с помощью операции взятия адреса). Ещё мы можем менять значения по этому адресу с помощью разыменования.

Пример:

void main() {
    int fourtyTwo = 42;
    int* ptr42 = &fourtyTwo;

    // далее *ptr42 ведёт себя как переменная типа int.

    *ptr42 = 58;

    printf("%d %d\n", fourtyTwo, *ptr42); // что выведет?
}

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

Пусть у нас существует массив

short A[6] = { 0x10, 0x24, 0x73, 0x63, 0x25, 0x00 };
     2б   2б   2б   2б   2б   2б   <-- размер -- 2 байта
   *-----------------------------*
   |0010|0024|0073|0063|0025|0000| <- значения
   *-----------------------------*
   |   0|   1|   2|   3|   4|   5| <- индексы
   *-----------------------------*
   |5812|5814|5816|5818|5820|5822| <-- номера ячеек
   *-----------------------------*

Посмотрим на следующий код:

  short *fst = &A[0]; // адрес первого элемента массива
  // чему будет равен адрес следующего элемента?
  if (&A[1] == fst + 1) {
      printf("aye!\n");
  } else {
      printf("nay!\n);
  }

Итак, если ptr -- это указатель на элементы типа T, то ptr+1 -- это адрес ячейки, в которой может лежать следующий элемент типа T. Ну и соответственно address(ptr+n) равен address(ptr) + n * sizeof(T)

Связь массивов и указателей.

Если A определён как int A[4], то что же хранит в себе переменная A?

if (A == &A[0]) {
    printf("aye!\n");
} else {
    printf("nay!\n");
}

Из вот этого примера следует, что A -- это указатель, хранящий в себе адрес первого элемента массива.

Динамическая память

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

Чтобы работать с динамической памятью, нам необходимо уметь делать 3 различных операции:

  1. выделить какой-то объем памяти
  2. освободить этот выделенный объем
  3. поменять размер выделенного объёма

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

Для выделения памяти служит функция malloc(size). Она принимает в качестве аргумента количество байт, которые мы должны выделить, а возвращает указатель на область памяти, в которую нам разрешено что-то писать.

Функция malloc возвращает нетипизированный указатель на память (это записывается как void*). Некоторые компиляторы требуют, чтобы программист явным образом приводил наши указатели к конкретному типу.

T* ptr = (T*) malloc(size);

Предположим, мы хотим выделить память под вот такую структуру:

typedef struct data {
    int p;
    data* something;
    char bytes[16];
} data;

Сколько нужно выделить байт, чтобы нам хватило? Для того чтобы нам ответить на этот вопрос, нам приходит на помощь уже известный нам оператор sizeof. sizeof(T) отвечает на вопрос, сколько байт требуется для того, чтобы записать переменную типa T.

Т.о., чтобы выделить память под такую структуру, мы пишем:

data * data_ptr = (data*) malloc(N * sizeof(data));

Теперь мы можем использовать data_ptr как массив из N элементов типа data (где N может быть выяснен на этапе выполнения программы).

Освобождение памяти

В динамическом классе памяти эту память после использования надо освобождать вручную.

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

Чтобы этой проблемы избежать, необходимо использовать функцию free(ptr).

Функция free(ptr) принимает параметром указатель на ранее выделенную область памяти, и освобождает её для дальнейшего использования (т.е. помечает её как неиспользуемую). После этого эта память может быть использована снова.

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

Иногда требуется сменить размер ранее выделенного участка памяти. Для этого используется метод realloc(ptr, new_size).

realloc принимает 2 параметра:

  1. первый параметр ptr -- это указатель, для которого мы хотим изменить размер выделенного участка;
  2. второй параметр new_size -- это новый размер, который мы хотим получить.

Важное замечание

Выделенная память не зануляется (не записывается нулями).

Для того, чтобы нам при выделении памяти её занулить, можно использовать функцию calloc.

calloc(size_t count, size_t size) принимает 2 параметра: количество выделенных ячеек, и размер каждой ячейки.

Она выделяет память в колличестве count * size байт, и зануляет эту память.

Символы и строки

Символ -- это число. Эти числа записываются в переменные типа char. У "странных" символов ('\n', ' ', '\t', '\0') тоже есть номера.

Строка -- это массив символов. Длина строки заранее неизвестна, а конец строки определяется специвльным символом '\0'.

В языке Си есть набор функций, которые позволяют прооизводить операции со строками. Они лежат в библиотеке с названием <string.h>, и их список можно узнать вот здесь: http://man7.org/linux/man-pages/man3/string.3.html

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment