Sintaxa îți ușurează scrisul, nu gândirea

Limbajul C# a evoluat foarte frumos în direcția bogăției și eficienței în exprimare, uneori în detrimentul purității academice. Fiecare versiune pune varii trufandale pentru, bunăoară, reducerea ceremoniei, favorizarea logicii și trimiterea fricii în fundal, ori pentru producția expresivă de valori.

Iar prin frică, desigur, mă refer la manifestările noastre defensive precum verificarea diverselor variabile, indiferent de scop: că este sau nu este null, că un număr stă bine chitit într-un interval bine definit, că un obiect oarecare este „activ” ori ba ș.a.m.d.

Superior pe orice teren

Superior pe orice teren

Ele sunt binevenite, iar eu nu-s omul să nu le folosească, ba se poate spune că prezint o vagă propensitate pentru abuz. Pe de altă parte, nu-s nici omul care să nu cârcotească, deci mai întâi am să trec prin ele mai mult pentru propria-mi plăcere, ca o cafea caldă-ntr-o dimineață rece, apoi o să sorbim și un pic de zaț.

În majoritatea lor covârșitoare, notițele mele acoperă diverse aspecte ale operațiunilor pe baza pattern matching, întinzând câteva tentacule și-n direcția construcției obiectelor, unde, iată, avem acum o nouă opțiune în afara constructorilor standard. Și înainte de-a purcede aș vrea să-mi scarpin o traducere mai elegantă (cât de cât) pentru mâncărimea termenului pattern matching.

Prima tentativă – verificări tipizate, nu m-a satisfăcut. Așa că-i musai să detaliem mai intâi sensul expresiei. Pattern matching ne permite să descriem forma datelor, să vedem dacă realitatea corespunde formei și, uneori, să producem o nouă realitate: să stabilim o corespondență între formă și o valoare condensată.

Pe acest temei, pattern devine mai degrabă formă, structură sau o declarație vis-a-vis de. Matching, pe de altă parte, are darul de-a întări ideea de condiție, de expresie ori de selecție. Deci, iacătă, avem două variante: expresii structurale sau condiții declarative. Aș folosi momentan mai degrabă expresii structurale.

Înainte să continui, în loc să-mi scuz pedanteria (demers inutil, căci am să m-arunc iar în brațele dânsei cu proxima ocazie), vreau să subliniez un rol important în a-ncerca să traduci un termen în loc de-a-l prelua verbatim: înțelegerea. Iar ingineria programelor este, în primul rând, despre înțelegere, nu despre scriere.

Expresii structurale pentru evaluarea proprietăților

În treacăt fie spus, sunt aplicabile și câmpurilor simple în limita vizibilității. Rolul lor este să ajute evaluarea structurală într-o manieră sintactică mai concisă, gestionând implicit și valorile nule, adică expresia nu va emite o NullReferenceException. Bunăoară, un astfel de cârnățel:

return customer != null 
    && customer.LatestOrder != null 
    && customer.CustomerForDays > 150 
    && customer.LatestOrder.Active 
    && customer.LatestOrder.Amount > 100;

Poate fi înlocuit cu:

customer is
{
    CustomerForDays: > 150, 
    LatestOrder:
    {
        Active: true, 
        Amount: > 100
    }
};

Adică tot un cârnățel, dar mai scurt și mai elegant, doar un pic mai elegant. Beteșugul însă rămâne: nu spune ce înseamnă. Presupunând că nu avem acces la clasa ce-a dat naștere variabilei customer, putem să ne repliem într-o extensie:

public static class SampleCustomerExtensions
{
    public static bool IsPreferred( this SampleCustomer customer )
    {
        return customer is
        {
            CustomerForDays: > 150,
            LatestOrder:
            {
                Active: true,
                Amount: > 100
            }
        };
    }
}

Altfel spus, câteodată, cea mai bună modalitate de-a utiliza puterea este… s-o cedezi. Codul devine mai scurt, iar cititorul nu-i forțat să devină automat mai deștept decât e prevede legea. Ceea ce, apropo, este aplicabil și-n continuare.

Expresii structurale relaționale și logice

