Számábrázolás. Alprogramok

1 Emlékeztető: tömbök, tételek

Tömbök

/* 10 elem, tomb[0…9] */
int tomb[10];

for (i=0; i<10; i+=1)
   printf("%d\n", tomb[i]);

Tételek

/* 'E' betűk számlálása */
db=0;
scanf("%c", &betu);
while (betu!='\n') {
   if (betu=='e' || betu=='E')
      db+=1;
   scanf("%c", &betu);
}

2 Angry Birds szorgalmi feladat

Angry Birds – Hajnal Márk

Hajnal Márk rajza

Számítógép, számábrázolás, számrendszerek

4 Számítógép: elvi felépítés

Számítógépek elvi felépítése Notebook számítógép CPU-ja
Processzor
CPU (central processing unit)
Memória (memory)
Ez tárolja az adatokat és a programokat
Perifériák (peripheral device)
HDD, DVD, megjelenítő, billentyűzet, egér, hangkártya, óra, …
Busz v. sín (bus)
Vezetékek, amelyeken a kommunikáció történik.

5 Adatok és programok

CPU működése

x=x+2;
mov   eax, 1055h   x a memóriából a processzorba
add   eax, 2       a processzor hozzáad 2-t
mov   1055h, eax   eredmény a memóriába

Notebook számítógép memóriája

Memória működése: számok tárolása

Érdekesség: A végrehajtott program, és az adatok, amelyeken a program dolgozik, lehetnek külön memóriában is. Az első automatikusan működő számítógépek a programot nem a belső memóriájukban tárolták, hanem papírszalagról vagy lyukkártyáról olvasták be azt futás közben. Ennek egyszerűen az volt az oka, hogy nagyon költséges és bonyolult volt relékből (lásd lent) memóriát csinálni. A tervezők pedig ott spóroltak, ahol tudtak. Ahogyan a technika fejlődött, úgy vált lehetővé, hogy a programot is a központi memóriában tárolják. Ezt az elvet Neumann János (John von Neumann) javasolta kollégáival, és ma Neumann-féle architektúrának nevezzük. Az első ilyen elven működő számítógép az EDVAC nevet viselte. Ez egyben az első kettes számrendszert használó, már nem elektromechanikus, hanem teljesen elektronikus számítógép volt. A külön programmemória elve a fentiek ellenére nem halt ki: ezt ma Harvard architektúrának nevezzük, az EDVAC-nál régebbi régebbi Mark I számítógép nyomán.

6 Érdekesség: kapcsolók generációi

A mai számítógépek digitális elven működnek. Csak egész számokkal tudnak dolgozni, amelyeket kettes számrendszerben tárolnak. A kettes számrendszer előnye az, hogy csak két állapot van benne: 0 és 1. Ez elektronikusan könnyen kezelhető (nincs áram, van áram), ezért a működést kapcsolók adják. Bármi, ami kapcsolóként tud működni, az használható számítógép építésére is.

Az alábbi fényképek a számítógépek generációit mutatják. Ezek elvben nem különböznek egymástól, csak a gyakorlatban, méghozzá abból az egyetlen szempontból, hogy milyen elektronikus, vagy esetleg még elektromechanikus eszközt használtak kapcsolónak. Az első három képen lévő eszköz egyetlen kapcsolónak felel meg, míg a jobb alsó képen látható integrált áramkörön már sok millió kapcsoló van. Összehasonlításképp: 3-4000 kapcsoló használatával már egészen jól használható processzor tervezhető, egy Core i7 processzorban viszont már 730 millió darab van.

Relék (relay): az áram hatására a bennük lévő tekercsben (jobb oldalt) mágneses tér keletkezik, és így lehet vezérelni a kapcsolót (bal oldalt). Egy ilyen eszköz kb. 3-4 cm nagy. Manapság is használnak ilyet nagyobb áramok kapcsolására, pl. autókban is.

Elektroncső (tube): a bennük lévő vákuumban repülő elektronok mozgása vezérelhető az elektromos tér változtatásával. Ezek is viszonylag nagyok: 3-4 cm, ráadásul fűteni kell a belsejüket, hogy az elektronok kilépjenek a fémből.

Tranzisztor (transistor): a félvezető anyagok vezetőképessége (ellenállása) elektromos úton szabályozható, így kapcsolónak is használhatóak. A képen látható tranzisztorban a félvezető szilícium darabka 1 mm-nél is kisebb. A védő fém vagy műanyag tokozás nagyobb, 3-4 mm-es.

Integrált áramkör (integrated circuit): ebben is tranzisztorok vannak, azonban az előzőnél jóval kisebbek. Egy 1 cm2 méretű szilícium lapra akár több tízmillió transzisztor integrálható, amelyek egyesével alig néhány tíz nanométeresek (vagyis méretük egy ember hajszál vastagságának ezrede). A fenti processzor mikroszkóp alatt forgatva egy videón is látható.

7 Számábrázolás

Római: I, II, III, IV, V, … X, C, D, M


  1
 123
+639
────
 762

Hindu-arab: 345, 1203, 2615

Kettőezer-hatszáztizenöt
sorszám 3210
számjegy 2615
helyiérték 103102101100
valódi érték 2000600105

8 Számrendszerek (numeral system)

1
1
1

számrendszerek
alappélda
10 decimális1203tíz = 1·103 + 2·102 + 0·101 + 3·100
8 oktális377nyolc = 3·82 + 7·81 + 7·80 = 255tíz
2 bináris1101kettő = 1·23 + 1·22 + 0·21 + 1·20 = 13tíz

A mindennapi életben a tízes számrendszert használjuk. Ennek oka nagyon egyszerű: azért alakult így ki, mert tíz ujjunk van. Más számrendszerek is használhatóak, és a hindu-arab számírás logikus felépítése miatt ezekben a szabályok pontosan ugyanazok, mint a tízes számrendszerben.

