A C# védelmében (null <= null)

Éppen az imént olvastam egy jópofa észrevételt, amely első pillanatra meglepő, azonban jobban belemélyedve logikus jelenséget mutat be. A következő példaprogramon láthatjuk, miről van szó:

static void Main(string[] args)
{
  var smallerOrEqual = null <= null;
  var equal = null == null;

  Console.WriteLine(smallerOrEqual);
  Console.WriteLine(equal);
}

a program eredménye pedig:

False
True

Hogyan lehet hamis a kisebb-egyenlő operátor, ha mind a két oldalán ugyanaz az érték szerepel? És miért működik az egyenlőség operátor? Ennek járunk utána a következőkben.

null <= null

Ennek a kifejezésnek az a trükkje, hogy másnak látszik, mint ami valójában. Különösen megtévesztő, ha az ember C++ os háttérrel rendelkezik. A fenti kifejezés ugyanis nem referenciákat (és nem pointereket) hasonlít össze.

Az összehasonlító operátorok

A C# nyelv több összehasonlító operátorral rendelkezik, ilyen többek között a <= operátor. A C# Language Specification-ből megismerhetjük az operátor működését (14.9 Relational and type-testing operators). A lényeg, hogy x <= y esetén, az operátor igaz értéket ad eredményül, ha x kisebb vagy egyenlő mint y, egyébként pedig hamis az operátor értéke. Ezen nem lepődünk meg, de még mindig ellentmondás van a fenti program eredményében, mivel az ember (a <= a) esetén igaz értéket várunk minden a-ra, amely mellesleg matematikában a rendezési relációk definíciójában is szereplő kitétel (reflexivitás).

A specifikáció ezután leírja, hogy a <= operátornak számos előredefiniált változata van, például egész típusokra:

bool operator <=(int x, int y);
bool operator <=(uint x, uint y);
bool operator <=(long x, long y);
...

lebegőpontos típusokra:

bool operator <=(float x, float y);
bool operator <=(double x, double y);

Ezenkívül decimális és enumerált típusokra.

A null vajon mi?

A fentiekből nem derül ki, hogy miért működik a <= operátor a null-ra? Láthatunk overloadot egész, lebegőpontos illetve egyéb numerikus vagy felsorolt jellegű típusokra, de melyikbe passzol a null? Egyáltalán mi az, amikor egy programban leírjuk, hogy null?

C# kódban a literálként leírt null a “null value”-re értékelődik ki, ami két dolgot jelölhet: referenciát, ami nem mutat sehova, illetve egy érték hiányát. (Specifikáció, 11.2.7) A második furcsán hangozhat, de azonnal beugrik miről van szó, ha a nullable típusokra gondolunk.

Sok lehetőség, kevés operátor

Láthattunk egy listát az operator <= overload-jairól. Ami feltűnhet, hogy mindegyik overload mindkét paramétere azonos típusú. Ugyanakkor tudjuk, hogy az operátor működik kevert esetben is, például

if (12M <= 34L)
{
  ...
}

Ebben az esetben egy decimal és egy long típusú érték kerül összehasonlításra. Igen, legtöbben ismerik az implicit konverziókat, és itt valóban ez történik. A C# elég sok implicit konverziót definiál (Specifikáció 13.1.2), jellemzően egy szűkebb értékkészletű típusról egy bővebbre. A konverziók viszont egy szabályhalmaz alapján mennek végbe, amit megtalálhatunk a C# specifikáció 14.2.6.2 pontjában:

  • ha valamelyik operandus decimális típusú, a másik operandus decimális típusra lesz konvertálva (ha csak nem double vagy float)
  • különben. ha valamelyik operandus double, a másik double-re lesz konvertálva.

  • különben, ha valamelyik operandus uint, akkor a másik operandus uint-re lesz konvertálva,
  • különben mind a két operandus int-re lesz konvertálva.

Ami az egész listából fontos, az az utolsó pont. Ugyanis bármi más, ami nincs a listában, azt int típusra lesz konvertálva.

int vs null

A probléma most az, hogy a null értéket kellene int-té konvertálni. Bár ilyet nem lehet, azért van, ami közelebb visz a célhoz, és ezek a Nullable típusok (Specifikáció 8.19-pont). A specifikációból megtudhatjuk, hogy a C# nyelvben minden Nullable típusra létezik implicit konverzió a null típusról, azaz ha int-et nem is, de int? típusú operandust tud létrehozni a fordító. Kár, hogy nem erre van szükség, hacsak…

“Lifted” operátorok.