Expresiile relaționale permit evaluarea conform operatorilor <, >, <=, >=, iar cele logice permit combinarea altor expresii structurale prin conjuncție (and), disjuncție (or) sau negare (and), rezultatul net fiind un câștig expresiv și mai consistent. Să luăm câteva verificări pe baza unui simplu număr întreg:

int age = 66;

if (age > 18 && age < 65) 
    Console.WriteLine("Adult");

if (age >= 0 && age <= 12)
    Console.WriteLine("Child");
else if (age >= 13 && age <= 19)
    Console.WriteLine("Teenager");
else if (age >= 0) // Same as age is not < 0
    Console.WriteLine("Adult or Senior");

if ((age >= 18 && age <= 65) || age == 0) 
    Console.WriteLine("Eligible for discount");

if (!(age >= 18 && age <= 65)) 
    Console.WriteLine("Not in prime working age");

Am putea să-l rescriem după cum urmează:

int age = 66;

// Relational patterns
if (age is > 18 and < 65)
    Console.WriteLine( "Adult" );

// Logical patterns (and, or, not)
if (age is >= 0 and <= 12)
    Console.WriteLine( "Child" );
else if (age is >= 13 and <= 19)
    Console.WriteLine( "Teenager" );

else if (age is not < 0) // Same as age is >= 0
    Console.WriteLine( "Adult or Senior" );

// Combining patterns, including with regular checks
if (age is (>= 18 and <= 65) || (age == 0))
    Console.WriteLine( "Eligible for discount" );

// Using 'not' with relational patterns
if (age is not (>= 18 and <= 65))
    Console.WriteLine( "Not in prime working age" );

Deși este o variantă afectată de-un început de logoree (în mod deosebit unele exemple punctuale), există o serie de îmbunătățiri de natură epistemică (în descrierea intenției, transferului de cunoștințe și reducerea presiunii cognitive):

Subiectul (age) poate fi descris o singură dată, centrând regula pe o singură valoare;
Compoziția permisă de and / or / not permite o scalare mai naturală atunci când condițiile capătă o carnație mai sănătoasă;
Se pun bazele unei evoluții mai ușoare de la simple verificări numerice la includerea unor expresii de tip switch.

Expresii structurale de tip switch

Expresia structurală de tip switch este una din cele mai potrivite utilizări ale acestor caracteristici de limbaj. Deși similar construcției standard de tip switch, destinația și comportamentul celor două sunt complet diferite, mai profund decât pare la prima vedere. Dar să vedem mai întâi un exemplu non-trivial (omit clasele pentru simplitate, oricum nu sunt interesante):

private ConnectionCategory ComputeCategory( SampleConnection conn )
{
    return conn switch
    {
        {
            Status: ConnectionStatus.Connected,
            BytesTransferred: int bytes,
            ConnectedSeconds: int duration
        }
            when bytes / duration > 1024
            => ConnectionCategory.HighTraffic,

        {
            Status: ConnectionStatus.Connected,
            BytesTransferred: int bytes,
            ConnectedSeconds: int duration
        }
            when bytes / duration < 1024
            => ConnectionCategory.Normal,

        {
            Status: ConnectionStatus.Error,
            ErrorCount: > 5
        }
            => ConnectionCategory.ToInvestigate,

        _ => ConnectionCategory.Uninteresting
    };
}

Prima observație ar fi că fiecare ramură este în sine o evaluare structurală pe baza căreia se emite o valoare. De altfel, diferența cardinală este intenția: expresia switch are scopul de-a calcula ceva, iar blocul clasic switch are scopul de-a executa ceva imperativ.

Am ales intenționat un exemplu mai sofisticat decât simpla corespondență dintre o singură valoare și o alta (gen traducerea valorilor unui enum), pentru a ilustra și ceea ce poate trece drept o limitare: nevoia de-a repeta anumite calcule și verificări pentru situații înrudite. Din fericire, se pot calcula anterior expresiei:

private ConnectionCategory ComputeCategoryV2( SampleConnection conn )
{
    bool isHightTraffic = conn.ConnectedSeconds > 0
        && conn.BytesTransferred / conn.ConnectedSeconds
        > 1024;

    return conn switch
    {
        { Status: ConnectionStatus.Connected }
            when isHightTraffic
            => ConnectionCategory.HighTraffic,

        { Status: ConnectionStatus.Connected }
            => ConnectionCategory.Normal,

        { Status: ConnectionStatus.Error, ErrorCount: > 5 }
            => ConnectionCategory.ToInvestigate,

        _ => ConnectionCategory.Uninteresting
    };
}

Altfel stai la coadă la certificate verzi! Bun, o altă diferență importantă ține de exhaustivitate: Un bloc switch nu îți impune să tratezi toate cazurile, pe când o expresie switch își dorește mai mult victoria. Astfel, dacă te prinde că nu vădești rigurozitate, emite mai întâi o avertizare:

warning CS8509: The switch expression does not handle 
all possible values of its input type (it is not exhaustive). 
For example, the pattern 'ConnectionCategory.Normal' 
is not covered.

Dacă persiști în ignoranță și rulezi, face poc atunci când cazi pe o situație netratată:

Eroare tratare incompletă expresie switch

Eroare tratare incompletă expresie switch

Cele două niveluri de diagnosticare, să-i zic așa, decurg direct din natura lor de producătoare de valori și se poate spune, drept consecință, că reprezintă un  avantaj față de un dicționar, fie dânsul interogat cu TryGeValue().

Dar e bine să nu uităm că programele sunt, în ciuda filmelor ce ni le facem, mult mai complexe decât capacitatea noastră de-a le ține complet în cap. Cu toate acestea, dovedind lipsă de smerenie operațională, din când în când mai uităm și, dacă suntem norocoși, ne-aduce din nou în simțiri câte un paradox amenințător la adresa însuși Universului.

Am să continui, deci, cu observația mai degrabă anticulminantă, ca o tuse paguboasă, că lucrurile pot degenera foarte rapid fie și-n scenarii de o complexitate moderată ca mai sus, deci utilitatea expresiilor poate fi periclitată de lipsa unei planificări sumare, cum ar fi gruparea comportamentului detaliat în variabile locale, metode or extensii. Dacă aș vedea pe undeva cod ca-n prima variantă, ar cădea capete, inclusiv al meu…

În final, pentru a fi pe deplin pedant, am sumarizat mai jos diferențele dintre cele două switch-uri:

AspectBloc switchExpresie switch
Scop principalControlul fluxului de execuțieCalcularea unei valori
FormăBlock cu etichete caseExpresie cu brațe expresie structurală => valoare
ExhaustivitateNu este impusăEste așteptată; nesatisfacerea emite CS8509 la compilare și excepție la rulare dacă
Ramură implicităEticheta default:_ => ...
Trecere implicită la ramura următoare (fall-through)Restricționat la faptul că două etichete case pot împărți același corp, altminteri controlul trebuie efectuat explicit, folosind instrucțiuni goto.Nu
Corpul ramuriiMultiple instrucțiuni imperativeO singură expresie
UtilizareRamificare imperativăClasificare, transformare, corespondențe între valori
Compozabilitate (cum poate interacționa cu alte apeluri sau expresii)Doar prin variabilă temporarăDirect în clauze return, în inițializări de variabile, în expresii LINQ

Expresii structurale pentru evaluarea listelor (sau vectorilor)

Sau, mai precis, evaluarea unei liste prin raportare la o secvență de expresii. În termeni de complexitate redusă și produsă se compară doar cu evaluarea proprietăților, însă aici există și ofensa adăugată de faptul că avem de-a face cu identificări poziționale nu nominale, deci limitele noaste cognitive sunt testate cu atât mai mult.

Acestea fiind spuse, reprezintă o modalitate neașteptat de convingătoare de-a verifica forma unei liste. O verificare altminteri constând din lungime minimă, indexare și comparații succesive poate fi exprimată ca banală descriere a formei datelor. Eu unul am zis bogdaproste atunci când am putut să-mi simplic secvențe de cod precum testarea unui pachet de octeți:

bool IsJpeg(byte[] data)
{
    return data.Length >= 3
        && data[0] == 0xFF
        && data[1] == 0xD8
        && data[2] == 0xFF;
}

//now becomes

bool IsJpeg(byte[] data) =>
    data is [0xFF, 0xD8, 0xFF, ..];

Câștigul este indubitabil, atât ca plantație cât și ca expresivitate, în ciuda izului ușor mistic. Și mai valoros ar fi într-o secvență ce-ncearcă să miroasă mai multe cazuri, combinându-le într-o expresie switch.

Totuși, în loc să reiau exemplul, prefer să exemplific prin modul în care gestionez în ultima vreme parametrii aplicațiilor din linia de comandă, profitând de capacitatea secvențelor de expresii structurale de-a defini mini-gramatici declarative:

private void ProcessArgs( string[] args )
{
    switch ( args )
    {
        case [ "add", var x, var y ]:
            Add( x, y );
            break;

        case [ "remove", var id ]:
            Remove( id );
            break;

        case [ "help" ]:
            ShowHelp();
            break;

        default:
            Invalid();
            break;
    }
}

Așadar, în biblioteci de infrastructură ori la granițele sistemului (așa cum e cazul unei aplicații CLI) este pomană de duminică. Și nu suntem limitați la valori primitive sau liste de șiruri de caractere, ci se pot compune expresii pentru liste de obiecte. Se pot, dar nu suntem mereu utile.

Bunăoară, când se modelează un domeniu de bișniț, ca mai sus, pe o listă de clienți, există o sumă de constrângeri intrinseci ce ne leagă de mâini, de picioare și ne pun căluș la gură:

– verificările structurale pe liste sunt inerent poziționale, ori poziția nu contează cât contează existența pur și simplu a anumitor obiecte;
– pe lângă natura pozițională, se codifică implicit și cardinalitatea și, deși cardinalitatea poate fi realist de interes față de poziție, poate la fel de bine să nu fie.

Parexamplu, dacă vreau să verific că o listă e compusă din cel puțin un client preferat, noțiune unde nu mă interesează poziția acelui client, pot folosi pur simplu .Any()cu predicat (cu toate că pot scrie o expresie, nu pot scrie dintr-o bucată și nici așa nu pot înlocui pe deplin conceptul foarte simplu descris prin .Any()):

customers.Any(c => c.IsPreferred())

De-aș dori, în schimb, să stabilesc dacă lista-i formată doar din clienți preferați, nu pot face asta decât dacă știu lungimea și sunt dispus să scriu suficient de mult același cod (ceea ce-aș face de-aș avea garanțiile că nu eu voi fi răspunzător să-l modific în viitor):

bool isAllPreferredPatterned = customers is [
    {
        CustomerForDays: > 150,
        LatestOrder: { Active: true, Amount: > 100 }
    },
    {
        CustomerForDays: > 150,
        LatestOrder: { Active: true, Amount: > 100 }
    },
    {
        CustomerForDays: > 150,
        LatestOrder: { Active: true, Amount: > 100 }
    }
];

//versus the more traditional way:

bool isAllPreferredSane = customers.All( c
    => c.IsPreferred() );

Poate părea un exercițiu absurd, însă pentru a stabili niște limite rezonabile, conceptele trebuie duse mai întâi până la ultima consecință. Iar concluzia e că, din păcate, sintaxa încă nu poate să gândească pentru noi. Dar poate să facă suficient!

Constructori primari

Trebuie să mărturisesc că-s o găselniță insolită și, pentru elementele cu unic rol ca vas de date (record, DTO, alte modele de transport, view-uri sau intermediare), perfect, aproape perfect. Există, în ceea ce privește uzul general, diverse argumente pro și contra, unele împărtășite și de mine, altele nu.

Per total, fie că-s eu mai greu de scos din boii mei, fie că-s pur și simplu mai tipicar, mi se pare un aspect atât de gingaș încât mai mult îl evit decât îl folosesc. Motivul principal e că un constructor clasic mi se pare deja ideal pentru inițializarea obiectului, nu-i cu mult mai logoreic și, situat în general la începutul clasei, nu-i greu de ajuns acolo. Orișicât, iată o situație unde costul este rezonabil, o simplă clasă de opțiuni ușor condensată drept urmare:

public class TaskQueueOptions( ConnectionOptions connectionOpts,
     QueuedTaskMapping mappingOpts,
     SerializerOptions serializerOpts )
{
    public TaskQueueOptions( ConnectionOptions connectionOpts,
        QueuedTaskMapping mappingOpts )
        : this( connectionOpts,
              mappingOpts,
              SerializerOptions.Default )
    {
        return;
    }

    public ConnectionOptions ConnectionOptions
    {
        get; private set;
    } = connectionOpts
        ?? throw new ArgumentNullException( nameof( connectionOpts ) );

    public SerializerOptions SerializerOptions
    {
        get; private set;
    } = serializerOpts
        ?? throw new ArgumentNullException( nameof( serializerOpts ) );

    public QueuedTaskMapping Mapping
    {
        get; private set;
    } = mappingOpts
        ?? throw new ArgumentNullException( nameof( mappingOpts ) );

    public string ConnectionString => ConnectionOptions
        .ConnectionString;
}

Când zic ideal fac aluzie și apel la considerentul semantic: există un loc dedicat ceremoniei de comisionare a obiectului, unde se pot face sau nu validări, derivări, pre-procesări ș.a.m.d. Constructorul primar propune și impune desfășuarea acestei ceremonii pe fiecare membru afectat în parte, ca niște hârtiuțe împrăștiate alandala pe birou în loc să stea chitite cuminte într-un teasc.

În mod egal îmi displace poluarea antetului clasei cu parametrii necesari inițializării, nu simt nevoia să-i văz acolo, ba chiar mă indispun. În mod normal nu ar trebui să fie foarte mulți; totuși, până se atinge un echilibru, pot fi. Nu vreau în atare situații să-mi sucesc gâtul sau să-mi trimit globii oculari să execute tumbe peste dânșii pentru a descifra definiția clasei.

În loc de încheiere

Înainte să semnez de plecare, aș vrea să notez și câteva chestiune legate de complexitatea lizibilității. Probabil că este ceva ce ține și de obișnuință și de formare (încă mai țin minte entuziasmul trecerii la rezoluția de 800×600, iar rezoluțiile mici au creat în general un stil de scriere și o modalitate de citire orientată preponderent pe verticală – nici acum nu suport și nu pot citi linii lungi fără să-mi sângereze ochii), dar eu cred că e și o componentă general umană.

Reluând exemplul verificării antetului unui JPEG, deși varianta clasică este mult mai lungă, are proprietatea interesantă că poate fi descompusă în linii autonome mai ușor de citit (cu atât mai ușor cu cât indexul este explicit), versus unicul artefact sintactic reprezentat de-o listă de verificări structurale:

data.Length >= 3 &&
    data[0] == 0xFF &&
    data[1] == 0xD8 &&
    data[2] == 0xFF

Și sunt doar trei verificări, pe lângă preliminarul lungimii. Pe măsură ce numărul de elemente crește, dificultatea vizuală incumbată de cele două variante crește liniar în cazul sintaxei clasice și, aș zice, exponențial, în cazul evaluărilor structurale. Ochiul trebuie, indiferent de artefactul folosit, să:

– urmărească poziția
– interpreteze operatori logici (dacă sunt)
– rețină contextul;
– țină minte locul ori elemente ce afectează secvențierea (operatorul .. ori _).

În mod ironic, propozițiile imperative, te iau de mânuță și-ți dau cu lingurița. Enfin, un corolar la fel de important e că același motiv pentru care aceste propoziți trebuie ținute din scurt devine cu atât mai relevant când folosim artefacte sintactice mai dense, cum sunt noile posibilități de expresie ale limbajului.

Lasă un răspuns

Adresa ta de email nu va fi publicată. Câmpurile obligatorii sunt marcate cu *

Anti-Spam * Time limit is exhausted. Please reload CAPTCHA.

Acest site folosește Akismet pentru a reduce spamul. Află cum sunt procesate datele comentariilor tale.