Good practices

Bitdefender este un lider recunoscut în domeniul securității IT, care oferă soluții superioare de prevenție, detecție și răspuns la incidente de securitate cibernetică. Milioane de sisteme folosite de oameni, companii și instituții guvernamentale sunt protejate de soluțiile companiei, ceea ce face Bitdefender cel mai de încredere expert în combaterea amenințărilor informatice, în protejarea intimității și datelor și în consolidarea rezilienței la atacuri. Ca urmare a investițiilor susținute în cercetare și dezvoltare, laboratoarele Bitdefender descoperă 400 de noi amenințări informatice în fiecare minut și validează zilnic 30 de miliarde de interogări privind amenințările. Compania a inovat constant în domenii precum antimalware, Internetul Lucrurilor, analiză comportamentală și inteligență artificială, iar tehnologiile Bitdefender sunt licențiate către peste 150 dintre cele mai cunoscute branduri de securitate din lume. Fondată în 2001, compania Bitdefender are clienți în 170 de țări și birouri pe toate continentele. Mai multe detalii sunt disponibile pe www.bitdefender.ro.

Resposabili:

  • Cristi Olaru
  • Cristi Pătrașcu
  • Cristi Popa
  • Darius Neațu
  • Liza Babu
  • Radu Nichita

Cuprins

Materialul prezent își propune să prezinte câteva din modalitățile prin care putem face codul scris să poate fi înțeles și extins de o altă persoană, precum și câteva modalități de a scrie cod modularizat, ce respectă un anumit stil de coding-style.


Notă: Lista de bune practici din acest document nu este exhaustivă, ci cuprinde doar câteva din cele mai importante aspecte pentru a scrie clean code.


De ce cod organizat?

Majoritatea aplicațiilor din prezent se scriu în echipă, pe o perioadă mai mare de timp. Astfel, un proiect poate avea anumite caracteristici la început (ce sunt bine definite), dar ulterior se mai pot adăugă noi funcționalițăți (numite și features).

Este important să cunoaștem bune practici de a scrie cod!

Exemplu lipsă organizare

Frecvent, se întâmplă să punem fișierele în primul loc disponibil (de obicei, cele care sunt descărcate vor rămâne în directorul Downloads până ajung în Trash / Recycle Bin). Când e nevoie de un anumit fișier, este foarte posibil să nu îl găsim unde ne așteptăm și să fim nevoiți să alocăm mai mult timp cu căutarea. Similar, în codul scris, puteți să căutați ceva timp o anumită bucată de cod sau puteți să nu mai înțelegeți o funcție (cel mai frecvent: main în care e scrisă toată logica programului) care are foarte multe linii.


****Experiment****: salvați un cod sursă C scris la prima temă și comparați-l cu un cod scris peste 1 - 7 semestre. Încercați să înțelegeți rezolvarea problemei fără a citi cerința.


Aplicații complexe și care sunt foarte mult folosite în prezent, precum Facebook, Twitter, Instagram, nu au fost concepute să aibă toate opțiunile din prezent, ci au fost extinse de-a lungul timpului. Echipele implicate s-au schimbat / modificat, însă codebase-ul a rămas același (există mai multe motive pentru care se reutilizează / extinde codul de la o aplicație existentă, printre care timpul de dezvoltare și usurința mentinerii unei singure instanțe de cod în producție - cu mici variații).

Organizare surse

Scrierea codului unei aplicații complexe presupune un codebase mare, cu multe funcții și structuri folosite. Ținerea acestora într-un singur fișier îngreunează citirea și înțelegerea codului pe termen mai lung.

Soluția este să mutăm componentele care se ocupă de o anumită parte a aplicației în fișiere separate, pentru a putea urmări mai bine funcții / structuri care au un scop similar (de exemplu, funcțiile care fac operații aritmetice pe matrici ar putea fi într-un fișier separat de funcțiile care se ocupă de prelucrarea imaginilor).

De multe ori, când lucrăm cu o funcție, dorim să cunoaștem doar modul de trimitere al parametrilor, nefiind necesară neapărat înțelegerea modului în care a fost scrisă funcția (de exemplu, pentru funcția fseek ne interesează rezultatul - că se mută cursorul în fișier, nu cum se face acest lucru în sistemul de operare).

Pentru a delimita partea de implementarea funcției de partea de întelegere a modului de apelare, folosim fișiere sursă (cu extensia .c) și fișiere antet (sau header - cu extensia .h). În fișierele header scriem:

  • semnăturile funcțiilor
  • tipuri noi de date definite de programator (structuri, uniuni, enum-uri)
  • constante / macrouri

În fișierele sursă scriem:

  • implementările funcțiilor din header
  • implementările altor funcții care sunt folosite doar în fișierul curent (și declarate de obicei cu specificatorul static)
  • alte tipuri de date / constante / macrouri care sunt folosite doar în fișierul în care sunt declarate

Avantajele folosirii unei organizări logice și structurate în mai multe fișiere ne permite:

  • dezvoltare mai rapidă - spargerea codului în mai multe părți contribuie la găsirea, înțelegerea și modificarea părții de cod necesare.
  • reutilizarea codului - diferite “module” de cod pot fi separate în grupuri de fișiere sursă care pot fi ușor integrate și în alte programe.

Exemplu

Presupunem că avem 3 funcții care fac operații asupra unui vector:

  • sum_vector → calculează suma elementelor unui vector v cu n elemente.
  • sort_vector → sortează un vector cu n elemente.
  • print_vector → afișează un vector cu n elemente într-un mod custom.

Dacă toate funcțiile ar fi implementate în același fișier, el ar arăta similar cu cel de mai jos.

// main.c
#include <stdio.h>
 
// sum of a vector's elements
int sum_vector(int n, int* v)
{
    int sum = 0;
    for (int i = 0; i < n; ++i) {
        sum += v[i];
    }
    return sum;
}
 
// sort a vector
void sort_vector(int n, int* v)
{
    for (int i = 0; i < n - 1; ++i) {
        for (int j = i + 1; j < n; ++j) {
            if (v[i] > v[j]) {
                int aux = v[i];
                v[i] = v[j];
                v[j] = aux;
            }
        }
    }
}
 
// print a vector
// e.g. "[ 10, 20, 30 ]"
void print_vector(int n, int *v)
{
    printf("[ ");
    for (int i = 0; i < n; ++i) {
        printf("%d ", v[i]);
    }
    printf("]\n");
}
 
int main(void)
{
    int v[] = {2, 3, 5, 10};
    int n = sizeof(v) / sizeof(v[0]);
 
    printf("Sum is %d\n", sum_vector(n, v));
 
    sort_vector(n, v);
    printf("Sorted vector is: ");
    print_vector(n, v);
 
    // other operations with v
 
    return 0;
}

Ne propunem să avem o structură organizată cu fișiere. Pentru asta, o să creăm un fișier vector.h unde vom pune semnăturile funcțiilor și un fișier vector.c unde vom pune implementările efective ale funcțiilor.

// vector.h
#pragma once
 
// sum of a vector's elements
int sum_vector(int n, int* v);
 
// sort a vector
void sort_vector(int n, int* v);
 
// print a vector
void print_vector(int n, int *v);
// vector.c
#include <stdio.h>
 
#include "vector.h"
 
int sum_vector(int n, int* v)
{
    int sum = 0;
    for (int i = 0; i < n; ++i) {
        sum += v[i];
    }
    return sum;
}
 
void sort_vector(int n, int* v)
{
    for (int i = 0; i < n - 1; ++i) {
        for (int j = i + 1; j < n; j++) {
            if (v[i] > v[j]) {
                int tmp = v[i];
                v[i] = v[j];
                v[j] = tmp;
            }
        }
    }
}
 
void print_vector(int n, int *v)
{
    printf("[ ");
    for (int i = 0; i < n; ++i) {
        printf("%d ", v[i]);
    }
    printf("]\n");
}

pragma once

Observație : pragma once este o directivă preprocesor folosită pentru a spefica ca fișierul să fie inclus o singură date. (pentru a evita diferite probleme cu includeri de fișiere recursiv).

În fișierul în care se implementează funcțiile, este obligatoriu să includem fișierul header pentru care implementăm funcțiile respective - recomandat chiar pe prima linie, deoarece în funcție de ce simboluri se află în acesta restul sursei se poate compila într-un fel sau altul. În acest exemplu, vector.h este inclus cu "" și nu cu <> pentru a specifica că este vorba de un fișier local, nu dintr-o cale standard, cunoscută de compilator. Orice bibliotecă necesară va fi inclusă în fișierele .h și .c (aici s-a inclus stdio în fișierul vector.c pentru a putea apela funcția printf).

Fișierul main.c va arăta acum astfel:

#include <stdio.h>
 
#include "vector.h"
 
int main(void)
{
 
    int v[] = {2, 3, 5, 10};
    int n = sizeof(v) / sizeof(v[0]);
 
    printf("Sum is %d\n", sum_vector(n, v));
 
    sort_vector(n, v);
    printf("Sorted vector is: ");
    print_vector(n, v);
 
    // other operations with v
 
    return 0;
}

Compilarea se realizează prin rularea comenzii gcc împreună cu toate fișierele .c necesare (pentru compilare nu este nevoie să dăm ca parametri și fișierele header):

gcc main.c vector.c -o main
./main

Coding-style

Un aspect important pentru a avea o organizare bună este respectarea convenților de coding-style. Recomandăm parcurgerea tutorialul de coding-style

Modularizare cod

De ce?

Dacă tot codul pentru o aplicație ar fi scris într-o singură funcție, el ar fi foarte de greu de extins în cazul în care s-ar implementa o funcționalitate nouă. Majoritatea aplicațiilor importante au câteva sute de mii de linii de cod, iar urmărirea modificării unei variabile de-a lungul programului ar presupune o perioadă lungă de timp.

Cum?

Codul va fi împărțit în mai multe părți, astfel încât să existe o distincție a părților. Partea de citire a datelor ar trebui să fie separată de partea de procesare. Similar, partea de scriere a datelor prelucrate va reprezenta o altă bucată de logică.

FAQ
Când fac funcții?

Întotdeauna.

Cum aleg numele de funcții?

Numele funcțiilor ar trebui să definească cât mai bine scopul acesteia.

Câti parametri ar trebui să aibă o funcție?

Nu este recomadat ca o funcție să conțină un număr foarte mare de parametri (majoritatea funcțiilor au cel mult 6-7 argumente). Dacă există prea mulți parametri, este dificil să se înțeleagă scopul acelei porțiuni de cod.

Când ar trebui să am un parametru declarat const într-o funcție?

Este de preferat ca toți parametri care nu sunt modificați într-o funcție să fie marcați ca și const. Astfel, la încercarea eronată de modificare a acestora, se va genera o eroare de compilare.

Când este bine să trimit variabile parametri la o funcție și când nu?

Limbajul C folosește transmiterea prin valoare și nu prin referință a parametrilor unei funcții. Astfel, la apelul unei funcții se face o copie într-o altă zonă de memorie a argumentelor.

De aceea, este important să contorizăm câtă memorie va fi copiată pentru fiecare parametru.

Disclaimer: valorile menționate mai jos se referă la o arhitectură pe 32 de biți.

De exemplu, pentru a copia un număr întreg, am avea nevoie de 4 bytes (echivalentul sizeof(int)). În schimb, pentru structura:

typedef struct {
    char firstname[20];
    char lastname[10];
} student_t;

ar fi necesară copierea a 30 de bytes dacă se dorește pasarea unei structuri student_t ca parametru. O modalitate de a evita copierea, este pasarea unui pointer la această structură. (sizeof(student_t *) = 4).

Așadar, pentru structurile în limbajul C se recomandă pasarea lor ca pointer.

Tips & tricks

Programare defensivă

  • Programarea defensivă (Defensive programming) este o modalitate de a anticipa ce poate să nu funcționeze într-o aplicație în circumstanțe neprevăzute. Astfel, se pot lua anumite decizii ce țin de funcționarea în continuare a aplicației pe baza erorilor întâlnite.

Cum se realizează?

  • prin verificarea valorilor de retur pentru fiecare funcție care întoarce un anumit tip de date (de exemplu, pentru funcțiile de alocare malloc / calloc / realloc sau funcțiile de lucru cu fișiere fopen / fprintf etc).
#include <stdio.h>
#include <stdlib.h>
 
int main(void)
{
    int n, *v;
    scanf("%d", &n);
    v = malloc(n * sizeof(int));
    // check if malloc succeded
    if (!v) {
        fprintf(stderr, "malloc failed.\n");
        return -1;
    }
 
    // do stuff with v
 
    free(v);
    return 0;
}

Dacă pentru codul de mai sus s-ar omite verificarea valorii de retur pentru funcția malloc, atunci pentru un anumit input (de exemplu, n = -1 sau n foarte mare, nu se va putea aloca respectiva zonă de memorie), v va fi NULL (pointează către o zona de memorie invalidă), iar orice operație cu elemente din vectorul v va rezulta în terminarea programului cu Segmentation Fault.

  • prin întoarcerea unui tip dintr-o funcție, care să indice dacă funcția s-a terminat cu success sau nu acest tip poate fi:
    • int - care indică dacă funcția s-a terminat cu success sau nu (de obicei, o funcție care se termină fără erori ar trebui să întoarca 0, altfel un cod aferent problemei întâmpinate).
#include <stdio.h>
 
#define FILENAME "logs.txt"
#define FILE_SUCCESS 0
#define FILE_NULL_FILENAME 1
#define FILE_INNACESIBLE_FILE 2
 
int write_vector_file(int n, int *v, char* file_name)
{
    if (file_name == NULL) {
        return FILE_NULL_FILENAME;
    }
 
    FILE *f = fopen(file_name, "wt");
    if (!f) {
        fprintf(stderr, "Cannot open %s\n", file_name);
        return FILE_INNACESIBLE_FILE;
    }
 
    fprintf(f, "%d\n", n);
    for (int i = 0; i < n; ++i) {
        fprintf(f, "%d ", v[i]);
    }
    fprintf(f, "\n");
 
    fclose(f);
    return FILE_SUCCESS;
}
 
int main(void)
{
    int v[] = {1, 2, 3, 4};
    int n = sizeof(v) / sizeof(v[0]);
 
    write_vector_file(n, v, FILENAME);
    return 0;
}

Alternativă pentru codurile de eroare

În loc să facem pentru fiecare eroare un nou define, putem pune toate erorile într-un enum astfel:
enum code {
FILE_SUCCESS = 0,
FILE_NULL_FILENAME,
FILE_INNACESIBLE_FILE
};
  • pointer - care indică spre o anumită adresă dacă funcția s-a terminat corect sau spre NULL dacă au fost erori pe parcursul execuției funcției.
#include <stdio.h>
#include <stdlib.h>
 
int** alloc_matrix(int n, int m)
{
 
    int **mat = malloc(n * sizeof(int *));
    if (!mat) {
        return NULL;
    }
 
    for (int i = 0; i < n; ++i) {
        mat[i] = malloc(m * sizeof(int));
        if (!mat[i]) {
            for (int j = 0; j < i; ++j) {
                free(mat[j]);
            }
            free(mat);
            return NULL;
        }
    }
 
    return mat;
}
 
void free_matrix(int n, int m, int** mat)
{
    for (int i = 0; i < n; ++i) {
        free(mat[i]);
    }
    free(mat);
}
 
int main(void)
{
    int n, m;
    scanf("%d%d", &n, &m);
 
    int **mat = alloc_matrix(n, m);
    if (!mat) {
        fprintf(stderr, "alloc_matrix failed\n");
        return -1;
    }
 
    // do stuff with mat
 
    free_matrix(n, m, mat);
    return 0;
}

Genericitate

  • Ce înseamnă?
    • Un cod este generic dacă funcționează pentru mai multe tipuri de date. Un exemplu de funcție generică este memcpy care copiază n bytes de la sursă la destinație (nu contează dacă datele / bytes copiate(i) reprezintă valori de tipul int, float, double, struct etc).
  • De ce codul ar trebui să fie cât mai generic?
    • Pentru a evita codul duplicat (de exemplu, la un algoritm de sortare, ne dorim să meargă pentru orice tip de date, nu doar pentru int).
    • Pentru a extinde și adapta ușor funcționalitatea respectivă într-un alt context.
  • Când scriem cod generic?
    • Întotdeauna.
Exemplu de funcție generică

Dorim să realizăm o funcție de sortare care să funcționeze pentru mai multe tipuri de date. Pentru sortarea de numere întregi, comparativ cu sortarea de șiruri de caractere diferă modul de comparare (> vs strcmp) și modul de interschimbare a două elemente. Acestea sunt elementele specifice comparării și în funcția generică ar trebui să fie independente de elementele comparate.

Avem nevoie așadar de:

  • un mecanism de comparare a două valori de orice tip;

Pentru a compara două valori de orice tip, este suficient să dăm ca parametru un pointer la o funcție de comparare, ce va fi implementată pentru fiecare tip de comparare (adică dacă se dorește sortarea unui vector de numere întregi, se va face o funcție compare_ints, pentru un vector de șiruri de caractere se va face o funcție compare_strings ș.a.m.d).

  • un mecanism de interschimbare a două valori de orice tip;

Pentru a interschimba două valori de orice tip, ar trebui să ne gândim ce înseamnă a interschimba un element v[i] cu un alt element v[j]? Cum v este de tipul void*, nu este permis să folosim v[i],deoarece printr-un pointer void* nu știm ce elemente avem. Însă, cunoaștem dimensiunea unui singur element din vector - elem_size, putem determina adresa de început al celui de-al i-lea element din vector, care va fi la v + i * elem_size . Pentru a interschimba valorile, vom copia cu funcția memcpy un număr de elem_size bytes de la adresa determinată pentru elementul i într-un buffer temporar (similar cu temp = v[i];), apoi copiem de la adresa corespunzătoare lui j la cea a lui i (similar cu v[i] = v[j];, apoi din bufferul temporar la adresa corespunzătoare lui j (similar cu v[j] = tmp;).

O posibilă implementare a funcției este următoarea:

// generic_sort.c
 
void sort_vector(int n, void *v, int elem_size, int (*compare_function)(void *, void *))
{
 
    char *tmp = malloc(elem_size);
    if (!tmp) {
        return;
    }
 
    for (int i = 0; i < n - 1; ++i) {
        for (int j = i + 1; j < n; j++) {
            void *vi = (char *)v + i * elem_size; // address of the i-th element from vector <=> &v[i]
            void *vj = (char *)v + j * elem_size; // address of the j-th element from vector <=> &v[j]
            if (compare_function(vi, vj) > 0) {
                memcpy(tmp, vj, elem_size); // tmp = v[j];
                memcpy(vj, vi, elem_size); // v[j] = v[i];
                memcpy(vi, tmp, elem_size); // v[i] = tmp;
            }
        }
    }
 
    free(tmp);
}

Spoiler program C care testează funcția de sortare generică

// generic_sort_example.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
 
void sort_vector(int n, void *v, int elem_size, int (*compare_function)(void *, void *));
 
// this function will only be used for comparing integers.
int compare_ints(void *ptr_a, void *ptr_b) {
    // here we know that ptr_a and ptr_b ar pointer to addreses where we have ints stored
    // so it's safe to cast and dereference, in order to get the int value!
    int a = *(int *) ptr_a; // get first value - a
    int b = *(int *) ptr_b; // get second value - b
    return a > b;
}
 
// this function will only be used for comparing doubles.
int compare_doubles(void *ptr_a, void *ptr_b) {
    // here we know that ptr_a and ptr_b ar pointer to addreses where we have doubles stored
    // so it's safe to cast and dereference, in order to get the double value!
    double a = *(double *) ptr_a; // get first value - a
    double b = *(double *) ptr_b; // get second value - b
    return a > b;
}
 
// this function will only be used for comparing strings.
int compare_strings(void *ptr_a, void *ptr_b) {
    // here we know that ptr_a and ptr_b ar pointer to addreses where we have char* stored
    // so it's safe to cast and dereference, in order to get the char* value!
    char *a = *(char **) ptr_a; // get first value - a
    char *b = *(char **) ptr_b; // get second value - b
    return strcmp(a, b);
}
 
int main(void)
{
    int v[] = {2, 10, -3, 7};
    int size_v = sizeof(v) / sizeof(v[0]);
    sort_vector(size_v, v, sizeof(int), compare_ints);
    for (int i = 0; i < size_v; ++i) {
        printf("%d ", v[i]);
    }
    printf("\n");
 
    double w[] = {2.3, 10.3, -3.04, 7.02};
    int size_w = sizeof(w) / sizeof(w[0]);
    sort_vector(size_w, w, sizeof(double), compare_doubles);
    for (int i = 0; i < size_w; ++i) {
        printf("%lf ", w[i]);
    }
    printf("\n");
 
    char *z[] = {"this", "is", "an", "example"};
    int size_z = sizeof(z) / sizeof(z[0]);
    sort_vector(size_z, z, sizeof(char *), compare_strings);
    for (int i = 0; i < size_z; ++i) {
        printf("%s ", z[i]);
    }
    printf("\n");
 
    return 0;
}
programare/tutoriale/good_practices.txt · Last modified: 2022/01/24 16:28 by stefan.popa99
CC Attribution-Share Alike 3.0 Unported
www.chimeric.de Valid CSS Driven by DokuWiki do yourself a favour and use a real browser - get firefox!! Recent changes RSS feed Valid XHTML 1.0