Programozás: amit nem muszáj, de hasznos

2018.02.25. · tudomány

Kérdezték tőlem, hogy voltaképpen mi is a célom ezzel az egésszel, mire jó a programozásról elemi szinten, vagy még annál is dedósabban sorozatot írni, mikor bármelyik programozási nyelvet számtalan helyen meg lehet tanulni, akár segítséggel, akár önállóan, sokkal gyorsabban, mint ha valaki az én lassú és egyáltalán nem célratörő csapongásaimat olvasgatja. Való igaz, hogy nem az a szándékom, hogy az olvasó gyors sikert érjen el úgy, hogy máris tud írni egy programocskát, ami valami mulatságosat csinál. Még egy konkrét programozási nyelvet se választottam, amibe szépen bevezetem az olvasót. Pláne nem törekszem arra, hogy áttekintést adjak sok különböző „paradigma” alapötleteiről vagy technikai megoldásairól. Ehelyett különböző részleteket ragadok ki különböző nyelvek különböző lehetőségeiből, mert valójában nem a számítógépről szeretnék mesélni, meg a programokról, amiket futtatni lehet rajta, hanem a programozó ember gondolkodásáról, amint a saját, nem informatikai gondolatait próbálja megfogalmazni olyan pontossággal, hogy abból akár automatikusan működő számítógépes rendszer is épülhessen.

Azok a csodálatos modulok

Eddig csupa olyanról beszéltem, amit nem lehet megkerülni, ami feltétlenül velejárója az ilyen gondolkozásnak: eljárások formájába kell öntenie algoritmusokat, és ábrázolást kell kitalálnia az adataihoz. Ma viszont olyasmiről lesz szó, amire elvileg nem lenne szükség, csak a gyakorlatban rettentő hasznos. Arról, hogy a programunkat hogyan lehet egészen kicsi, egymástól nagyjából független (és ezért külön-külön lecserélhető) modulokból összeállítani. Rengeteg olyan program van, ami nem így készül, és azok is tök jól működhetnek, csak sokkal nehezebb őket karbantartani, folytatni, a részeiket máshol felhasználni, másokkal megosztani vagy együttműködni a továbbfejlesztésükön, mint ha moduláris módon írták volna meg őket.

Vegyük megint a francia kártya 52 lapját. A múltkor is jeleztem, de talán nem elég hangsúlyosan, hogy számtalan módon lehet őket ábrázolni a memóriában. Akkor azt mutattam meg, hogy tekinthetjük a sorba rakott lapokat, a paklit egy 104 számból álló tömbnek, amiben minden páros 2 * i sorszámú elem az i sorszámú lap színét kódolja, a 2 * i + 1 sorszámú elem pedig ugyanennek a lapnak az értékét. Választhattam volna azt is, hogy a színeket meg a lapértékeket nem számok ábrázolják, hanem csak néhány bites sorozatok, hiszen messze nincs annyi szín meg érték, mint ahány szám. Lehetett volna azt is csinálni, hogy két 52 elemű tömböt használunk, az egyikben az i indexnél az i-edig lap színét, a másikban ugyanannál az indexnél ugyanannak a lapnak az értékét. És így tovább, mindenféle lehetőségek vannak. A lényeg az, hogy ha moduláris módon programozunk, akkor nem szabad számítania, hogy melyik megoldást választjuk, amikor valamit csinálni akarunk a kártyapaklival, akkor ezt egyáltalán nem kell tudnunk. Ha a programnak egy másik részét, mondjuk azt, amiben a játékosok döntéseit modellezzük, másvalaki írja, akkor neki ne is kelljen belenéznie abba a részbe, ahol a lapok és a pakli ábrázolását megoldottuk.

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

A modulok tehát kívülről nézve fekete dobozok, mindegy, hogy mi van bennük, csak az a lényeg, hogy mire hogyan tudjuk használni őket. Ezt, amit a külvilág tud róluk, szakszóval úgy szoktuk hívni, hogy interfész (vagy interface, ahogy tetszik). Ami pedig bennük van, amit kintről nem kell látni, úgy hívják, hogy a megvalósításuk, implementációjuk.

A modul mint fekete doboz

Mondok erre egyszerű példát, egy kicsit eltúlozva a dolgot, mert azt fogom feltételezni, hogy még a kártya színeinek az ábrázolását is egy kis modulba, fekete dobozba csomagoljuk be, pedig ezt a valóságban valószínűleg nem tenném külön modulba. Mit kell tudnia a külvilágnak a kártya színeiről? Igazán nem sok mindent, de például hasznos, ha bármelyik színt meg tudjuk valahogy jeleníteni a képernyőn (például kiirathatjuk a képernyőre azt a stringet, hogy pikk, vagy hogy ♠). Ha a kártyajátékban szerepe van (pl. ha bridzsről van szó), akkor tudnunk kell, hogy két szín milyen viszonyban van egymással (alacsonyabb értékű-e az egyik a másiknál, és ha igen, melyik az alacsonyabb). Lehet, hogy még mást is akarunk csinálni a színekkel, de most maradjunk ennél a két dolognál. Akkor a fekete dobozunk, a modulunk interfészének ezeket a szolgáltatásokat kell tartalmaznia, például eljárások formájában: a külvilág megkérheti a modulunk egyik eljárását, hogy mutassa meg (adja vissza értékként) egy bizonyos szín string-ábrázolását, vagy döntse el (megint valamilyen érték visszadásával), hogy két szín hogy viszonyul egymáshoz, és így tovább. És nem utolsósorban, hogy ezeket az eljárásokat el tudjuk indítani, tudnunk kell egyáltalán színekre hivatkozni, tehát kell lennie egy adattípusnak, nevezzük úgy, hogy szin, aminek a részleteit, az ábrázolását nem kell ismernie a külvilágnak, de ilyen típusú létezőkről kell tudnia beszélni. Ennek az adattípusnak a neve is az interfészhez tartozik.

Most olyan programozási nyelvet fogok példaként használni, amiben mindezt a legszebben és legegyszerűbben tudom megmutatni, az OCaml nyelvet, az SML (Standard ML) nyelv egyik leszármazottját. Ezen nyelven a színekhez tartozó interfész ilyeneket fog tartalmazni:

type szin

Ez csak annyit jelent, hogy van a lényeknek egy csoportja (típusa, osztálya), amit szimbolikusan úgy hívunk, hogy szin. Aztán:

val nyomtathato : szin -> string

Ez meg azt jelenti, hogy kell legyen egy eljárásunk (függvényünk), aminek az egyetlen argumentuma egy szin típusú dolog, a visszatérési értéke meg egy string típusú dolog. (Ebből látszik, hogy az OCaml azok közé a nyelvek közé tartozik, amiket a múltkor funkcionális nyelvnek neveztünk.) A szin nevű típus az értelmezési tartomány, a string nevű meg az értékkészlet. A függvényeknek az ilyen jellemzését a függvény szignatúrájának vagy prototípusának nevezik. (Egyébként az egész interfész leírását is nevezik a modul szignatúrájának.) És akkor még van ez:

val hasonlit : szin * szin -> int

Ez is egy függvény szignatúrája, csak ennek az értelmezési tartományában két szin típusú dologból álló párok vannak (ezt jelöli a két típus közé tett „*” jel), az értékkészlete meg egy egész szám. Természetesen magyarázatra szorul, hogy miért és hogyan jelöljük számmal két szín összehasonlításának az eredményét. Ez elég általános konvenció, és valójában csak háromféle szám lehet a kapott érték: a –1 szám szokta azt kódolni, hogy a pár első eleme kisebb, az 1 szám azt, hogy a második elem kisebb, a 0 pedig azt, hogy egyforma nagyságú a két elem.

Az OCaml nyelven az interfészeket modultípusoknak nevezik, így kell őket megadni:

module type SZIN =

sig

(* A kártya színeinek típusa: *)

type szin

(* Színhez nyomtatási képet rendel: *)

val nyomtathato : szin -> string

(* -1: az első alacsonyabb; 1: a második; 0: egyformák: *)

val hasonlit : szin * szin -> int

end

A „(*” és „*)” jelek között kommentek vannak, ezek nem a program részei, hanem arra valók, hogy az interfész használatát magyarázzák el annak, aki használni akarja a modult.

Mindenféle mással is gazdagíthatnánk persze ezt az interfészt, például lehetővé tehetné a modul, hogy az egyes konkrét színekre szimbólumokkal hivatkozni tudjon a külvilág:

(* A treff szín (a legalacsonyabb): *)

val treff: szin

(* A káró szín (a treff után következő): *)

val karo: szin

És így tovább. Ezeket a szimbólumokat néha konstansoknak nevezzük, ha pedig függvény-mániások vagyunk, akár konstans függvénynek is tekinthetjük őket, mindig ugyanazt az értéket adják vissza, az illető színeket.

Jöhet az implementáció!

És akkor most akkor térjünk rá arra, ami a modul belügye, hogy hogyan ábrázoljuk a színeket, és hogyan definiáljuk a két függvényünket. Tehát jöjjön a fenti interfész (modultípus, modul-szignatúra) egy lehetséges megvalósítása, implementációja. Ugyanazt az interfészt többféleképpen is implementálhatjuk, akár ugyanabban a programban is, ezért nevet is kell adni az implementációknak. (Sőt, ennek a fordítottja is igaz, ugyanahhoz az implementációhoz több interfész is tartozhat.) Az OCaml nyelven az implementációt egyszerűen modulnak, a leírását struktúrának nevezik, ezért így fog kinézni egy implementáció:

module Szin : SZIN = struct (* ... *) end

Az első sor azt jelenti, hogy ennek az implementációnak (modulnak) Szin lesz a neve, és azt is kijelentjük, hogy ez a modul a SZIN nevű modultípus (interfész) egyik implementációja. Ezek után jönnek majd, a struct és az end között, a definíciók.

Az implementáció részletei a mostani téma, a modularitás szempontjából már nem is érdekesek, csak a rend kedvéért mutatok meg egy lehetőséget:

type szin = int

val nyomtathato =

fn 0 => "♣"

| 1 => "♢"

| 2 => "♡"

| _ => "♠"

val hasonlit =

fn (0, 0) => 0

| (0, _) => -1

| (1, 1) => 0

| (1, _) => -1

| (2, 2) => 0

| (2, _) => -1

| (3, 3) => 0

| (_, _) = 1

Az első sorban egyszerűen megadtuk, hogy a szin típus az egész számok típusával azonos (de erről a külvilág nem fog tudni). Ezután jön a két függvény definíciója, itt erre egy érdekes formátumot választottunk: az fn kulcsszó után az argumentumok egy-egy értékéhez egyenként megadjuk (egy „=>” jel után) a függvény visszatérési értékét. A „_” jelet úgy kell kiolvasni, hogy „bármi más”. Tehát ha a hasonlit függvény argumentuma a (0, 0) rendezett pár, akkor 0 az értéke, ha pedig az első elem 0, és a második bármi más (de nem 0, mert az már volt), akkor –1 az értéke (mert a treff a legalacsonyabb szín).

Házi feladat

Otthoni feladat (nem kell megoldásokat beküldeni): írjunk a lapértékekhez, majd a lapokhoz tartozó interfészeket és implementációkat. Az egész kártyapaklihoz tartozó modult az eddigiek alapján ezen a nyelven nem tudjuk még megírni, mert annak az interfészéhez olyanok tartoznak, mint a pakli megkeverése, kiosztása (ami azt jelenti, hogy kisebb paklikat hozunk létre belőle), és ezeket a fentiek alapján még nem tudjuk hogyan implementálni. De azon gondolkozzon csak el az olvasó, hogy mi mindent szeretne a pakli-modul interfészében látni.

A teljes igazság kedvéért hozzáteszem, hogy sok olyan szituáció van, amiben a modulok nem ilyen egyszerűek, és sok olyan programozási nyelv is van, amiben másképp kell őket felfogni (például önálló létezőkként kell felfogni őket, és helyet kell nekik csinálni a memóriában). Erre kell lelkileg felkészülnünk, de egyelőre ne foglalkozzunk a részletekkel.

Összefoglalva: Amikor benépesítjük az univerzumunkat, ügyelnünk kell arra, hogy ne csináljunk rendetlenséget. Külön sarkokba tesszük az összetartozó dolgokat, és gondosan meghatározzuk, hogy a különválasztott dolgok egymással pontosan hogyan kommunikálhassanak. Én ugyan nem vagyok rendmániás, sőt, de azt tapasztaltam, hogy ha a virtuális térben nincs rend, az iszonyú sok plusz munkát és időveszteséget jelent.

A szerző nyelvész, az MTA Nyelvtudományi Intézetének főmunkatársa.