A létező legegyszerűbb számrendszer a kettes alapú. Ebben csak kétféle számjegy van, a 0 és az 1. Hogy miért ez a legegyszerűbb? Mert ebben az összeadó és a szorzótábla nem tízszer tízes, hanem mindössze kétszer kettes.

bináris összeadás
+01
001
1110
bináris szorzás
×01
000
101

9 Átalakítás kettes számrendszerbe

Tízesből kettesbe
maradék 
helyiérték/ 64/ 32/ 16/ 8/ 4/ 2/ 1
számjegy 

Szám: tíz        kettő

Az átalakítás lépései: a számot leosztjuk kettő első olyan hatványával, amely nagyobb nála. Az eredmény egy számjegy, a hányadost pedig felírjuk a következő oszlopba. Így folytatjuk az egyre kisebb hatványokkal, amíg el nem érünk 0-ig. (A legutolsó esetben eggyel osztunk, aminek a maradéka biztosan nulla lesz már.) Az osztások során sehol nem kaphatunk 1-nél nagyobb értéket; ha ilyen történne, akkor kettő egy nagyobb hatványától kell indulnunk.


Kettesből tízesbe
számjegy 
helyiérték×64×32×16×8×4×2×1
valódi érték 

Szám: kettő        tíz

Az átalakítás lépései: a szám számjegyeit (alaki értékek) összeszorozzuk kettő megfelelő hatványaival (helyi értékek). Az így kapott számok (valódi értékek) összege adja az eredményt.

A más számrendszerekbe átalakítás ugyanígy működik, csak az ottani alap hatványait kell használni.

10 Binary digit = bit

Érdekesség: A kettes számrendszernek minden más számrendszernél nagyobb jelentősége van. A 0 és 1 számjegyeket nagyon könnyű automatikusan, elektronikusan kezelni: van áram – nincs áram, világít a lámpa – sötét a lámpa, be van kapcsolva – ki van kapcsolva a kapcsoló. A „binary digit”, azaz bináris számjegy szókapcsolatot eredetileg „bigit” vagy „binit” néven rövidítették. Később a „bit” szót John Tukey, amerikai matematikus javasolta. (Ő találta ki a „software” szót is.)

A szó tudományos írásban először Claude Shannon M.Sc. diplomamunkájában szerepelt, amelynek címe A Symbolic Analysis of Relay and Switching Circuits. Ebben megmutatta, hogy az addig telefonközpontokban használt relék segítségével logikai problémák is megoldhatóak. Pár évvel később megépült az első relékből felépített, kettes számrendszert használó számítógép, a Harvard Mark I. Azóta gyakorlatilag nem készül olyan számítógép, amely nem bináris elven működne.

HDD: akár 2 TiB

Bitek és bitcsoportok

bit
Az információ alapegysége: 0 vagy 1.
bájt (byte): bitek csoportja
A memória legkisebb egysége: általában 8 bit.
szó (word): több bájtos adategység
Általában 4 bájt (32 bit), vagy 8 bájt (64 bit).

DVD: 4.3 GiB

Előtagok (prefix)

kilobájt (kB) és kibibájt (KiB)
103=1000 ≈ 210=1024 bájt.
megabájt (MB) és mibibájt (MiB)
106=1000000 ≈ 220=1048576 bájt.
gigabájt (GB, GiB), terabájt (TB, TiB)
109≈230 és 1012≈240 bájt.

A kettes számrendszerbeli működés miatt a szokásos mértékegységeknek megvan a bináris párja. Bár a kilo- előtag általában ezret jelent, a számítástechnikában inkább 1024-et, azaz 210-t. Ezt azért választották meg így, mert a kettő között nagyon kicsi a különbség. Sajnos gyakran keverik is a kettőt. A merevlemezgyártók például előszeretettel használják a kilo=1000 jelölést, mert így nagyobb kapacitást írhatnak rá az eladott merevlemezekre. Hogy ne kelljen mindig hozzátenni, ha pontosak akarunk lenni, hogy a bináris vagy a decimális prefixumról beszélünk, bevezették a kibibájt, mebibájt stb. jelöléseket. Egy DVD kapacitása így 4,7 gigabájt, azaz 4,3 gibibájt.

11 Egész típusok a C-ben

Egész számok tipikus méretei
névtipikus
méret
tipikus tartományprintf,
scanf
megjegyzés
signed char
unsigned char
8 bit-128…127
0…255
legkisebb,
mindig egy bájt
signed short int
unsigned short int
16 bit-32768…32767
0…65535
%hd
%hu
signed int
unsigned int
32 bit-231…231-1 (±2·109)
0…232-1 (4·109)
%d
%u
tipikus szóhossz
a gépen
signed long int
unsigned long int
32 bit-231…231-1 (±2·109)
0…232-1 (4·109)
%ld
%lu
néha akár
64 bit
signed long long int
unsigned long long int
64 bitkb. ±9·1018
kb. 0…1,8·1019
%lld
%llu

Mivel a számítógép nem végtelen nagy, a tárolt adatok sem lehetnek azok. Ha szeretnénk egy számot eltárolni, akkor arra egy véges, fix méretű memóriaterületet kell kijelölnünk. Ezáltal keletkezik „legkisebb” és „legnagyobb” szám, bár matematikailag ez értelmetlenül hangzik. A számítógépen használt számok emiatt a matematikai számfogalomnak csak tökéletlen modelljei. A matematika számai lehetnek egészek, racionálisak, irracionálisak – a programjainkban ezzel szemben rögzítenünk kell, hogy egy változó típusa egész-e vagy nem, sőt még a tartományban és a pontosságban is be vagyunk korlátozva.

