Уроки > Практика использования функций: декомпозиция и рефакторинг программы

Мы не продаём знания — мы ими делимся!

Сделать вклад

на развитие и поддержку курса

Практическое применение функций в языке Си

В этой заметке на примере программы «Игральный кубик» из прошлых уроков я бы хотел проиллюстрировать некоторые возможности использования функций.

Вспомним нашу программу «Игральный кубик» v.3.0 из урока про циклы
Листинг 1. Программа «Игральный кубик» v.3.0

#include <stdio.h>
#include <stdlib.h>
#include <time.h>

int main(void)
{
        srand(time(NULL));

        printf("#########  Devil\'s bones   #########\n");
        printf("#                                   #\n");
        printf("#   Commands:                       #\n");
        printf("#                                   #\n");
        printf("#   1 - new game                    #\n");
        printf("#   0 - exit                        #\n");
        printf("#                                   #\n");
        printf("#####################################\n\n");

        int control;
        int value = 0, 
            total_runs = 0,     // количество бросков
            sum_results = 0;    // сумма очков за всю игру
        
        do {
                printf("Enter command: ");
                scanf("%d", &control);
                
                switch (control) {
                        case 0: 
                                printf("\nGood bye!\n");
                                printf("Total number of die rolls: %d\n", total_runs);
                                printf("Sum of all die rolls: %d\n", sum_results);
                                break;
                        case 1:
                                value = 1 + rand() % 6;
                                printf("Result: %d\n", value);

                                // увеличиваем кол-во бросков на 1
                                total_runs += 1;        

                                // добавляем значение к сумме очков
                                sum_results += value; 
                                break;
                        default:
                                printf("Error! Try again...\n");
                                break;
                }

        } while (control != 0); 

        return 0;
}

Теперь давайте проведём небольшой рефакторинг этого кода, т.е. поработаем над его структурой.

Для начала хорошо бы выделить части кода, которые можно вынести в отдельные функции. Подобную операцию иногда называют декомпозицей, т.е. разделением одной большой программы на ряд небольших вспомогательных программ. Правило "Divide and conquer" работает и в программировании.

Во-первых, сразу бросаются в глаза два куска кода, где есть последовательные вызовы функции printf:

  1. вывод приветственного экрана;
  2. вывод статистики игры;

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

Ну, с именами всё просто: show_intro_screen и show_exit_screen. С возвращаемыми значениями тоже, ни та, ни другая функция ничего вычислять не должны, а должны просто выводить информацию на экран, значит это будут void-функции, иногда так называют функции, которые не возвращают никакого значения. Что же касается формальных параметров, то у функции show_intro_screen их не будет, т.к. она выводит на экран захардкоженное сообщение, которое ни от чего не зависит. А вот функция show_exit_screen должна выводить на экран статистику игры: общее количество бросков и сумму выпавших очков, т.е. она зависит от двух параметров. Оба параметра целые числа.

Исходя из вышесказанного, запишем прототипы получившихся функций:
Листинг 2.

void show_intro_screen(void);
void show_exit_screen(int runs, int sum);

Исходя из этих прототипов опишем реализации функций show_intro_screen и show_exit_screen.

Листинг 3.

void show_intro_screen(void)
{
        printf("#########  Devil\'s bones   #########\n"
               "#                                   #\n"
               "#   Commands:                       #\n"
               "#                                   #\n"
               "#   1 - new game                    #\n"
               "#   0 - exit                        #\n"
               "#                                   #\n"
               "#####################################\n\n");
}


void show_exit_screen(int runs, int sum)
{
        printf("\nGood bye!\n");
    
        printf("Total number of die rolls: %d\n", runs);
        printf("Sum of all die rolls: %d\n", sum);
}

Тут ничего особо сложного. Я буквально просто скопировал код и внёс небольшие косметические изменения. В функции show_intro_screen я использовал подход, который называется string literal concatenation. Я описывал его в дополнительных материалах к уроку про printf.

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

