dimecres, 15 de novembre del 2017

Aprendre a programar aux - Bones i males pràctiques a PRO1

Aquest article se'm va acudir aquest dilluns, després de veure algunes correccions del primer parcial de PRO1. Veient diverses coses que ha entregat la gent, se m'ha acudit que potser calia parlar de bones i males pràctiques, de manera que no es tornin a veure coses així de rares.

Indentació

Aquí no hi ha gaire a dir. El codi, ben indentat. Què vol dir això? Que has de ser coherent. Primer cal determinar com ho faràs, si amb espais o tabulacions. Jo sóc molt més partidari de tabulacions, perquè així cadascú es configura l'editor per veure-ho com li agradi. Després cal definir l'amplada, jo tinc configurat perquè els tabulats ocupin l'equivalent a 4 espais. I un cop tens decidides aquestes dues coses, fes-ho sempre igual. 

Quan tabules?

Quan entris a un tros de codi que està dins d'alguna cosa (if, else, while, for, switch, una funció...), un nivell més. Així, quedaria
if (a) {
    codi
    if (b) {
        més codi
    }
}

Amplada de les línies

En resum, no es poden fer línies de més de 80 caràcters. Si tens una línia que ocupa més, mala idea, perquè costa molt de llegir (i més encara si utilitzes editors des de la consola). Si a nivell de PRO1 arribes a amplades així, probablement estàs fent alguna cosa malament. Però per arreglar-ho, sempre pots seguir a la línia següent. Poses simplement una \, i saltes a la següent línia 
int n=1+2+3+4+5+6+7+8+9+10+11+12+13+14+15+\
        16+17+18+19+20+21+22+23+24+25+26+27;

Les inclusions es fan bé

Sí, tal qual. Comencem forts. No pot ser veure codis de gent que utilitza una plantilla plena d'inclusions. O gent que no en posa algunes. Posa totes les que necessitis, i només les que necessitis. 

Algú s'estarà preguntant com pot ser que no incloguis coses que calen. Molt senzill. La llibreria iostream inclou la llibreria string, de manera que si utilitzes iostream, pots utilitzar strings sense incloure la seva llibreria. Però no té cap sentit fer-ho. Així que inclou tot el que calgui

Per altra banda, no em serveix que em diguis "no, si jo només incloc una llibreria, bits/stdc++.h. De debò, no inclogueu aquesta porqueria. Primer, perquè no és una llibreria estàndard, i segon, pel que he dit d'incloure només el que calgui. Ni explicaré què fa, no voldria ser responsable de que algú la utilitzi

El main no retorna res

Sí, en C era una pràctica comuna acabar el main amb un "return 0". En C++ no cal, i posar-lo és redundant

Respecta l'àmbit de les variables

Una variable té un àmbit. És la part del codi on existeix. Per exemple, al codi següent:
int s=0;
for (int i=0;i<n;++i) {
    int aux;
    cin>>aux;
    s+=aux;
}
cout<<s<<endl;

Aquí tenim quatre variables. Són s, i, n i aux. n no ens preocuparem del seu àmbit, assumeixo que ja ve declarada d'abans. Veiem que s es declara abans del for, mentre que al for declarem dues variables (i i aux). Doncs bé, l'àmbit d'aquestes dues és el for. Concretament, aux es declara a cada iteració del for, mentre que i dura mentre duri el for. Això serveix per utilitzar les variables només on calen. Si no ens cal la i fora del for, no la volem tenir declarada. Per això es diu que les variables s'han de declarar sempre a l'àmbit més intern possible

El for es fa bé

Un for té una estructura molt clara. I és la següent:
for (inicialització;comparació;modificació) {
    cos
}
Respecteu-la. No deixeu buida cap de les parts. Si ho feu, en el millor dels casos tindreu un codi fastigós, en el pitjor un bucle infinit. 
Quan s'inicialitza, es declara una (o més, però en general una) variable de control. Declara-la allà mateix. Al cap i a la fi, és una variable que en el 99,99% dels casos, només necessites al for. Normalment, quedarà una cosa molt similar a la següent
for (int i=0;i<n;++i)
Hi ha variacions, òbviament, potser arriba fins a n inclosa, a vegades es fa al revés (començant per n, i arribant a 0). I algun cop potser enlloc d'augmentar d'un en un, augmenta de dos en dos, o similar. Però la idea és que sigui similar a això