signed long a;
short b;
unsigned c;

Egy egész szám tárolására egy vagy több bájtot foglalunk le. A számítógép ezeket általában „hardverből”, áramkörileg tudja kezelni. Az egész típus neve a C-ben int, amelyhez társulhatnak ún. módosító jelzők (specifier). Pl. az unsigned jelző az előjel nélkülit jelenti, a signed pedig az előjelest. Vannak bizonyos alapértelmezések:

  • Az int típus (méret megadása nélkül) mindig azt a méretet jelenti, amely az adott számítógépen az optimális, vagyis amellyel a processzor leggyorsabban tud dolgozni. Ez manapság a 32 bitest szokta jelenteni, de lehetnek eltérések.
  • Ha nincs signed vagy unsigned megadva, akkor signed, azaz előjeles lesz a változó. Kivétel a char: ennél nincs alapértelmezés. Ajánlatos így használni:
    • char: betű
    • signed char: 1 bájtos, előjeles egész szám
    • unsigned char: 1 bájtos, előjel nélküli egész szám
  • Ha van módosító megadva, akkor az int szó elhagyható. Pl. long x; ugyanaz, mint long int x; és signed x; ugyanolyan típusú változót hoz létre, mint a signed int x; utasítás.

A szabvány nem köti meg a típusok pontos méretét, hogy minél többféle számítógépen működhessenek a C programok. A fenti táblázat a tipikus értékeket mutatja. Ritka, de van, ahol a char 9 bites vagy az int csak 16! Erre figyelni kell, amikor hordozható (portable) programokat szeretnénk írni, amelyek különféle gépeken működnek. Főleg, ha Interneten kommunikálnak egymással vagy egymás fájljait kell olvassák.

12 Túlcsordulás és kettes komplemens

Túlcsordulás: ha nem fér el az eredmény

 11111111        255    
  
+00000001       +  1    
─────────        ───    
100000000        256 → 0
255+1 = 0
0-1 = 255

Ha egy eredmény nem fér el az adott bitszámban, túlcsordulást kapunk. Emiatt 8 biten: 255+1 = 0, és 0-1 = 255.

A biteket az alsó helyiértékektől számozzuk, aszerint, hogy 2 hányadik hatványának felel meg. 20 → 0. bit, 21 → 1. bit stb. A helyiértékek matematikából megszokott neve alapján a legkisebb helyiértékű bitet (least significant bit, LSB) legalsónak, a legnagyobb helyiértékűt (most significant bit, MSB) legfelsőnek nevezzük.


bitekérték
11111110-2
11111111-1
000000000
000000011
000000102

Negatív számok tárolása: a kettes komplemens

A kettes komplemens ábrázolás előnye, hogy a túlcsordulások miatt automatikusan előállnak a helyes pozitív/negatív értékek – nem kell megkülönböztetnie a hardvernek (az áramköröknek) a pozitív és negatív számokkal végzett összeadásokat, kivonásokat. (Egy négy bites példát tekintve 1110+0011=0001, azaz −2+3=1, ami éppen a helyes eredmény.) Ezért mindenhol ezt szokás használni.

Egyébként ugyanez működik tízes alapú számrendszerben is. Pl. 3 számjegyen 17+999=1016, amiből ha eldobjuk a túlcsordulás miatti ezrest, akkor 16-ot kapunk, ami pont 17-1. A témakör a digitális technika tárgyból még fog szerepelni részletesen.

Miért fontos ezt ismerni? Mert így tudjátok, miért adhat a számítógép negatív számot két pozitív összegeként. Mert túlcsordulás történt!

13 A túlcsordulás élőben

#include <stdio.h>

int main()
{
   unsigned char kicsi;
   int i;
   
   kicsi=1;
   for (i=0; i<20; i+=1) {
      printf("%u\n", kicsi);
      kicsi*=2;
   }
   
   return 0;
}

Típusok, amelyekkel érdemes kipróbálni: unsigned char, signed char, short. Bármekkorával is próbáljuk, előbb-utóbb előjön a probléma. Ha kettővel szorozgatjuk a számot, akkor előbb-utóbb nulla lesz az eredmény, így könnyű tetten érni a hibát. Ha hárommal szorzunk a ciklusban, akkor már kevésbé!

14 Valós számok ábrázolása

Lebegőpontos ábrázolás (floating point)

± mantissza · 2karakterisztika

A korlátos pontosság miatt még az is előfordulhat, hogy a+b=a, ahol a egy nagy, b pedig egy kicsi szám. Ez történik (pirossal az értékes jegyek):

a    10000000000000.0000000
b   +             0.0000001
    ───────────────────────
a+b  10000000000000.0000001 → 10000000000000 lesz!

15 Furcsaságok: ½+⅓+⅙, 9,95 és 0-tól 1-ig

#include <stdio.h>

int main()
{
   double szam;

   printf("%e\n", 
     1.0/2.0 + 1.0/3.0 + 1.0/6.0 - 1);

   printf("%.16f\n", 9.95);
  
   for (szam=0; szam<1; szam+=0.1)
      printf("%f\n", szam);

   return 0;
}

Egyes géptípusokon az ½+⅓+⅙-1 műveletsor eredménye nem adja ki a 0-t. Ez azért van, mert a törtek tizedes törtként (kettedes törtként) nem ábrázolhatóak pontosan. Az ⅓ tört értéke például 1.333333…, amelynél minél több hármast írunk, annál pontosabban adjuk meg a számot, de teljesen pontosak sosem lehetünk. (A %e ugyanolyan, mint a %f, csak a normálalakú kiírást jelenti.)

