Ki- és bevezető csövek a programozásban: kapcsolatba lépünk a külvilággal!

A programozásnak ettől a részétől a programozni tanulók nem szoktak lázba jönni, mert kicsit unalmas, de a programok csak akkor tudnak igazán érdekesen viselkedni, ha kapcsolatba lépnek a külvilággal, ha adatokat tudnak onnan beszippantani, vagy oda kibocsátani; szakszóval úgy mondjuk, hogy adatokat beolvasni (input) és kiírni (output). Ezt a lehető legáltalánosabb értelemben kell érteni: a beolvasás jelentheti azt is, hogy egy robot érzékelőitől származó adatokat dolgozunk fel, a kiírást akár úgy is érthetjük, hogy parancsokat küldünk vissza a robotnak. Mindezt úgy tudjuk megvalósítani, hogy a virtuális világunkban nemcsak adatokkal ábrázolt lények lehetnek, hanem olyan be- és kijáratok is, ahonnan és ahová adatok érkezhetnek és távozhatnak. Képzeljük el úgy, hogy lehetnek speciális lényeink, amik olyanok, mint egy cső végződése (mondjuk egy vízvezeték „kiállása”), és ezeken a csöveken át adatokat szippanthatunk be vagy eregethetünk ki.

Grafika: Tóth Róbert Jónás

Bármilyen módon érintkezünk a külvilággal, sose felejtsük el, hogy mi csak a belső, virtuális világunk fölött rendelkezünk (majdnem) teljes mértékben: bármi, amit a kimenő és bejövő csövekkel csinálunk, azok csak kérések, amiket a csövek túloldalán többé-kevésbé készségesen várakozó operációs rendszer teljesít akkor és olyan tempóban, amikor és amennyire éppen ráér. Nem lehet csak úgy kedvünk szerint szippantani és kiköpni dolgokat a csöveken át, azokkal a túloldalon valakinek dolga van. Ezért a beolvasás és a kiírás időigényes lehet.

Van néhány olyan vezeték, ami mintegy eleve be van építve a világunkba, amit készen kapunk a rendszertől (az operációs rendszertől). Három ilyen nevezetes vezeték van: az ún. standard input, ahonnan alapesetben a billentyűzeten bepötyögött betűket szippanthatjuk be, az ún. standard output, ahova alapesetben a (nem grafikus) képernyőre szánt betűket küldhetjük ki, és ettől meg szokták különböztetni a standard error nevűt, ahova a felhasználónak szóló hibaüzeneteket küldhetjük (alapesetben ez is a képernyő). A standard outputra (a képernyőre) való kiírás korábbi gyakorlatainkban már előfordult, például amikor Java nyelven írtunk egy kártyaosztó programot:

System.out.print( lapok[k++].kep() );

A Java nyelvben a System.out maga a standard outputnak megfelelő kivezető cső neve, ennek az egyik módszere a print, aminek az egyetlen paramétere egy betűsorozat, azt küldi ki a csövön.

Azt, hogy „alapesetben” a cső ide vagy oda vezet, úgy kell érteni, hogy különböző átszerelésekkel elintézhető, hogy az adatokat eltereljük, és hogy ne az alapértelmezett helyükről jöjjenek, vagy ne oda menjenek a jelek, hanem például valamilyen adathordozón tárolt file-ból, illetve file-ba. Például a hibaüzeneteket gyakran szokták file-okba átirányítani, hogy ne zavarjanak. De ezeket az átirányításokat nem a programunkon belül hajtjuk végre.

És mivel a virtuális világunk szabad és szabadon bővíthető, bármikor létrehozhatunk újabb ki- és bejáratokat, ha megmondjuk, hogy honnan vagy hova vezessenek. Ilyen általunk létrehozott csatornákat használunk például arra, hogy meghatározott file-ok tartalmát beszippantsuk, vagy hogy meghatározott file-okba írjuk azt, amit akarunk (általában abból, amit a programunk produkált). Például a C nyelven így nyithatunk egy kivezető csövet, aminek a másik végén egy kimenet nevű file van (vigyázat, ha a file-ban már volt valami, akkor ha így nyitjuk meg, a tartalma elvész!):

#include    // kell, ha input-output műveletek lesznek
   // Deklaráljunk egy file-hoz vezető csövet,
   // a file nevét kell megadnunk, és hogy hogyan vezessen
   // oda a cső:
   FILE* kivezeto = fopen( "kimenet", "w" );

A fopen eljárás második paramétere, a "w" string arra utal, hogy a file-ból nem olvasni akarunk, hanem írni bele, egyúttal vállaljuk, hogy a korábbi tartalma, ha volt, elvész. Ha a "w" helyett az "a" stringet írtuk volna oda (ez annak a rövidítése, hogy append), akkor a régi tartalom nem veszne el, hanem ha a file már létezett, akkor a végére tudnánk írni.