Important: Si has deixat buida una de les tres parts, probablement estigui malament
Important 2: No serveix posar a=a perquè no quedi buit. Sembla una tonteria, però és que ha passat
Per cert, també està prohibit modificar la variable de control. Jo no ho prohibiria, malgrat que em sembla una mala pràctica, però el tema és que no es pot. 

No trenquis el codi

Hi ha vegades on volem fer un bucle, però sabem que en algun moment potser ens hem d'aturar. I, buscant codis, a vegades et trobes una cosa com la següent

for (int i=0;i<n;++i) {
    ...
    if(...) break;
}

Això trenca molt el codi. No és gens visual, i està prohibit a PRO1. No ho feu, a més és cutre i lleig. En tot cas, una alternativa vàlida per PRO1 seria la següent

bool fi=false,
for (int i=0;i<n and not fi;++i) {
    ...
    fi=...;
}
Fent funcions hi ha una altra possibilitat, però ja l'explico més endavant 

Tot això no només s'aplica aquí, també s'aplica amb continue i goto

Booleans

Quan treballes amb booleans, saps que un booleà té dues possibilitats (true i false). Una condició (per exemple a==b) a C++ retorna també un booleà. Per tant, una cosa que no mola gens és la següent
bool b;
//codi, en algun moment b agafarà valors
if (b==true) ...
else ...

Això és completament absurd. b serà true o false. La comparació retornarà true o false. Per tant, per què cal comparar? Podem fer directament
if (b) ...
else ...

De la mateixa manera, si volguéssim comprovar que b és fals, podríem fer
if (not b) ...
else ...

Això també aplica per donar valors a un booleà. No pot ser que facis el següent
bool b;
if (condicio) b=true;
else b=false;

És molt millor fer
bool b=condicio;

Lazy evaluation

Això no ho he traduït, perquè no s'acostuma a veure traduït. Seria com avaluació mandrosa, o una cosa així. En essència es basa en les següents tautologies:

true or x=true
false and x=false

Aleshores, per què dedicar temps a mirar si x és cert o fals, si al cap i a la fi, podem saber-ho ja. Doncs el programa no ho mirarà. Això ens pot aportar avantatges. Per exemple
double a, b;
cin >>a>>b;
if (b!=0 and a/b>1) ...
else ...

No facis coses prohibides

Sembla una obvietat, no? Doncs no ho és. I no només pot implicar un 0 de manual, sinó que et poden invalidar l'automàtic també. Poso a continuació una sèrie de coses prohibides a PRO1 (ante la duda, pregunteu):
-Modificar la variable de control d'un for dins del cos d'aquest
-Utilitzar punters
-New i delete
-goto
-try, catch i throw
No necessitareu excepcions per res. Si el vostre codi us llença una excepció, probablement esteu fent alguna cosa molt malament, i per tant, arregleu el codi. No serveix capturar l'excepció i tractar-la
-template
-class
-crides a sistema
És molt típic veure gent que posa al final del codi un system("pause"). No ho feu. Si executeu des de consola, no cal. Si igualment necessiteu que s'esperi per veure-ho, poseu cin.get(). Però millor encara si no ho poseu
-Directives del preprocessador. És a dir, res que tingui un # a davant, excepte #include
-Llegir i escriure estil C. És a dir, scanf, printf, fprintf, write, read i similars
-Operacions sobre strings que no siguin declaració, assignació, comparar, llegir i escriure
Les següents es permeten:
string s;
string s="Hola";
if (s1<s2)...
cin >>s;
cout<<s<<endl;
Fins i tot, si jo fos el profe, us permetria la següent:
cout<<string(n,'*')<<endl;//mostra n asteriscs
Al cap i a la fi, no és res més que una de les opcions que creen un string. No obstant, pregunteu al profe

No es val utilitzar coses com la concatenació. A partir de que es facin vectors sí que es permeten més coses, en essència treballar amb ells com si fossin vectors, però inicialment no
-vectors abans de ser explicats a classe. Arrays mai es poden utilitzar
-operacions sobre vectors no explicades. 
És a dir, declarar-los sí, size també, accedir amb [] també. Push_back si inicialment no sabem la mida i ens fa un codi més senzill. La resta, no
-Inclusions que no siguin iostream, cmath, string, vector, algorithm
-Variables globals (excepte constants)
-Tipus no explicats. 
És a dir, utilitza bool, int, double i ja (string també, però no és un tipus). Cap altre. Ni short, ni long, ni unsigned, ni float, ni coses que se t'acudeixin
(enums es permeten. Estan desrecomanades, però es permeten)