Hasonlóan a fentihez, a 9,95 szám, bár tízes számrendszerben kerek, kettes számrendszerben nem az. Ezért nem ábrázolható pontosan. Banki szoftverekben nem szoktak lebegőpontos számokat használni, hiszen még egy ilyen egyszerű pénzösszeg, mint a 9,95 € (9 euró 95 cent), sem adható meg pontosan.

A ciklus pedig adhat olyan kimenetet, amelyben 11-szer történik meg a kiírás. A dolog külön érdekessége, hogy a ciklus, bár feltételében szam<1 van, gyakorlatilag 1-nél is lefut! Nem 10-szer (0; 0,1; … 0,9), hanem 11-szer! És ez teljesen esetleges; szam=1, szam<2 esetén csak 10-szer fut le, mivel ott úgy alakulnak a kerekítési hibák. Az eredmény géptípusonként változhat, a pontosság függvényében. Ha a printf() formátumsztringjébe %.16f-et írunk, akkor egyből látszik, mi okozza a hibát. A jelenség azért érhető ilyen könnyen tetten, mert a tízes számrendszerben „egész” számok, mint a 0,1, kettes számrendszerben nem azok. Kettes számrendszerben „kerek” törtek esetén (0,5; 0,25; 0,125; 0,0625…) nincs ilyen probléma. Érdemes kipróbálni!

16 Lebegőpontos típusok C-ben

Lebegőpontos típusok
névtipikus
tartomány
pontosság
(tizedesjegy)
printfscanf
float±10±38kb. 7%f
double±10±308kb. 16%f%lf
long double±10±4932kb. 36%Lf

A támogatott típusok itt is eltérnek számítógépenként. A long double gyakran nem létezik, hanem a double szinonímája csak.

Ha valós szám kell, legtöbbször double-t használunk.

Figyeljünk arra, hogy a printf()-nek és a scanf()-nek adott formátumkód a double esetében nem ugyanaz. A kiíráshoz ennél %f kell, a beolvasáshoz %lf. Ennek mélyebb, történelmi okai vannak, amelyek itt nem lényegesek.

17 Számok a C nyelvben

dechex
10a
11b
12c
13d
14e
15f

A tizenhatos számrendszer

Használhatnánk a nyolcas számrendszert is azzal a céllal, hogy spóroljunk a számjegyekkel. Azzal azonban van egy kis probléma. Manapság szinte mindegyik számítógépen nyolc bites a bájt. Ha egy ilyet nyolcas számrendszerben írunk le, akkor 2-3-3 bites csoportok adódnak: 10'101'111kettő=257nyolc. Ezzel önmagában nem is lenne probléma, azonban ha egy két bájtos, azaz 16 bites számot szeretnénk átírni, akkor az egymás mellé tett bájtok nyolcas átírása eltér attól, mint a két bájtté külön. Ha az előző bitsorozatot kétszer egymás mellé írjuk, annak átírása: 1'010'111'110'101'111kettő=127657nyolc, nem pedig 257257nyolc, ahogyan a két nyolcas számrendszerbeli egymás után írása miatt gondolnánk. A tizenhatos számrendszerrel nincs ilyen probléma, mert ott nem három, hanem négy bit van egy csoportban, és egy bájt nyolc bitje pontosan két csoportot ad. A négybites csoportok angol neve a nibble (esetleg nybble).

Számok megadása C-ben (numerikus literálisok)

Bitműveletek

19 Boole-féle algebra

A Boole-féle algebrában a változóknak két értékük lehet: HAMIS és IGAZ, vagy bitekben gondolkodva 0 és 1. A sokféle művelet közül a fontosabbak a következők.

NEM
(NOT)
  
01
10
ÉS
(AND)
01
000
101
VAGY
(OR)
01
001
111
KIZÁRÓ VAGY
(XOR)
01
001
110
Lásd:
Digit I.

A fenti mondatok a logikai műveletek szokásos értelmezését adják. Érdemes azonban más szempontból is vizsgálni őket: az egyik bemenetet változatlannak gondolva azt figyelni, hogyan reagál a kimenet a másik bemenet megváltozására.

  • Az ÉS művelet ugyanezt csinálja nullákkal. Ha az egyik bemenet 0 (vízszintes), akkor a másik bemenet (függőleges) értékétől függetlenül 0-t ad a kimeneten. Ha az előbbi bemenet 1-es, akkor az utóbbit lemásolja.
  • A VAGY műveletnél: ha függőlegesen a 0-t választjuk, akkor a kimeneten a vízszintesen kiválasztott bit jelenik meg. Ha az 1-et, akkor pedig fixen 1. Szóval a VAGY művelet lemásolja az egyik bemenetét, ha a másik 0, és fixen 1-et ad a tőle függetlenül, ha a másik 1.
  • Az XOR (exclusive or), ha az egyik bemenet 0, akkor a másikat lemásolja. Ha az előbbi 1-es, akkor pedig az utóbbit másolja – de negálva.

A következő bitműveletes feladatoknál egyértelmű lesz, hogy miért hasznosak ezek a megfigyelések. Ugyanis pl. a VAGY művelet ezen tulajdonsága miatt használható arra, hogy egy szám egy adott bitjét 1-be állítsuk, miközben a többi változatlanul marad.

Bitműveletek használata

  • Minden bit kihasználása: egy 8 bites unsigned char-ban 8 igaz/hamis értéket tárolhatunk
  • Hardverközeli programozás: hardvereszközben (pl. grafikus kártya) adott bit 1-esbe állításával bekapcsolunk valamit
  • Hálózatprogramozás: egy internet adatcsomag adott bitjei jelzik, hogy kapcsolat létrehozása, lebontása stb. történik-e
  • Kriptográfia: titkosítási és ellenőrző összegeket előállító algoritmusok
  • Véletlenszámok: pszeudo-véletlenszámokat előállító algoritmusok

Az utóbbi két témakörről szólnak az SHA-1 és a Mersenne twister Wikipedia szócikkek. Ezekben a bitműveleteket arra használják, hogy minél jobban összekeverjék az adatok bitjeit. A Mersenne twister a legelterjedtebben használt algoritmus, amely pszeudo-véletlenszámokat állít elő: vagyis egy bonyolult matematikai műveletsort végez, amelynek a kimenete látszólag véletlenszerű értékekből áll.

Még egy nagyon fontos megjegyzés: a bitenkénti műveletek nem keverendőek a logikai műveletekkel! Míg a bitenkénti ~ művelet egy egész szám összes bitjét ellentettjére állítja, a logikai ! művelet nem nulla számból nullát csinál, és nullából nem nullát (egyet). Ugyanígy, a bitenkénti | nem ugyanaz, mint a logikai ||, és a bitenkénti & mást csinál, mint a logikai && művelet. (A ^ kizáró vagy műveletnek nincsen logikai párja.)

20 Bitműveletek: léptetés (shift)

Balra léptetés: << operátor

10000110  előtte
00110000  balra 3-mal
x = 134 << 3;
  • A felső három elveszik, alulról három nulla jön be.
  • Mintha szoroznánk 23-nal

Jobbra léptetés: >> operátor

01010110 előtte
00010101 jobbra 2-vel
x = 86 >> 2;
  • Az alsó kettő elveszik, felülről két nulla jön be.
  • Mintha osztanánk 22-nal

A bitenkénti léptetés operátorok ugyanolyanok, mint az összeadás vagy a szorzás: két operandusuk van, és létrehoznak egy harmadik számot, az eredményt. Eközben a két operandusukat nem változtatják meg! A b=a<<3 kifejezés így az a változó értékét változatlanul hagyja, és csak b változik! A többivel párhuzamosan viszont ezeknek is megvan a rövidített, értékadó párjuk:

  • x<<=3 ugyanaz, mint x = x<<3 és
  • x>>=2 ugyanaz, mint x = x>>3.

A <<= és >>= operátorok azok, amelyek léptetnek is és értéket is adnak, a << és a >> operátorok nem! Mindez igaz a következő diákon bemutatott operátorokra is.

21 A bitenkénti VAGY művelet: |

Ez a művelet két szám bitjeit hozza VAGY kapcsolatba páronként, vagyis az egyik szám 0. bitjét a másik szám 0. bitjével, 1. bitet az 1. bittel stb. A VAGY műveletnél „az eredmény 1, ha bármelyik 1”. Ezt adott bitek 1-be állítására szokás használni.

VAGY művelet
76543210
A01001010
B00100000
A|B00000000
|
„pipe”,
álló vonal

Feladat: állítsuk egy szám 5. bitjét 1-be!

22 A bitenkénti kizáró vagy: ^

A két operandusának ugyanolyan sorszámú bitjeit hozza KIZÁRÓ VAGY kapcsolatba. Mivel a kizáró VAGY műveletnél az eredmény akkor 0, ha egyformák a bemeneti bitek, és akkor 1, ha nem egyformák, ezt adott bitek negálására szokás használni.

KIZÁRÓ VAGY művelet
76543210
A01001010
B00011000
A^B00000000
^
„caret”,
kalap

Feladat: negáljuk egy szám 3. és 4. bitjét!

Az x=x^y kifejezés rövidítve is írható: x^=y, vagyis a fenti példa rövid változata: x ^= 1<<3 | 1<<4;

23 A bitenkénti ÉS művelet: &

ÉS művelet
76543210
A01001010
B00001000
A&B00000000

A két operandusának ugyanolyan sorszámú bitjeit hozza ÉS kapcsolatba.

&
„et” vagy
„és” jel

Feladat: ellenőrizzük, egyes-e a szám 3. bitje!

24 A bitenkénti tagadás: ~

A ~ operátor a bitenkénti negálás jele C-ben. Ezt egy szám vagy változó neve elé írva olyan számot kapunk, amelyben minden bit ellenkezőjére (negáltjára) van váltva.

NEM művelet
76543210
A11110111
 ~A00000000

~
„tilde”,
hullámvonal

Feladat: állítsuk egy szám 3. bitjét 0-ba!

Függvények

26 Prímszámok 2-től 1000-ig

Számoljuk meg, hány prímszám van 2 és 1000 között!


Számlálás tétele
int sz, db;
db=0;
for (sz=2; sz<=1000; sz+=1)
   if (… sz egy prím …)  !
      db+=1;
Lineáris keresés tétele
int oszt, van;
van=0;
oszt=2;
while (oszt<sz && !van) {
   if (sz%oszt==0)
      van=1;
   oszt+=1;
}
Teljes megoldás
int sz, db, oszt, van;

db=0;
for (sz=2; sz<=1000; sz+=1) {
   van=0;
   oszt=2;
   while (oszt<sz && !van) {
      if (sz%oszt==0)
         van=1;
      oszt+=1;
   }
   if (!van)
      db+=1;
}

printf("%d prím.\n", db);

Az előző előadáson azzal a megállapítással zártuk ezt a feladatot, hogy a két egymástól független programrész összedolgozása által egy nehezen értelmezhető program keletkezett. Nagyban rontotta az áttekinthetőségét az, hogy az egyik algoritmust bele kellett építenünk a másikba, kettévágva azt. Megírni is nehezebb egy ilyet, hiszen egyszerre több dolgon kell gondolkozni.

27 Alprogramok: függvények

Tegyük fel, hogy van egy programrész, amely megmondja egy adott számról, hogy prím-e, vagy nem. Ez a programrész egy ún. függvény. Ahogyan a sin() is, amely kap egy szöget, és megadja annak szinuszát, ez kap egy egész számot, és meghatározza annak prím vagy nem prím voltát. Ha van egy ilyen, akkor a prímek számlálása feladat nagyon egyszerű: a kékkel jelölt „… sz egy prím …” programrész helyére csak annyit kell írnunk, hogy prim_e(sz). Mint az lent is látható, ilyet a C-ben lehet csinálni.

Alprogram (subprogram), szubrutin (subroutine)

Az egész programunk együttműködő alprorgamokból épülhet fel!

if (… sz egy prím …)
   db+=1;
if (prim_e(sz))
   db+=1;

Ezekhez is tartozik specifikáció: bemenet → kimenet összefüggése.


C-ben a nevük: függvény (function)

y=f(x)
y=x²
double negyzet(double x) // x: bemenet
{
   return x*x;           // x²: kimenet
}

28 Függvény példa: prímek 2-től 1000-ig

A részfeladatokat külön függvényben írhatjuk meg. Így egy nagyobb program áttekinthetőbb, kezelhetőbb lehet. Sőt a gyakran ismétlődő programrészeket is így csak egyszer kell majd megírnunk. A programok így kisebbek, hatékonyabbak lehetnek. Egy feladat elvégzéséhez szükséges programrész kódja csak egyszer fog szerepelni a memóriában, mindenhol máshol csak hivatkozunk rá! A printf() függvényt is egyszer megírta valaki, és mindenhol használhatjuk.

Főprogram

int main()
{
  int db, sz;

  db=0;
  for (sz=2; sz<=1000; sz+=1)
    if (prim_e(sz)) // prím?
      db+=1;
    
  printf("%d db.\n", db);
  
  return 0;
}

Alprogram

int prim_e(int szam) // prím?
{
  int oszt, van;

  van=0; oszt=2;
  while (oszt<szam && !van) {
    if (szam%oszt==0)
      van=1;
    oszt+=1;
  }
   
  return !van;
}

Elnevezések: fejléc, függvénytörzs, paraméter, visszatérési érték, hívás, visszatérés, lokális változó, láthatóság, élettartam.

Dokumentálandó: bemenet, kimenet, feladat, hibalehetőségek.

Függvények definiálása

visszatérési_típus függvénynév(paraméterlista)
{
   … függvénytörzs …
   
   return visszatérési_érték;
}

A fejléc (header) meghatározza a függvény nevét, a paraméterei és a visszatérési értéke (return value) típusát. A függvénytörzs (function body) tartalmazza azt a programrészt, amely a függvény feladatát elvégzi.

Visszatérés a függvényből

A függvény törzsében elhelyezett return utasítással visszatérhetünk (return) a függvény hívásának (call) helyére. Ezzel egyben megadjuk a visszatérési értéket is, amelyet egyébként a függvény értékének (function value) is nevezünk. Fontos, hogy a return utasítás ezt a két szerepet elválaszthatatlanul összeköti! Ami a return után van, az már nem hajtódik végre. (Viszont egy függvényben lehet több helyen is return utasítás.)

Lokális változók

  • A függvényen belül vannak definiálva
  • Függvénybe belépéskor jönnek létre
  • Végén megszűnnek → értékük elveszik
  • Minden függvény csak a sajátjait látja! (láthatóság, scope)

Mi az előnyük? Egyrészt az, hogy minden függvénynek lehetnek saját lokális változói, amelyekben olyan értékeket tárolnak, amelyekre csak a futásuk idején van szükség. A fenti példában az osztó változóra nincsen már szükség, amint meghatároztuk, hogy a szám prím-e. (A van változó is lokális, és az is megszűnik, azonban az értéke lemásolódik, és átadódik a hívónak.) Ezek a változók csak a függvényen belül látszanak (a láthatóságuk (scope) csak a függvényen belülre terjed ki), és így a nevük csak azon belül értelmezett. Másik függvényeknek lehetnek ugyanolyan nevű lokális változóik, mint ennek, a nevek mégsem fognak ütközni. A másik előny, hogy a változó nem foglal memóriát, csak akkor, ha az azt definiáló függvény belsejében vagyunk. Vagyis a változó élettartama (extent, vagy más néven lifetime) is csak arra az időre terjed ki, amíg a függvény végrehajtása tart.

A main() függvény

Most már tudjuk, hogy a main() is egy függvény. Az egy egész számmal kell visszatérjen, amelynek hibajelző szerepe van. Egyelőre mindig 0-ra állítjuk, ami annál azt jelenti, hogy nincs hiba. Hogy a paraméterei mik, azt a kérdést egyelőre hagyjuk nyitva!

Függvények dokumentációja

A függvények olyan kis programrészek, amelyek egy jól elhatárolt részfeladatot hajtanak végre. Ezért egy függvény dokumentálásakor pontosan meg kell határozni, hogy mire való, milyen feladatot hajt végre. A programokhoz hasonlóan rögzíteni kell azt is, hogy milyen bemenetet vár és milyen kimenetet állít elő a futása során. A bemenet dokumentálásához hozzá tartozik a bemeneti tartomány leírása is (pl. a négyzetgyököt vonó függvény nem kaphat negatív számot paraméterként). A működés leírásához pedig a hibalehetőségek rögzítése. Mindezeket a függvények előtt, a forráskódban, megjegyzés (comment) formájában is meg kell tenni, hogy ezáltal a kód kezelhetővé, karbantarthatóvá váljon. Ahol van hely, ezt meg fogjuk tenni (sajnos az előadás diákra nem mindenhol fér oda ez).

29 A függvényhívás menete

double faktorialis(int mie) 
{ 
   double szorzat; int i;

   szorzat=1;    
   for (i=2; i<=mie; i+=1)    
      szorzat*=i;    
   return szorzat;    
} 
 
int main() 
{ 
   int sz; double eredm; 

   printf("sz="); scanf("%d", &sz);    
   eredm=faktorialis(sz);    
   printf("%d!=%.0f\n", sz, eredm);    
   return 0;    
}


main()

sz:    
eredm: 

Az animáció azt mutatja, hogy mely függvényekhez tartozó lokális változók mely időpillanatban léteznek.

A faktoriális függvény olyan, mint a teljes programunk: van kimenete, van bemenete, csak ezek nem a képernyő és a billentyűzet, hanem a főprogrammal történő kommunikáció által valósulnak meg:

Főprogram és alprogram kommunikációja

(Megjegyzés: a printf() és a scanf() függvények is alprogramok, amelyek azonban az ábrán nem szerepelnek.)

30 Függvények paraméterei

double teglalap_kerulet(double a, double b)
{
   return 2*(a+b);
}
printf("%f", teglalap_kerulet(2, 3.4)); // a=2, b=3.4

Formális paraméter (formal p.): a neveik a függvény fejlécében.

Aktuális paraméter (actual p.): a hívás helyén adott érték.

Fontos, hogy a paraméterlista megadásánál minden egyes paraméter elé oda kell írni annak típusát. Akkor is, ha azok egyformák. Ezért van a fenti függvény fejlécében double a, double b, és nem pedig double a,b, ami helytelen szintaktikailag.

31 Paraméterek – a klasszikus hiba

#include <stdio.h>

void szamol(int a, int b, int osszeg)
{
   osszeg=a+b;  /* el fog veszni! */
}

int main()
{
   int szum;
   
   szum=0;
   printf("előtte: %d\n", szum);

   szamol(5, 6, szum);
   printf("utána: %d\n", szum);

   return 0;
}

Mivel a paraméter is lokális változó, a függvényből visszatérve megszűnik létezni! Emiatt a paraméteren keresztül közvetlenül nem lehet visszaadni értéket! A fenti programban is a függvény nem a szum változót, hanem a szum változó tartalmának másolatát kapja csak meg, vagyis 0-t. Az osszeg nevű lokális váltózba tényleg bekerül az összeg, de megszűnik a return utasítás után!

A problémára megoldására egy későbbi előadáson fogunk visszatérni.

32 Nagyobb program: deklarációk, definíciók

double kerulet(double a, double b); // deklaráció/prototípus

int main()
{
   printf("%f", kerulet(2, 3.4));
   return 0;
}

/* kiszámolja egy a,b oldalú téglalap kerületét */
double kerulet(double a, double b)  // definíció
{
   return 2*(a+b);
}

A fenti példa azt is kiemeli, miért olyan fontos ez. A fordító minden függvényhívás helyén elvégzi a paraméterek ellenőrzését: a függvénynek csak olyan típusú paraméterek adhatóak, amelyeket a fejléce alapján vár. A main()-ben itt a hívás helyén egy érdekes dolog történik. A 2 konstans egy egész, ezért a típusa int – viszont a fordító tudja, hogy a kerulet() valós paramétert vár, ezért előbb elvégzi a 2→2.0 átalakítást. Fordított esetben, pl. egészet váró függvénynek 3.14-et adva először egy lefelé kerekítés történne, és a függvény végülis a 3-as számot kapná meg. Az ilyesmit általában egyébként egy figyelmeztető üzenettel jeleznek is a fordítók, ugyanis a törtrészt ilyenkor elveszítjük, amit lehet, hogy nem szeretnénk.

A függvényt egyébként nem szükséges a main() függvény után definiálni, előtte pedig csak deklarálni. Az egész definíció áthelyezhető a main() függvény elé is. Ilyenkor külön deklarációra nincsen szükség, mert a definíció is tartalmazza a nevet és a típusokat. A lényeg az, hogy a függvényhívás helyén a függvény típusának már ismertnek kell lennie:

/* kiszámolja egy a,b oldalú téglalap kerületét */
double kerulet(double a, double b)  // definíció
{
   return 2*(a+b);
}

int main()
{
   printf("%f", kerulet(2, 3.4));
   return 0;
}

33 A void típus: „semmilyen”

int beolvas_szam(void)
{
   int sz;
   scanf("%d", &sz);
   return sz;
}
void kiir_szam(int szam)
{
   printf("%d", szam);

   return;
}

x=beolvas_szam(); // A híváshoz ilyenkor is kell a ( )
kiir_szam(x);

34 Fő- és mellékhatás I.

int negyzet(int szam)
{
   return szam*szam;
}

Főhatás: a függvény értéke. A függvény kiértékelése: a függvény lefut, és a hívás helyén lévő kifejezésbe a visszatérési értéke behelyettesíthető.

void szam_kiir(int szam)
{
   printf("%d", szam);
}

Mellékhatás: a függvény valahol változást okoz. (Ezt mellékhatásnak nevezzük még akkor is, ha kifejezetten ez a célja a függvénynek!)


Általában igyekszünk olyan függvényeket írni, amelyeknek csak főhatása vagy csak mellékhatása van. Ennek az elvnek neve is van: command-query separation. Eszerint kétféle függvény van. Az egyik fajta a parancsfüggvény (command), amelyet azért használunk, hogy hatása legyen. A másik fajtának kérdéseket teszünk fel (query), amely kiszámol valamit, de mellékhatása nincs. Ha ez a kettő keveredik, az könnyen összevisszasághoz, átláthatatlan programfelépítéshez és nehezen megtalálható hibákhoz vezet.

Fontos: ha a specifikáció nem kéri a kiírást, akkor kifejezetten hibának számít, ha a függvény mégis ilyet tesz! Például kiírja a képernyőre az eredményt ahelyett, hogy visszatérne vele. Hadd döntse el a hívó, mit szeretne csinálni vele!

35 Fő- és mellékhatás II.

Szám faktoriálisa

int fakt(int mie);
printf("%d\n", fakt(6));
printf("%d\n", fakt(6));
printf("%d\n", fakt(6));
720
720
720

Véletlenszám

#include <stdlib.h>
printf("%d\n", rand());
printf("%d\n", rand());
printf("%d\n", rand());
8311623
2141262
16641798

Vannak esetek, amikor a command-query separation elvet nem tartjuk be. A véletlenszám függvény például kell rendelkezzen valamiféle belső állapottal. Láthatóan a kimenete nem a bemenő paraméter függvénye, hiszen nincs is neki! Általában sem lenne sok értelme a void paraméterű vagy visszatérési értékű függvényeknek, ha nem lenne mellékhatásuk. Az ilyenek matematikai értelemben véve nem függvények már, de ennek ellenére C-ben így hívjuk őket.

Funkcionális dekompozíció

avagy
procedurális programozás
avagy
top-down tervezés

37 Dekompozíció példa: Cesàro és a π

A teljes
megoldás:
cesaro.c

Ernesto Cesàro (olasz matematikus): válasszunk ki véletlenszerűen két egész számot. Annak a valószínűsége, hogy ezek relatív prímek, 6/π².

Feladat: írjunk programot, amely megbecsüli a π értékét!


A következőképpen nézhet ki. Először is, rendezzük az egyenletet:

  6       6
P=─  → π²=─
  π²      P
int main()
{
    double pi = sqrt(6/valoszinuseg());
    
    printf("%f", pi);
    
    return 0;
}

A keresett valószínűséget majd a függvény megmondja.

38 Dekompozíció: a π meghatározása I.

Hogyan számoljuk ki a valószínűséget? Adott egy kísérletünk, legyen ez kiserlet(). Térjen ez a függvény vissza igazzal, ha a kísérlet sikerült! Végezzük el ezerszer! A sikeres kísérletek számát 1000-rel osztva megkapjuk a becsült valószínűséget.

Monte-Carlo
módszer
/* P meghatározása kísérletezéssel */
double valoszinuseg()
{
    int i, db;
    
    db=0;
    for (i=1; i<=1000; i+=1)
        if (kiserlet())     // elvégzi a kísérletet
            db+=1;
    return db/1000.0;   /* egész osztás elkerülése! */
}

Mi a kísérlet? Az, hogy két véletlenszám relatív prím. Gyártsunk ehhez 1 és 1000 között véletlenszámokat, és hasonlítsuk a legnagyobb közös osztójukat 1-hez:

/* A kísérlet: a legnagyobb közös osztójuk 1? */
int kiserlet()
{
    return lnko(rand()%1000+1, rand()%1000+1)==1;
}

39 Dekompozíció: a π meghatározása II.

És hogyan számolunk legnagyobb közös osztót? Euklidész módszerét megnézhetjük a Wikipédián is. A pszeudokódot csak át kell írni C-be:

„Az euklidészi algoritmus minden algoritmusok nagyapja. Ez a legrégebbi nemtriviális algoritmus, amelyet mindmáig használunk.”
– Donald Knuth
/* visszatér a két szám legnagyobb közös osztójával. */
int lnko(int a, int b)
{
    while (b!=0) {
        int t=b;
        b=a%b;
        a=t;
    }
    return a;
}

Érdekesség: Donald Knuth amerikai programozó. Leghíresebb műve a „Számítógépprogramozás művészete” (The Art of Computer Programming) című többkötetes könyv. Az ő nevéhez fűződik a TeX nevű szövegszedő program kifejlesztése is, amelynek különféle változatait most is használják könyvek, folyóiratok szerkesztéséhez, szedéséhez. Az elterjedt irodai programcsomagokkal készített dokumentumok külleme meg sem közelíti azt, ami a TeX segítségével elérhető!


Az eredmény

3.131121
_

40 Procedurális/hierarchikus programozás

A funkcionális dekompozíció (functional decomposition) egy tervezési elv. A másik neve a felülről lefelé (top-down) elv. Lényege, hogy a problémát részfeladatokra bontjuk. Az egész rendszert, programot úgy tervezzük meg, hogy közben a részfeladatokat megoldottnak tekintjük. Az egyes részfeladatok megoldása közben így nem kell a többi részleteivel bajlódni. A részfeladatok ugyanúgy specifikálhatóak, mintha egy teljes programról lenne szó.

„Az egyik dolog, amit a programozásban meg kell tanulnunk, az az, hogyan hagyjuk figyelmen kívül a részleteket.”
– Gerald J. Sussman

A funkcionális dekompozíció céljai:

Érdekesség: Gerald Jay Sussman amerikai programozó, matematikus, aki legtöbbet a mesterséges intelligenciával foglalkozik. Az ő nevéhez is fűződik a Structure and Interpretation of Computer Programs című könyv megírása is, amelyhez egy programozás alapjait bemutató tárgy is tartozott az MIT egyetemen. A fenti idézet az egyik előadásáról származik. A tárgyat egyébként a saját maguk által kifejlesztett programozási nyelvvel tanították, amelynek a neve Scheme.

A top-down tervezést „wishful thinking” néven mutatta be (kb. ábrándozó gondolkodás). Hiszen éppen ez a lényege: „Bárcsak lenne egy olyan függvény, amelyik megmondja egy számról, hogy prímszám-e… Mert ha igen, akkor milyen egyszerű lenne a feladatunk!” Sokszor ezzel a gondolkodásmóddal tudjuk szétválasztani a részfeladatokat.


A fenti feladat a felülről lefelé (top-down) tervezést szemlélteti. Azt kell látni, hogy minden lépésben csak egy kicsit lépünk a megoldás felé; a következő lépést pedig mindig megoldottnak tekintjük.