Amiatt, hogy a Nullable típusok jobban illeszkedjenek a nyelvbe, a C# számos trükköt bevezet. Ezek egyike a “Lifted” operátorok. Ez azt jelenti, hogy ha egy operátornak van op(T x, T y) formája, akkor van neki egy “lifted” op(T? x, T? y) formája is. Ezek egyszerű kiegészítései az eredeti operátoroknak, ahol definiálva van, hogy mi legyen az eredmény, ha x vagy y vagy mindkettő értéke null. A <= esetében például a definíció azt mondja ki, hogy ha az egyik vagy másik vagy mindkettő operandus értéke null, akkor az eredmény hamis. (Specifikáció, 14.2.7)

Mi történt tehát?

A folyamat most már könnyen átlátható: a C# fordító talált egy <= operátort, null operandusokkal. A belső szabályai szerint elkezdte keresni az operandus típusának megfelelő overload-ot, amit nem talált. Emiatt az implicit konverziók szabályai szerint a null-okat int?-re konvertálta, ezeken hajtotta végre a műveletet az operator <=(int? x, int? y) overloadot használva. Ez definíció szerint False értéket adott vissza.

De miért pont False?

Bár a folyamatot látjuk, még mindig lehet vitatkozni, hogy miért pont False értékre definiált a null <= null, amikor mind a két oldalon ugyanaz szerepel. Csakhogy az a feltevés rossz, hogy mind a két oldalon ugyanaz szerepel. A null a C# specifikáció szerint ebben az esetben az érték hiányát jelöli. Nincs mit hasonlítgatni. Láthatunk még ilyet, mégpedig a lebegőpontos típusoknál a NaN, ami ugyanígy viselkedik. Ha ez még mindig nem elfogadható, nézzük az alábbi kis példát:

static Dictionary<string, int> statistics = new Dictionary<string,int>();

static int? GetScoreOf(string thing)
{
    int score;
    if (statistics.TryGetValue(thing, out score))
    {
        return score;
    }
    else
    {
        return null;
    }
}

static void Main(string[] args)
{
    var scoreOfMine = GetScoreOf("Me");
    var scoreOfYours = GetScoreOf("You");

    if (scoreOfYours <= scoreOfMine)
    {
        Console.WriteLine("Legalább olyan menő vagyok, mint te!");
    }
}

A fenti kis program bizonyos dolgokra vonatkozó adatokat próbál előszedni. Ha az adat nem áll rendelkezésre, akkor a null értéket használja. A program törzse ezután a kapott adatok alapján hoz döntést. Hogyan érezzük helyesnek, ha mind a scoreOfYours mind a scoreOfMine “értéke” null, akkor jogos kiírni a szöveget? Az én véleményem szerint nem. Ez egyben arra is intő példa, hogy hiba lenne szimplán egy else ágat bevezetni egy ellentétes akcióhoz, mivel ebben az esetben azt sem mondhatjuk, hogy “kevésbé vagyok menő, mint te”.

null == null

Mostanra talán legtöbben elfogadhatónak tartják a C# viselkedését null <= null esetében. Ez a viselkedés azonban látszólag ellentmond a null == null eredményének, ami viszont True. Miért van ez?

Ha figyelmesen olvassuk a C# specifikációt, a 14.9 pontban találhatjuk leírva a következőket: ha egy equality-expression (ami x == y vagy x != y) mind a két operandusa null típusú (és így null értékű), akkor az “overload resolution” (tehát amikor azt keresi a fordító, hogy melyik overload-ot kell használni a kifejezés kiértékeléséhez) nem fut le, hanem a kifejezés értéke definíció szerint True az == esetében, False a != esetében. A (null == null) így lényegében a True konstans egy trükkös leírása. A kérdés már csak az, hogy miért ezt az értéket választották a C# tervezői? A korábbi kis példaprogram az == operátorra is alkalmazható, ahol nem null literálokat, hanem változókat használunk:

static void Main(string[] args)
{
    var scoreOfMine = GetScoreOf("Me");
    var scoreOfYours = GetScoreOf("You");

    if (scoreOfYours == scoreOfMine)
    {
        Console.WriteLine("Ugyanolyan menő vagyok, mint te!");
    }
}

Ebben az esetben a szöveg kiíródik akkor is, ha egyikünk score értékét sem sikerül meghatározni, tehát nincs információ arról, hogy egyformán menők vagyunk-e. Ebből az irányból közelítve tehát nem tűnik jónak a működés. A C# tervezőinek azonban mást is kellett mérlegelni. Ha van egy nullable típusom, és azt akarom megvizsgálni, hogy ennek van-e értéke, hajlamos vagyok a következő sorokat leírni:

if (scoreOfMine == null)
{
    Console.WriteLine("Nem tudom milyen menő vagyok");
}

Lát valaki kivetni valót a fenti kódban? Legtöbbeknek logikusnak és egyértelműnek tűnik (nekem biztosan igen), pedig éppen azt bizonygatjuk, hogy az if-ben szereplő kifejezés értékének false lenne a logikus értéke. Egy tökéletes világban a fenti feltételt így vizsgálná minden programozó:

if (!scoreOfMine.HasValue)
{
    Console.WriteLine("Nem tudom milyen menő vagyok");
}

Egy ilyen világban lehetne a null == null értéke False, és senki nem járna pórul. De más programnyelvek illetve a referencia típusokra alkalmazható szabályok miatt a programozók többsége a scoreOfMine == null értékétől szintén azt várja, hogy ha scoreOfMine-nak nincs értéke, akkor a kifejezés igaz lesz. A C# tervezőinek így a kerek világ illetve a működő világ között kellett választani, és amint látjuk, a működő világot választották, azon az áron, hogy a == operátor és <= operátorok körül kicsit torz ez a világ.

Konklúzió

A fentiekben a C# nyelv egy furcsaságát vizsgáltuk meg. A jelenséget labor körülmények között, vagy matematikai megközelítéssel élve csúnyának ítélhetjük meg. A C# azonban egy ipari környezetben használt eszköz, amivel súlyos pénzeket termelnek. Ennek megfelelően a célja nem az, hogy a háttere kerek legyen, hanem az, hogy kényelmesen kézbe feküdjön. Ehhez arra van szükség, hogy az általánosan előforduló helyzetekben az történjen, amit a programozó elvár, még akkor is, ha ez speciális helyzetekben ellentmondást mutat.

…Mondjuk egy Warning-ot adhatna a fordító ezekben a helyzetekben…