Funcions i procediments. 

En el cas de les funcions/procediments (un procediment no retorna res, però jo en general parlaré de funcions), hi ha unes quantes coses a tenir en compte

Declaració

Les funcions es declaren donant l'especificació directament, ja que no farem recursivitat creuada ni coses rares. Per tant, posarem el codi en el moment de declarar-la. Com que fem això, hem de tenir en compte que si una funció B utilitza una funció A, la funció A ha d'estar abans de la B

Pas de paràmetres

Les funcions en general reben paràmetres. Quan són paràmetres normals (enters, doubles, strings normalets), es passen normal (és a dir, per còpia). Si volem que la funció modifiqui el paràmetre, o bé passar-li una cosa que ocupi molt (strings llargs, vectors o structs molt grans), li passarem per referència. Si volem que no es pugui modificar, serà referència constant. Per exemple
void foo (const string& biblia_en_vers) //No modifica
void foo2 (string& biblia_en_vers) //Modifica

Retorn

Per retornar es fa amb la paraula clau "return". Hem de posar què volem retornar (excepte als procediments), i ha de ser del tipus que retorna la nostra funció, o bé poder-s'hi convertir. És a dir, si ha de retornar un enter, podem retornar un enter directament, o bé un double (es convertirà a enter, perdent els decimals que pugui tenir)

Retorn i condicions

A vegades el retorn d'una funció depèn d'una o més condicions. En aquest cas, una cosa intuitiva seria:
int ret;
if (...) ret=..
else ret=...
return ret;

Això ho fan a algun problema de PRO2, però no els feu cas. És una mala decisió. Una altra cosa que se'ns pot acudir és
if (...) return ...;
else return ...;
Aquesta ja està prou bé. No obstant, com que quan retornem sortim de la funció, sabem que si seguim a la funció entrarem sí o sí al else. Per tant, pot quedar simplement
if (...) return ...;
return ...;

Retorn i bucles

Una mica com abans, hi ha situacions on podem saber ja el valor de retorn dins d'un bucle. En aquest cas, podem simplement retornar-ho dins del for. Per exemple
bool es_primer(int n) {
    for (int i=2;i*i<=n;++i) {
        if (n%i==0) return false;
    }
    return true;
}
Es pot fer diferent, podríem fer la que hi ha a continuació, però és més lleig i pitjor en general en tots els aspectes
bool es_primer(int n) {
  bool res=true;
    for (int i=2;i*i<=n and res;++i) {
        if (n%i==0) res=false;
    }
    return res;
}

Funcions i procediments recursius

En general, una funció recursiva es defineix amb un cas base, i una manera de reduir-ho tot fins al cas base. Per tant, hem de fer-ho de manera que en el cas base no torni a cridar a la recursiva. Per exemple, si tenim la funció per calcular el factorial, hem de fer que quan n==1 o n==0 ens retorni 1, i si no, cridi a la funció recursiva. Hi ha dues maneres, i partidaris de les dues
bool factorial (int n) {
    if (n>1) return n*factorial(n-1);
    return 1;
}
bool factorial (int n) {
    if (n<=1) return 1;
    return n*factorial(n-1);
}
Aquí pot semblar que les dues són iguals. No obstant, en casos més llargs són bastant diferents. Per exemple, existeix l'algorisme d'exponenciació ràpida, que calcula a^n en temps logarítmic (traducció: Molt ràpid)
int exp_rap(int a, int n) {
    if (n>0) {
        int aux=exp_rap(a,n/2);
        if (n%2==0) return aux*aux;
        return aux*aux*a;
    }
    return 1;
}
int exp_rap(int a, int n){
    if (n==0) return 1;
    int aux=exp_rap(a,n/2);
    if (n%2==0) return aux*aux;
    return aux*aux*a;
}
Jo personalment sóc més partidari de la segona versió. En aquesta, el cas base en poses a davant de tot, i així no has de posar tota la resta dins d'un if.
En funcions gairebé sempre es fa així. Però en procediments... En procediments molts cops es fa diferent. Per exemple
//Mou n torres de la torre a fins la torre c
//utilitzant b com a auxiliar
void hanoi (int a, int b, int c, int n) {
    if (n>0) {
        hanoi(a,c,b,n-1);
        cout <<a<<"->"<<c<<endl;
        hanoi(b,a,c,n-1);
    }
}
void hanoi (int a, int b, int c, int n) {
    if (n==0) return;
    hanoi(a,c,b,n-1);
    cout <<a<<"->"<<c<<endl;
    hanoi(b,a,c,n-1);
}