Amikor a képernyőn mindenféle grafikus dolgokat akarunk megjeleníteni, akkor is a világunkból kivezető csatornákat használunk, de olyankor általában nem közvetlenül a képernyő pixeleihez vezetnek a csövek, hanem külön erre a célra írt programcsomagokat, programkönyvtárakat használunk, és nekik küldjük a csöveken az utasításainkat, hogy mit rajzoljanak. Ezt a témát még egy kicsit későbbre hagyom.

Az, hogy hogyan szippantunk be adatokat a bemenetekről, és hogyan eregetünk ki adatokat a kimenetekre, meglepően hasonlóan működik a különböző programozási nyelveken. Nem csoda: ezek a műveletek szoros kapcsolatban vannak a közvetlen külvilággal (az operációs rendszerrel), azzal a külső adottsággal, ami nem függ a programozási nyelvtől. Ezért ebben a részben csak arról fogok beszélni, hogy milyen speciális kérdéseket vet fel a bejövő és kimenő csatornák használata.

Hibalehetőségek

Bár nem tartozik szorosan ide, mégis itt mondom el a következőket, mert a beolvasásnál és a kiírásnál különösen gyakran fordulhatnak elő hibák. Ilyen hiba lehet, amikor szeretnénk egy csövön egy file-ból olvasni, de az a bizonyos file nem létezik. Például Python nyelven így lehet kezelni ezt a helyzetet:

 try:
       ## próbáljuk meg megnyitni ezt a file-t olvasás
       ## céljából, ezt jelenti az "r" (angol "read"):
       bejovo = file( "bemenet", "r" )
   except IOError:
       ## ide jön az, hogy mit tegyünk, ha nem sikerült:
       ## ...

A lényeg az, hogy egy külön blokkba (amit a try kulcsszó vezet be) írjuk azt a programrészt, amiről tudjuk, hogy hibát eredményezhet (és azt is tudjuk, hogy milyen fajta hibát, ebben az esetben az IOError nevűt). Utána pedig egy másik blokkba írjuk bele, hogy mit tegyen a program, ha ilyen hiba következik be (ahelyett, hogy „elszállna”, vagyis egyszerűen leállna, esetleg valami alig értelmezhető dolgot kiírva a képernyőre). A legkevesebb, amit ebbe az except kulcsszóval kezdődő blokkba beírhatunk (ahol az except szót követi a várható hiba fajtájának a neve), hogy valami értelmes hibaüzenetet írjon ki a programunk, és aztán valami értelmes helyen folytassa a futást (vagy szép módon álljon le). Amit a Python nyelvben except-nek hívnak (az angol exception `kivétel' szóból, ami a programban előforduló hibák másik neve), máshol gyakran catch-nek hívják (angolul catch `elkap', vagyis elkapjuk a hibát, mielőtt még leálláshoz vezetne).

Egyébként a legtöbb programozási nyelvben mi magunk is létrehozhatunk új hibafajtákat, és szándékosan elő is idézhetünk hibát, amit valahol máshol elkapunk – azt, hogy saját magunk idézünk elő hibát, angolul úgy hívják, hogy raise, és általában a raise kulccszóval jelölik, például Pythonban:

  ## A hibafajtánk egy új osztály lesz, ami az
   ## Exception nevű osztály alosztálya:
   class SajatHibank( Exception ):
       ## Semmilyen tulajdonsággal nem kell rendelkeznie,
       ## az üres blokkot így jelezzük:
       pass

   def sajatEljarasunkAmibenHibaLehet():
       ## ...
       if valamiBajVan:
           ## ha véletlenül ide kerülünk, idézzünk elő egy hibát:
           raise SajatHibank
       ## ...

   try:
       sajatEljarasunkAmibenHibaLehet()
   except SajatHibank:
       ## amit ebben az esetben tenni akarunk...
       ## ...

Olvasható és bináris

Az egyszerűség kedvéért kétféle adatot különböztetünk meg, amiket a csöveken ki- és be lehet juttatni: az ember számára olvasható stringeket (betűsorozatokat) és a virtuális lényeket ábrázoló más bitsorozatokat. A stringeknek nagyjából egységes értelmezésük van, a csövek túloldalán is ismerik őket. De azért azzal számoljunk, hogy több létező szabvány van, például a magyar karaktereket kódolhatjuk az ISO-8859-2, más néven „latin-2” nevű szabvány szerint, vagy sokkal ritkábban a Windows 1250 jelű kódjával, és ma leggyakrabban a Unicode nevű szabvánnyal (ezen belül főleg az UTF-8 nevű változattal). Ilyen betűsorozatokat írtunk ki az eddigi példáinkban is.

Amikor a különböző lényeinket (számokat stb.) eresztjük ki a csöveken, akkor azt leginkább azért tesszük, hogy file-okban eltároljuk őket (hiszen a csövek túlsó végén az operációs rendszernek fogalma sem lehet, hogyan kellene őket emberi olvasásra alkalmas módon megjeleníteni), és arra számítunk, hogy aki onnan majd vissza akarja olvasni őket, az pontosan tudni fogja, hogy hogyan kell őket értelmezni. Ezt ugyanis kitalálni nem lehet, bárki ránéz egy ilyen módon keletkezett file-ra, csak értelmetlen bitsalátát lát.

Például a .doc kiterjesztésű, szövegszerkesztők által használt file-okat a szövegszerkesztő programok úgy állítják elő, hogy a virtuális világukban létező lények, a szerkesztett dokumentumok legtöbb jellemzőjét bináris formában, egy igen bonyolult rendszer szerint kiírják. (Az erre vonatkozó jelenlegi szabvány egy 576 oldalas dokumentum!) Például az ilyen file-ok első két byte-ja (vagyis az elsőtől a 16-ik bitig) annak az információnak a kódját tartalmazza, hogy ez egy .doc file, a harmadik és negyedik byte-ja pedig (vagyis a 17-iktől a 32-ik bitig) egy egész szám bináris ábrázolása, ez jelöli, hogy a szabvány melyik kiadását (verzióját) feltételezi a file. De aki a szabványt nem ismeri, ezt nem tudhatja.

Technikailag nem nehéz a bináris adatok kiírása és beolvasása. Annyit érdemes még megszokni, hogy bár az adatok egyenként (ha úgy tetszik: bitenként) folynak a csöveken, általában nagyobb adagokban szoktuk kieregetni vagy beereszteni őket (például azért, hogy kevesebbszer kelljen kéréssel fordulni az operációs rendszerhez). Az eddigi példáinkban is egyszerre egy csomó betűt írtunk ki, egy stringbe rakva. Szóval sokszor előre odakészített edényekbe, vagyis erre a célra fenntartott memóriarekeszekbe, ún. bufferekbe gyűjtjük azt, amit majd ki akarunk írni (vagy ezekbe folyatunk egyszerre meghatározott mennyiségű beömlő adatot).

A példa kedvéért maradjunk a C programozási nyelvnél. Ha a fenti módon megnyitottuk a kivezeto-nek nevezett file-t, akkor például így írhatunk bele bináris formában négy darab egész számot:

 int edeny[4];    // buffer
   int kiirtAdatok; // ellenőrzéshez
   // feltöltjük számokkal az 'edeny' tömböt:
   // ...
   // kiírjuk az adatokat:
   kiirtAdatok = fwrite( edeny, sizeof( int ), 4, kivezeto );
   // ...
   // zárjuk el a csövet, vagyis zárjuk be a file-t:
   fclose( kivezeto );

És kész! A fwrite eljárásnak négy paramétere van: az a buffer, ahonnan a kiírandó adatokat sorban kiírjuk, az egyes adatok mérete (a sizeof egy adattípus nevére alkalmazva megadja az illető adattípus méretét), a kiírandó adatok száma, és végül az a csővezeték, amin keresztül az adatokat ki akarjuk ereszteni (ennek egy korábban írás céljából megnyitott file-nak kell lennie). Ha az érték (amit itt a kiirtAdatok változóban tároltunk) nem annyi, mint ahány adatot ki akartunk írni (vagyis 4), akkor baj van, akkor valamiért nem sikerült a csövön kiküldeni őket.

Végül: arra kell ügyelni, hogy az adatok, amiket így kiírtunk a file-unkba (a példánkban ez négy egész szám), bitről bitre úgy vannak ábrázolva, ahogy a C nyelv a memóriában ábrázolja őket. Annak ellenére, hogy a C a legelterjedtebb szabványt használja erre, semmi sem garantálja, hogy egy másik gépen vagy egy másik nyelven ennek a file-nak a tartalmát ugyanannak fogják értelmezni, mint aminek mi szántuk! (Persze egy külön dokumentumban leírhatjuk, hogy mi a pontos értelmezése, ugyanolyan leírással, mint a feljebb említett dokumentum a .doc file-okról.) De a C nyelven írott hasonló programmal persze vissza tudjuk olvasni az adatainkat: van egy másik eljárás a C-ben, amit nem meglepő módon fread-nek hívnak, és aminek pontosan ugyanolyan paraméterei vannak, mint az fwrite-nak (kivéve, hogy ott a file-nak olvasásra megnyitott, befelé vezető csőnek kell lennie), az a file-ból olvassa a bináris adatokat, és tölti fel vele a megadott tömböt.

A szerző nyelvész, az MTA Nyelvtudományi Intézetének főmunkatársa. A Qubit.hu-n megjelent írásai itt olvashatók