Kicsit árnyalja a képet a Devportálon a témában kialakult szál

  1. #1 by Péter Juhász on 2012. January 3. - 17:27

    “equality-expression”-ből lemaradt egy ‘s’. Tetszett a cikk, érdekes volt!

  2. #3 by rlaci on 2012. January 4. - 15:22

    Nagyon érdekes tanulságos, mint úgy általában ez a blog. A devportal-os fejtegetés is jó.

  3. #4 by eMeL on 2012. January 5. - 10:37

    A “Konklúzió”-dat elfogadom. Kicsit olyan ez, mint az SQL-nél.
    Mi is az a null? Hogyan is kezeljem?
    Az “értelmét” kell átlátni (nem érték, hanem állapot!).

    Ugyanebbe a csapdába estünk mi is (és a C++ féle ‘a null egy érték’ szemléletű emberek).

    De ezt az SQL viszonylag konzisztensen oldotta meg😉
    A bemutatott ellentmondás pedig a konzisztencia hiányát mutatja. Ez valódi gond.

    Ez nemhogy ‘warning’ erejű probléma, ez egy a fordítóból hiányzó ‘szintax error’ vizsgálat.

    Már a C++-ban is nagyon haragudtam, hogy akkor is megpróbál automatikusan típusra konvertálni, ha nem eléggé egyértelmű. Ez pedig kényelmes, de veszélyes. Én meg a biztonságot választanám inkább.

    Ahogy említetted egy ( == null) vagy ( != null) még kivételes szabályként (a HasValue helyett) még akceptálható, de a (null == null) esetében már a fordítónak sikítania kellene!
    Mit akar összehasonlítani mivel? Típus infó nélkül nem lenne szabad pl. az int?-et választania. Miért is pont azt?

    Ugyanígy még a
    int? a = …
    if (a <= null) esetén is kiabálnia kell, mert nincs mit, miért vizsgálni. Hagyja magát egy automatizmussal zsákutcába vinni.

    ugyanakkor a

    int? a = …
    int? b = …
    if (a <= b) már az általad leírt automatizmussal kezelhető (ahogy átlátom helyesen=logikusan is), még akkor is ha egyik vagy mindkettő null státuszu.

  4. #5 by eMeL on 2012. January 5. - 10:58

    Hogy kicsit értelmezzem magam:

    if (a <= null)
    ez ugye annyit tesz hogy
    if (a == null) || (a < null)
    itt ugye az (a==null)-át értelmeztük már, na de mit jelent az (a < null) ?
    Nincs értelme!
    Ha meg nincs értelme, akkor nem is szabad megengedni hogy a kódban szerepeljen.

    • #6 by Tóth Viktor on 2012. January 5. - 12:12

      Azért nehezebb ez a kérdés egy kicsit, hogy mi legyen szintaktikai hiba és mi nem, mert nagyon-nagyon elbonyolítaná az így is bonyolult nyelvtant (nézd meg a C# specifikáció A.2.4-es pontját az kifejezésekről)
      Ha megnézed, látható, hogy a <= az egy "relational-expression", és a nyelvtan azt engedi meg, hogy egy ugynevezett "shift-expression" legyen a jobboldalon (illetve közvetve a baloldalon). Ez a shift-expression megint sok féle lehet, de lehet benne additive-expression, ahol az additive-expression többek között tartalmazhat multiplicative-expression-t, és sok-sok lépcsőt kihagyva végül eljutunk oda, hogy a lépcső alján megjelenik a null literál. Emiatt az összehasonlító operátoroknak a nyelvtan szerint meg kell engedni a null-t, akkor is, ha szemantikailag értelmetlen. Ennél is bonyolultabb módon megoldható lenne olyan nyelvtant kreálni, ami az összehasonlító operátoroknál nem engedi meg a null literált, de akkor lehet, hogy egy másik forumon azon vitatkoznának, hogy milyen hülyeség már, hogy a

      int? x = …;
      int? y = null;
      if (x <= y) …

      Ez jó, de a

      if (x <= null) …

      ez meg már nem.

      Annyit azért beletettek a fordítóba, hogy az x <= null esetén ad warningot, de pl a null == null az nem ad. Ha valaki lelkes, és beállítja, hogy a warning-ok erroroknak minősüljenek, akkor már majdnem helyben vagyunk. De a warning-okat a cikkben azért hiányoltam, mert szerintem az x <= y esetén sem ártana valami, nullable-k esetén, hogy tudja-e, hogy x és y = null esetén mi fog történni. Lehet, hogy a code analyzer szól miatta, a céges gépemen nem olyan VS-van, most nem tudom megnézni. De a resharpert nem izgatja, pedig az egyébként beszól mindenért.

  5. #7 by eMeL on 2012. January 5. - 13:04

    Persze igazad van, de ha “tisztességes” kódot akarunk írni, akkor

    int? x = …;
    int? y = …;
    if (x <= y) …
    esetén azt kellene írni, hogy

    if ((x = null) || (y == null))
    { …
    }
    else if (x <= y)
    { …
    }

    Ha pedig nincs ilyen vizsgálat, akkor elfogadom, hogy maradhat a "default" false válasz.
    Mint SQL-nél, ha valamely tag null, akkor a kifejezés is null, amit logikaira konvertálva…
    De itt a bibi, a null-t bool-ra konvertálni az máris egyfajta erőszak a konzisztens viselkedésen.

    Én minden ilyen nullable-t tartalmazó vizsgálatra kötelezően előírnám a warningot, amit csak úgy tud elkerülni, ha az általam vázolt "tisztességes" kódra nem sajnálja az idejét, amit persze a syntax elemző felderít. [vagy valamilyen jelzést ad a fordítónak, hogy nyomja el (itt) ezt a warning jelzést]

    Vagyis a kérdés az, hogy a nyelvtan belső szabályait akarjuk erőltetni, vagy a konzisztens (biztonságos!) viselkedést kényszerítjük ki, mégha szintaktikai vizsgálattal kell szemantikai kódolási/működési szabályokat betartatni.

    Ez sem súlyosabb elvi kérdés, mint hogy legyen-e figyelmeztetés, ha egy case szerkezetben nem kezeljük le az összes lehetséges értéket, így 'átcsúszhatnak' kódértékek. Potenciális fejlesztői hiba lehetőség (hiszen pl. egy enum-ba felvett új érték esetén célszerű lenne a teljes kódban a case-ekre szintaktikailag ellenőriznie a programnak). Ez kompatibilitási és kényelmi kérdés is, de elvi probléma. [szerintem valamilyen kód annotáció (mint c-s warning on/off kapcsolás) a "tisztességes" megoldás, sőt én új kódszót vezetnék be megkülönböztetve a case és a 'case exact' műveletet]

    • #8 by Tóth Viktor on 2012. January 5. - 20:20

      Értem mit mondasz, és tényleg szerencsés lenne valahogy (a fordító akciója által) rávezetni a fejlesztőt az általad javasolt formára.
      Egyébként kipróbáltam a vs2010 code analyzer-ét, és meg sem nyekken a nullable-s összehasonlításra. Szóval akinek van kedve, írhat hozzá hobbiból egy új rule-t🙂

  1. In defence of C# (null <= null) | ProC#tology

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

%d bloggers like this: