Основни добри софтуерни практики - ООП, Седмица 1, 22.02.2024

Миналия семестър не ни стигна времето да обсъдим домашното по-сериозно. Главния проблем, който забелязах, беше колко грозно и нагласено бяха написани решенията. Като въведение в ООП, ще поговорим по тази тема.



Основни идеи
при добро писане на код

Какво прави програмиста с чужд код?

  1. Чете
  2. Разучава как се ползва/какво прави
  3. Използва го/Модифицира го

Добър код прави тези задачи лесни!

Подобряване на четене

 .  .  0.00.0 010010 815213
       000000 000000 738438
.    . .0000. 100001 122481
 ....  0....0 011110 511119

Както виждате, в примера има усмихнато личице, “закодирано” по различни начини. С точка и шпации е най-ясно, като заменим шпациите с нули става по-неясно, като заменим точките с единици става още повече и като заменим нулите със случайни не-единични цифри, става пък съвсем невидимо. Важното е шаблона да се намира в среда на други шаблони, иначе трудно се забелязва.

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

Труден за четене код (забелязвате ли грешките?):

int a, b;
std::cout << "Enter coordinates" << std::endl;
std::cin >> a;
if (a == -1) return layerNavigation(board, rows, cols, layers);
if (a < 0 || a >= rows || b < 0 || a >= cols) return false;
for (int i = 0; i < layers; i++) {
if (board[a][b][i] == ' ') {
    for (int j = 0; j < 8; j++){
    if (enptyRow[j] != ' ') {
            emptyRow[j] = board[a][b][i];
            board[a][b][i] = ' ';
            return true;
        }
    }
    return false;
}
}

Нарочно съм премахнал оцветяването, то драстично помага, но тук повече се интересуваме от “формата” на код.

Вмъкнал съм три грешки: едно нещо липсва, има една печатна и една логическа грешка. Разбира се, ако се зачетете дума по дума сигурно ще ги откриете, но сега да сравним.

По-лесен за четене код (а сега виждат ли се по-лесно?):

std::cout << "Enter coordinates" << std::endl;
int a, b;

std::cin >> a;
if (a == -1) {
    return layerNavigation(board, rows, cols, layers);
}

if (a < 0 || a >= rows || b < 0 || a >= cols) {
    return false;
}

for (int i = 0; i < layers; i++)
{
    if (board[a][b][i] == ' ')
    {
        for (int j = 0; j < 8; j++)
        {
            if (enptyRow[j] != ' ') {
                emptyRow[j]    = board[a][b][i];
                board[a][b][i] = ' ';

                return true;
            }
        }
        return false;
    }
}

Би трябвало да е малко по-лесно, в последния if имаме “enptyRow”, което е печатната грешка, != което трябва да бъде == и липсва въвеждането на b след първия if.

Примерът не е перфектен, но илюстрира какво се опитвам да кажа: формата на код е от огромно значение, обръщайте ѝ внимание.

Абстракцията - улеснява изучаване И употреба/модификация

Нека сега разгледаме няколко примера, първият е много груб и елементарен, но служи за добро начало:

Лошо - doX()                    │ Добро - do(X)
                                │ 
int fib5()                      │ int fib(int n)
{                               │ {
    int prev = 0, curr = 1;     │     int prev = 0, curr = 1;
    for (int i = 1; i < 5; i++) │     for (int i = 1; i < n; i++)
    {                           │     {
        int tempPrev = prev;    │         int tempPrev = prev;
        prev = curr;            │         prev = curr;
        curr = curr + prev;     │         curr = curr + prev;
    }                           │     }
    return curr;                │     return curr;
}                               │ }

Би трябвало да е супер очевидно, никой няма да пише отделна функция на Фибоначи за всяко число.

Нека сега да разгледаме няколко “реални” примера, свързани с домашното (за припомняне, условието се намира тук). Самите решения няма винаги да са добре форматирани, това е направено с цел да се поберат добре в слайда.

Първия е върху функция, която приема координатите на плочка, премахва я от дъската (ако съществува) и я слага в края на празния ред.

Лошо - doX() в тялото на функцията

void tryTakeTile(char** board, int rows, int cols,
                 char emptyRow[8]) {
    int x, y;
    std::cin >> x >> y;
    if (x<0 || x>=rows || y<0 || y>=cols || board[x][y] == ' ') {
        std::cout << "Invalid coordinates!" << std::endl;
    }
    else {
        int emptyRowIndex = -1;
        for (int i = 0; i < 8; i++) {
            if (emptyRow[i] == ' ') {
                emptyRowIndex = i;
                break;
            }
        }
        emptyRow[emptyRowIndex] = board[x][y];
        board[x][y] = ' ';
    }
}

Повечето предадени работи са имплементирали функционалността по точно този начин. Замислете се обаче, какво става ако сменим типа данна, тоест ако дъската е триизмерна? Тогава трябва да добавим още една int променлива, още едно условие в if, трябва да я добавим при трите индексирания на board.

Какво става ако променим самата структура от данни за emptyRow (за сега сте учили само масиви, но скоро ще видите, че има и други начини да съхраняваш редица информация), тогава ще трябва да пренапишем целия цикъл.

Тоест, ако променим нещо, трябва да пренапишем значителни части от самия код, и като разширим това върху 10-20 функции, изведнъж малка промяна става голяма. Нека сега да пренапишем функцията, използвайки колкото се може повече абстракция.

Добро - do(X)

bool tryReadCoordinates(int& x, int& y) {
    std::cin >> x >> y;
    if (!availablePosition(board, rows, cols, x, y)) {
        std::cout << "Invalid coordinates!" << std::endl;
        return false;
    }
    return true;
}

void tryTakeTile(char** board, int rows, int cols,
                 char emptyRow[8]) {
    int x, y;
    if (tryReadCoordinates(x, y)) {
        appendToArray(emptyRow, 8, board[x][y]);
        board[x][y] = ' ';
    }
}

Искаме трето измерение на board? Няма проблем, написали сме нова tryReadCoordinates, която приема три променливи, и в tryTakeTile добавяме една декларация и обновяваме двете индексирания на board. Ако emptyRow стане друг тип данна, просто променяме функцията да бъде appendToOtherDataType и сме готови.

И ако тази промяна трябва да се направи в много функции, даже бихме могли да употребяваме функционалност на самия текстов редактор да търси и заменя срещания на един низ с друг.

Нека сега да увеличим сложността още малко, да разгледаме функция за генерирането на дъската. Отново, повечето предадени решения изглеждаха подобни на примера.

Лошо - всички цикли са doX()

void generateLayer(char** layer, int rows, int cols,
                   char* tiles, int* tileCounts, int tilesSize) {
    for (int i = 0; i < rows; i++) {
        for (int j = 0; j < cols; j++) {
            layer[i][j] = ' ';
        }
    }
    for (int i = 0; i < tilesSize; i++) {
        for (int j = 0; j < tileCounts[i]; j++) {
            int x, y;
            do { // randNum(x,y) връща случайно число∈[x,y]
                x = randNum(0, rows - 1);
                y = randNum(0, cols - 1);
            } while(layer[x][y] != ' ');
            layer[x][y] = tileCounts[i];
        }
    }
}

Стария проблем, някакви цикли, ако искаме да променям някоя данна или как нещо работи, трябва да пренапишем половината функция.

Този сегмент е по-интересен за променяне от преди, понеже за да го направим хубаво трябва да променим самата ни логика. Запълването със шпации е ясно, обаче случайното поставяне на плочки не е.

Ако имаме функция placeRandomЕлемент която само поставя една плочка в слоя, пак ще имаме вложени цикли, трябва да има начин да останем с един цикъл. Ако направим placeRandomElementNTimes, тогава пък абстрахираната функция става твърде специфична и вече имаме doX() при самата нея.

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

Добро - do(X)

void generateLayer(char** layer, int rows, int cols,
                   char* tiles, int* tileCounts, int tilesSize) {
    fillMatrix(layer, rows, cols, '\0');
    for (int i = 0; i < tilesSize; i++) {
        // appendToMatrix(mat,rows,cols,fill,max):
        // замества първите (най-много) max празни клетки с fill
        // връща колко клетки наистина е заместило
        appendToMatrix(layer, rows, cols,
                       tiles[i], tileCounts[i]);
    }
    shuffleMatrix(layer, rows, cols);
}

Бонус: работи по-коректно от преди

В предходния вариант, ако примерно цялата е пълна освен една клетка, и трябва да поставим една финална плочка, тогава трябва да чакаме докато случайните стойности се напаснат точно с координатите на тази една клетка. Това може да се случи след половин секунда, но може да отнеме и един час докато се случи. Даже, този проблем става все по-тегав с времето: с всяка запълнена клетка, шанса случайните стойности да ударят на заети координати се увеличава.

Докато тук, нямаме този проблем, отнема някакво консистентно предопределено време да разбъркаме матрицата. Как имплементираме shuffleMatrix си има и своите тънкости, но най-елементарния начин е просто да направим сортираш алгоритъм, при който вместо да сравним две стойности, използваме случайна стойност, която определя дали ще разменим плочките.

Друго хубаво нещо на решения с абстракция, е един лек лавинен ефект, където веднъж като почнем да мислим за абстракции, намираме къде да ги ползваме навсякъде. Да разгледаме същата функция, обаче при D подточка.

Решения със слоеве правят същото. Лошо - doX() при циклите:

void generate(char*** board, int rows, int cols, int layers,
              char* tiles, int* tileCounts, int tilesSize) {
    ...
    do {
        x = randNum(0, rows - 1); y = randNum(0, cols - 1);
        layer = randNum(0, layers - 1);
    } while(layer[x][y][layer] != ' ');
    ...
}

Без абстракция отново ще пренапишем голяма част от generate функцията, за да се побере на слайда съм оставил само “най-сложната” промяна - случайните стойности. Пак, имаше решения на подточката, без преизползване на generate за един слой. Айде да разгледаме решението, заедно с абстрахираната generate.

Добро - do(X)

void generateLayer(char** layer, int rows, int cols,
                   char* tiles, int* tileCounts, int tilesSize) {
    fillMatrix(layer, rows, cols, '\0');
    for (int i = 0; i < tilesSize; i++) {
        int actuallyAppended =
                       appendToMatrix(layer, rows, cols,
                                      tiles[i], tileCounts[i]);
        tileCounts[i] -= actuallyAppended;
    }
    shuffleMatrix(layer, rows, cols);
}
void generate(char*** board, int rows, int cols, int layers,
              char* tiles, int* tileCounts, int tilesSize) {
    for (int i = 0; i < layers; i++) {
        generateLayer(board[i], rows, cols,
                      tiles, tileCounts, tilesSize);
    }
}

Супер елементарно, има-няма 5-6 реда код промяна, при генериране за всеки слой, просто извикваме функцията за генериране върху един слой, за всеки слой. А в самата функция само премахваме броя вмъкнати плочки от сумата на даден тип.

Наблюдателните са забелязали, че в “ръчното” решение слоевете са третия индекс, тоест имаме board[x][y][layer], докато в това решение е първия, тоест board[layer][x][y]. Разликата е значителна, но от гледна точка на код, особено когато използваме абстракция, промяната е лесна.

Иначе, в момента се намираме в много добра позиция, без абстракция как правим рестартирането на играта? По условие броя и типовете плочки трябва да се запазят, но позиции не. Истината е, че става малко тромаво: цикълът за намираме на позиции се преизползва, и броя плочки трябва да се запази.

Обаче, сега това става с едно shuffleMatrix, не е нужно нищо специално да правим!

Абстракцията - подобрява и коментари

  • Целта на коментарите е да направи разбиране и модификация на код по-лесни.
  • В повечето случаи, коментарът трябва да ни дава нова информация или да свърже парчета информация

Да разгледаме следния пример.

Лошо:

// Fill matrix with '\0'
for (int i = 0; i < rows; i++) {
    for (int j = 0; j < cols; j++) {
        matrix[i][j] = '\0';
    }
}

Добро:

// Prepare matrix for functionX
// (for which empty cells are '\0')
for (int i = 0; i < rows; i++) {
    for (int j = 0; j < cols; j++) {
        matrix[i][j] = '\0';
    }
}

При добрия вариант, получаваме нова информация, че това запълване се случва понеже ще подаваме към някаква си functionX, и че евентуално може да има други функции, при които празни клетки може да не се обозначават с '\0'. В лошия можем да стигнем до същото заключение като погледнем самия код.

Умението да разрешиш един проблем не те прави добър програмист, да го разрешиш по четим, разширяем, лесен, ефикасен, … начин те прави добър програмист.

Изводи