Давайте объединим наши наработки из Листингов 2 и 3 в единый код.

Листинг 4. «Игральный кубик» v.3.1

#include <stdio.h>
#include <stdlib.h>
#include <time.h>

void show_intro_screen(void);
void show_exit_screen(int runs, int sum);

int main(void)
{
        srand(time(NULL));

        show_intro_screen();

        int control;
        int value = 0, 
            total_runs = 0,     // количество бросков
            sum_results = 0;    // сумма очков за всю игру
        
        do {
                printf("Enter command: ");
                scanf("%d", &control);
                
                switch (control) {
                        case 0: 
                                show_exit_screen(total_runs, sum_results);
                                break;
                        case 1:
                                value = 1 + rand() % 6;
                                printf("Result: %d\n", value);

                                total_runs += 1;        
                                sum_results += value; 
                                break;
                        default:
                                printf("Error! Try again...\n");
                                break;
                }

        } while (control != 0); 

        return 0;
}


void show_intro_screen(void)
{
        printf("#########  Devil\'s bones   #########\n"
               "#                                   #\n"
               "#   Commands:                       #\n"
               "#                                   #\n"
               "#   1 - new game                    #\n"
               "#   0 - exit                        #\n"
               "#                                   #\n"
               "#####################################\n\n");
}


void show_exit_screen(int runs, int sum)
{
        printf("\nGood bye!\n");
    
        printf("Total number of die rolls: %d\n", runs);
        printf("Sum of all die rolls: %d\n", sum);
}

Главная функция main стала гораздо чище.

Теперь давайте вынесем в отдельную функцию вычисление значения, выпавшего на кубике. Сейчас это вычисляется в строке value = 1 + rand() % 6;.

Назовём функцию generate_dice_value. В качестве выходного значения она должна возвращать целое число — количество очков, выброшенное на кубике. А что со входными параметрами? В принципе, можно оставить void, но я предлагаю добавить один целочисленный параметр — количество граней у кубика, так мы сделаем код программы чуть более универсальным и расширяемым.

Листинг 5. Определение функции generate_dice_value

int generate_dice_value(int dice_size)
{
        return 1 + rand() % dice_size;
}

Можно ли было убрать в функцию generate_dice_value строку
printf("Result: %d\n", value);? В принципе, конечно, так можно сделать. Но, на мой вкус, не желательно.

Во-первых, тогда у функции generate_dice_value появляется побочный эффект. А зачем лишний раз портить функцию побочным эффектом? Незачем!

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

Рекомендация: функция должна решать одну конкретную задачу. И желательно, решать её очень хорошо.

Если бы я добавил в эту функцию ещё и вывод строки на экран, то это уже было бы два действия. А когда функция выполняет два действия, то ей сложно придумать хорошее имя. Не называть же её generate_dice_value_and_print_result?

Сейчас у кого-нибудь появился вопрос, а почему бы тогда не декомпозировать функцию show_exit_screen ещё на две функции, например: say_goodbye и show_game_statistics, а show_game_statistics в свою очередь декомпозировать ещё на две функции?

В принципе, так сделать можно, никто не запрещает. Но это уже будет не элегантно, на мой взгляд, поэтому я так делать не стал. Можно ведь буквально каждую простую инструкцию обернуть в функцию, но это будет чересчур, так скажем избыточная декомпозиция. А как же рекомендация, спросите вы? А я отвечу: кодекс — это всего лишь свод указаний, а не жестких законов это всего лишь рекомендация!

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

Дополнительно, я создал отдельную переменную DICE_SIZE в функции main в которой будет храниться количество граней на нашей игральной кости.

Листинг 6. Программа «Игральный кубик» v.3.2

#include <stdio.h>
#include <stdlib.h>
#include <time.h>

void show_intro_screen(void);
void show_exit_screen(int runs, int sum);

int generate_dice_value(int dice_size);