Jo personalment sóc més partidari de la segona en els dos casos. Normalment, a PRO1, utilitzen la segona per funcions, i la primera per procediments. Això ja a criteri de cadascú, però intenteu ser coherents, per no liar-vos més encara

Nomenclatura

En aquest àmbit hi ha molta discussió, així que perfectament us podeu trobar que jo us dic una cosa, i algú us diu una altra diferent. No obstant, jo us diré que em feu cas a mi (òbviament, si ho faig així, és perquè crec que és millor)

Variables

Sempre que defineixis una variable, aquesta ha d'estar definida en minúscules. Per altra banda, si vols separar paraules, sempre han d'estar separades per _. Hi ha gent que utilitza la notació camelcase, que vindria a ser posar en majúscula la inicial de les paraules, si n'hi ha més d'una
int salari_minim;//La que jo recomano
int salariMinim;//lower camelcase
int SalariMinim;//upper camelcase

Sobretot a PRO1 no utilitzeu la tercera, ja que interferiria amb el que direm més tard. En general en variables no es discuteix tant, la majoria utilitza la primera

A més, és important posar noms explicatius, però no ens passem. Els noms han de ser prou curts (tampoc posem lletres per tot)

Funcions

En el cas de funcions, a C era molt típic utilitzar camelcase, mentre que a C++ s'opta més per la _ (almenys la STL). Jo particularment opto per això
Per altra banda, en C, quan volíem fer diverses funcions que facin el mateix però rebent diferents paràmetres (per exemple, el màxim de 2 números, o de 4), calia diferenciar els noms. Per això, en C existien coses com abs, labs, i similars. En C++ això ja no passa, pots tenir funcions que es diguin igual sempre i quan rebin paràmetres diferents (que el compilador pugui diferenciar clarament). Així, podem tenir
void torres_hanoi(int A, int B, int C, int n) {
    //el codi, no ve al cas
}
void torres_hanoi(int n) {
    torres_hanoi(1,2,3,n);
}
int main() {
    int n;
    while (cin >>n) torres_hanoi(n);
}

En el main llegeix la n, i crida a la funció torres_hanoi(int n). Aquesta crida a l'altra. Si estiguéssim programant una llibreria, l'usuari només necessitaria utilitzar la funció que rep la n. Això permetria canviar l'algorisme fàcilment sense que a ell li afecti. Per exemple, podríem fer que la funció rebi caràcters enlloc d'enters, i no passaria res.
double max(const double& a, const double& b) {
    //comprovacions pels NaN, inf i -0.0
    return a<b?b:a;
}
int max (int a, int b) {
    return b<a?a:b;
}
En aquest cas, ens convindria declarar diverses funcions per diferents tipus. Perquè en els nombres en coma flotant, hi ha casos estranys (el NaN, els infinits, el -0.0...), i ens pot anar bé treballar així. De fet, en aquest aspecte fallen bastant std::max i std::min, així que si creiem que ens pot afectar, és millor utilitzar fmax i fmin

Structs

Una struct en general és com si crees un nou tipus. El conveni per mi és senzill, i seria posar en majúscula només la primera lletra. Quedaria una cosa així:
struct Punt {
    double x, y;
};

No reinventis la polvora

Hi ha coses que són tan òbvies que segurament algú les ha fet ja. Per exemple, imaginem que et donen un programa que en algun punt necessita tenir el vector ordenat. No et posaràs a programar un algorisme d'ordenació, no?

La STL

Existeix la STL, que vol dir Standard Template Library. He fet un article que explica unes quantes coses de les que té, però la majoria no es poden utilitzar a PRO1, així que aquí posaré les que penso que sí. Davant del dubte, sempre es pot preguntar

pair<T1,T2>

Imaginem que necessitem retornar dos valors a l'hora. Una idea que se'ns podria acudir seria
struct Parella {
    int x, y;
};

Com que això és una cosa que pot caldre per moltes ocasions, ja se'ls va acudir crear una classe per fer això. És la classe pair, que conté dos valors (que poden ser de diferent tipus). Es declara fent
pair<T1,T2> p;
Pots donar-li valor, o bé en la declaració
pair<int,int> p(1,3);
O element per element
pair<int,int> p;
...
p.first=3;
p.second=4;
Fins i tot podem fer-ho amb la funció make_pair
p=make_pair(5,7);

Les pairs tenen l'operador < definit, de manera que es poden ordenar vectors de parelles sense fer una nova funció. Aquest compara el primer element, i si són iguals, compara el segon

sort

Òbviament, no ens posarem a programar algorismes d'ordenació a cada problema que necessitem ordenar. No ens posarem a fer un bubblesort (perquè és pura merda); o selection sort, o insertion sort, o mergesort, per cada problema on necessitem ordenar (encara que està bé saber com són aquests). Enlloc d'això, podem utilitzar el sort, definit a la llibreria algorithm, i no ens preocuparem de com ordena
El sort bàsicament és una funció que rep dos (o tres) paràmetres. La versió senzilla rep dos paràmetres, que indiquen on comença el vector i on acaba. Concretament, rep iteradors apuntant allà. No cal saber què són els iteradors, simplement que s'obté un que apunta a la primera posició fent v.begin(), i un que apunta al final fent v.end(). Per tant, per ordenar un vector faríem
sort (v.begin(), v.end());
La versió que rep tres paràmetres, el que fa és rebre una funció que indica com es compara. Això serveix per si volem ordenar decreixentment, o per si el que volem ordenar no té definit l'operador <. 

Functors per comparar

Això és una cosa que no tinc ni idea de si està permès o no. Per tant, pregunteu. La idea és que quan es va programar la STL, ja s'imaginaven que podria caldre comparar amb operadors diferents de <. Així que van definir functors (no cal saber què són) per comparar diferent. La típica és greater<T>, que compara dos elements de tipus T comprovant si el primer és més gran que el segon. Així, podríem fer
vector<int> v(n);
...
sort(v.begin(),v.end(),greater<int>);

Això ens permet ordenar el vector decreixentment sense preocupar-nos de programar cap funció

Vectors i funcions

A l'hora d'utilitzar funcions i vectors, la cosa és una mica complexa. Suposo que a PRO1 us hauran dit que mai passeu vectors per còpia ni els retorneu. Que sempre els heu de passar per còpia a les funcions, i modificar-los allà mateix. Això és una manera senzilla de no liar-se. Però què coi, som informàtics o no? Per tant, podem veure com podrien estar implementats els vectors

template<typename T>
class Vector {
    T* dades;
    size_t size;
    //totes les funcions
};

Això, òbviament, és un resum. Hi ha més coses. Però amb això ja ens serveix per veure com es fa tot. dades és un punter que apunta a les size dades que tenim. 

Què passa quan passem per còpia un vector?

Quan passem per còpia un vector, hem de copiar tot això. Això implica reservar espai per les dades, copiar-les totes allà... Bastant cost. Concretament, és cost lineal. Per tant, el que farem nosaltres és no copiar-ho. Si només volem fer consultes, referència constant. I si volem modificar-lo (compte, les modificacions són definitives) doncs referència a seques

Què passa quan retornem un vector?

Aquí hi ha dos casos. El primer, seria quan retornem un vector creat a la funció. Per exemple:
vector<int> llegir_vector() {
    int n;
    cin >>n;
    vector<int> v(n);
    for (int i=0;i<n;++i) cin >>v[i];
    return v;
}
En aquest cas, crea un vector, reserva espai per n elements i els llegeix. En el moment que va a retornar, el compilador s'adona que v es destruirà després del retorn. Així que el que fa és fer un swap amb el contingut del vector que rep això. És a dir, si fem
vector<int> vec=llegir_vector();
Tenim vec, que està buit. I tenim el v de la funció, que té el que hem llegit. Com que v es destruirà, i el contingut de vec no el volem per res, intercanvia size i dades dels dos. No ha de copiar les n dades, sinó que simplement fa que el punter de vec apunti al lloc on apuntava el punter de v, i viceversa. És a dir, el retorn té cost constant. 

L'altre cas seria quan retornem un vector que ja existeix. Per exemple
vector<int> fila_iessima(vector<vector<int>>& matriu, int i) {
    return matriu[i];
}

En aquest cas, com que la fila no es destruirà, el compilador determina que s'han de copiar tots els elements, cosa que pot ser necessària en algun cas, però en general no

Cap comentari:

Publica un comentari a l'entrada