int main(void)
{
        srand(time(NULL));

        show_intro_screen();

        int control;
        int value = 0,
            DICE_SIZE = 6,      // количество граней у кубика
            total_runs = 0,     // количество бросков
            sum_results = 0;    // сумма очков за всю игру
        
        do {
                printf("Enter command: ");
                scanf("%d", &control);
                
                switch (control) {
                        case 0: 
                                show_exit_screen(total_runs, sum_results);
                                break;
                        case 1:
                                value = generate_dice_value(DICE_SIZE);
                                printf("Result: %d\n", value);

                                total_runs += 1;        
                                sum_results += value;
                                break;
                        default:
                                printf("Error! Try again...\n");
                                break;
                }

        } while (control != 0); 

        return 0;
}


int generate_dice_value(int dice_size)
{
        return 1 + rand() % dice_size;
}


void show_intro_screen(void)
{
        printf("#########  Devil\'s bones   #########\n"
               "#                                   #\n"
               "#   Commands:                       #\n"
               "#                                   #\n"
               "#   1 - new game                    #\n"
               "#   0 - exit                        #\n"
               "#                                   #\n"
               "#####################################\n\n");
}


void show_exit_screen(int runs, int sum)
{
        printf("\nGood bye!\n");
    
        printf("Total number of die rolls: %d\n", runs);
        printf("Sum of all die rolls: %d\n", sum);
}

В принципе, пока можно и остановиться. Можно, а зачем?

Пока есть возможность, я хочу рассказать вам ещё пару штук.

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

Все переменные, которые мы объявляем внутри функций являются локальными. Они видны только в пределах той функции, в которой они объявлены. Но, вообще говоря, мы могли бы объявить переменную вне любых функций, т.е. даже вне функции main. Такие переменные называются глобальными переменными.

У них две основные особенности:

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

Давайте напишем очередную версию программы «Игральный кубик», но с использованием глобальных переменных. Глобальными сделаем переменные DICE_SIZE, total_runs и sum_results.

Листинг 7: Программа «Игральный кубик» v.3.3

#include <stdio.h>
#include <stdlib.h>
#include <time.h>

void show_intro_screen(void);
void show_exit_screen(void);

int generate_dice_value(void);

int DICE_SIZE = 6,  // количество граней у кубика
    total_runs,     // количество бросков
    sum_results;    // сумма очков за всю игру

int main(void)
{
        srand(time(NULL));

        show_intro_screen();

        int control;
        int value = 0;
        
        do {
                printf("Enter command: ");
                scanf("%d", &control);
                
                switch (control) {
                        case 0: 
                                show_exit_screen();
                                break;
                        case 1:
                                value = generate_dice_value();
                                printf("Result: %d\n", value);

                                total_runs += 1;        
                                sum_results += value;
                                break;
                        default:
                                printf("Error! Try again...\n");
                                break;
                }

        } while (control != 0); 

        return 0;
}


int generate_dice_value(void)
{
        return 1 + rand() % DICE_SIZE;
}


void show_intro_screen(void)
{
        printf("#########  Devil\'s bones   #########\n"
               "#                                   #\n"
               "#   Commands:                       #\n"
               "#                                   #\n"
               "#   1 - new game                    #\n"
               "#   0 - exit                        #\n"
               "#                                   #\n"
               "#####################################\n\n");
}


void show_exit_screen(void)
{
        printf("\nGood bye!\n");
    
        printf("Total number of die rolls: %d\n", total_runs);
        printf("Sum of all die rolls: %d\n", sum_results);
}

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

Во-вторых, т.к. глобальные переменные по умолчанию инициализируются нулевыми значениями (в отличие от локальных), то при объявлении переменных total_runs и sum_results опущена инициализация нулями.

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

Есть ли способ защитить значение переменной от изменения внутри программы? Да, такой способ есть.

Квалификатор const

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

Вот простой пример:

Листинг 8: Пример использования квалификатора const

int main(void)
{
    const int players = 2;
    players = 3;  // ОШИБКА! Пытаемся изменить переменную с квалификатором const
    
    return 0;
}

Обратите внимание, что const можно использовать не только с глобальными, но и с локальными переменными. В том числе и с переменными из параметров функций.

Что ж, давайте защитим нашу переменную DICE_SIZE от случайного изменения какими-либо функциями.

Листинг 9: Программа «Игральный кубик» v.3.4 с защитой глобальной переменной DICE_SIZE

#include <stdio.h>
#include <stdlib.h>
#include <time.h>

void show_intro_screen(void);
void show_exit_screen(void);

int generate_dice_value(void);


const int DICE_SIZE = 6;  // количество граней у кубика
int total_runs,           // количество бросков
    sum_results;          // сумма очков за всю игру

int main(void)
{
        srand(time(NULL));

        show_intro_screen();

        int control;
        int value = 0;
        
        do {
                printf("Enter command: ");
                scanf("%d", &control);
                
                switch (control) {
                        case 0: 
                                show_exit_screen();
                                break;
                        case 1:
                                value = generate_dice_value();
                                printf("Result: %d\n", value);

                                total_runs += 1;        
                                sum_results += value;
                                break;
                        default:
                                printf("Error! Try again...\n");
                                break;
                }

        } while (control != 0); 

        return 0;
}


int generate_dice_value(void)
{
        return 1 + rand() % DICE_SIZE;
}


void show_intro_screen(void)
{
        printf("#########  Devil\'s bones   #########\n"
               "#                                   #\n"
               "#   Commands:                       #\n"
               "#                                   #\n"
               "#   1 - new game                    #\n"
               "#   0 - exit                        #\n"
               "#                                   #\n"
               "#####################################\n\n");
}


void show_exit_screen(void)
{
        printf("\nGood bye!\n");
    
        printf("Total number of die rolls: %d\n", total_runs);
        printf("Sum of all die rolls: %d\n", sum_results);
}

Теперь переменная DICE_SIZE под защитой квалификатора const.

ВАЖНО! Старайтесь не использовать глобальные переменные без крайней необходимости. А если используете, то будьте предельно аккуратны.

Можно ли оставить программу в таком виде? Формально — можно, ведь всё работает. Но мне текущий вариант как-то не нравится. Не хочется мне связывать с глобальными переменными. Рассказал о них только для того, чтобы вы были в курсе, что такой механизм имеется.

Поэтому на основе Листинга 6 я сделаю новую версию программы: без глобальных переменных, с "защитой" через const переменной DICE_SIZE и параметров, которые функции только читают. Кроме того, я изменю названия некоторых переменных на более удачные варианты.

Листинг 10: Программа «Игральный кубик» v.3.5

#include <stdio.h>
#include <stdlib.h>
#include <time.h>

void show_intro_screen(void);
void show_exit_screen(const int runs, const int sum);

int generate_dice_value(int dice_size);

int main(void)
{       
        const int DICE_SIZE = 6;  // количество граней у кубика      
        int total_runs = 0,       // количество бросков
            sum_results = 0,      // сумма очков за всю игру
            cmd,                  // текущая команда 
            value;                // текущее значение на кубике

        srand(time(NULL));
        show_intro_screen();

        do {
                printf("Enter command: ");
                scanf("%d", &cmd);
                
                switch (cmd) {
                        case 0: 
                                show_exit_screen(total_runs, sum_results);
                                break;
                        
                        case 1:         
                                value = generate_dice_value(DICE_SIZE);
                                printf("Result: %d\n", value);

                                total_runs += 1;        
                                sum_results += value;
                                break;
                        
                        default:
                                printf("Error! Try again...\n");
                                break;
                }

        } while (cmd != 0); 

        return 0;
}


int generate_dice_value(int dice_size)
{
        return 1 + rand() % dice_size;
}


void show_intro_screen(void)
{
        printf("#########  Devil\'s bones   #########\n"
               "#                                   #\n"
               "#   Commands:                       #\n"
               "#                                   #\n"
               "#   1 - new game                    #\n"
               "#   0 - exit                        #\n"
               "#                                   #\n"
               "#####################################\n\n");
}


void show_exit_screen(const int runs, const int sum)
{
        printf("\nGood bye!\n");
    
        printf("Total number of die rolls: %d\n", runs);
        printf("Sum of all die rolls: %d\n", sum);
}

Конечно, здесь есть ещё над чем поработать, но давайте пока остановимся и подведём промежуточные итоги.

Сразу заметно, что код стал объёмнее (количество строк увеличилось). Это, конечно, не очень приятно.

НО, во-первых, теперь код имеет некоторое подобие внутренней структуры. Он написан не одним сплошным куском, а состоит из отдельных небольших функций, каждая из которых решает свою локальную небольшую задачу. Две из них можно назвать "интерфейсными", а одну "игровой".
И хотя общий объём кода вырос, каждый отдельный кусочек кода довольно прост и понятен. А большинство второстепенных деталей убрано из главной функции main.

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

Напоследок ещё раз напоминаю, не пренебрегайте декомпозицией даже в ваших небольших учебных программах, но избегайте бестолковой декомпозиции. Побольше практикуйтесь, ведь практика — путь к совершеству!

Когда имеет смысл выносить код в функцию:

  • если фрагмент кода повторяется в нескольких местах программы;
  • если часть кода выглядит как отдельный законченный шаг/подзадача алгоритма;

Практика

  • Убедитесь, что при компиляции программы из Листинга 8 появляется ошибка компиляции.

  • Какие из функций программы Листинг 10 являются функциями в истинном смысле этого слова, а какие скорее похожи на процедуры? Свой ответ и пояснения к нему напишите в комментариях к уроку.

  • Попробуйте в теле функции show_exit_screen из Листинга 10 явно изменить значение одного из параметров. Какую ошибку выдаст компилятор?

  • Посмотрите на код функции main из Листинга 10. Какие ещё фрагменты можно было бы вынести в отдельные функции? Предложите прототип таких функций в комментариях к уроку.

  • Попробуйте вызвать функцию printf вне любых функций, там где мы объявляли глобальные переменные. Что об этом думает ваш компилятор? О результатах пишите в комментариях к уроку.

Дополнительные материалы

  1. Есть способ задать в программе что-то вроде реально неизменяемого константного значения. Для этого используется директива препроцессора #define. Мы изучим, как она работает в уроке, посвящённом массивам.

  2. Насколько серьёзно квалификатор типа const защищает переменную? Ну, как сказать... Относительно хорошо. Случайно, конечно, такую переменную изменить не получится, но если очень захотеть, то, способ имеется. Для этого нужно использовать указатели. Наше первое знакомство с ними состоится буквально в следующей заметке!

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

// код до циклов

int break_flag = 0;

for (int i = 0; i < N; i++) {
    for (int j = 0; j < M; j++) {
        for (int k = 0; k < K; k++) {
            
            if (условие завершения всех трёх циклов) {
                break_flag = 1; // ставим значение флага 1
                break;          // выходим только из цикла по k
            }
        }
        if (break_flag) break;  // если флаг 1, то выходим из цикла по j
        // какой-то код
    }

    if (break_flag) break;      //  если флаг 1, то выходим из цикла по i
    // ещё какой-то код
}

// код после циклов

Другой вариант — вынести блок с циклами в отдельную функцию и вызвать её. Код выше превратится во что-то такое:

// код до циклов

nested_cycles(N, M, K);

// код после циклов

... 

// объявление функции с вложенными циклами
void nested_cycles(int N1, int N2, int N3)
{
    for (int i = 0; i < N1; i++) {
        for (int j = 0; j < N2; j++) {
            for (int k = 0; k < N3; k++) {
                
                if (условие завершения всех трёх циклов) {
                    return; // завершаем выполнение функции 
                            // и автоматически выходим из всех вложенных циклов
                }

            }
            // какой-то код

        }
        // ещё какой-то код

    }
}

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

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