Operacije sa tekstualnim datotekama u programskim jezicima C i Python
Uvod
U računarskim sistemima, termin "datoteka" odnosi se na kolekciju srodnih podataka koja se trajno čuva u računarskoj memoriji (i može se otvarati i obrađivati u različitim programima).
Datoteke predstavljaju važnu i zanimljivu pojavu, a sposobnost obavljanja operacija sa datotekama - pre svega sa tekstualnim datotekama - predstavlja prilično važan deo osnovne pismenosti svakog programera.
Da bismo se što bolje upoznali sa operacijama čitanja iz tekstualnih datoteka i pisanja u tekstualne datoteke, u nastavku ćemo razmotriti osnovnu teoriju i nekoliko primera iz programskih jezika C i Python.
Izabrali smo C i Python, budući da su u pitanju popularni jezici koji se koriste u fazi učenja, pri čemu programski kodovi iz C-a (koji se tiču učitavanja datoteka i obrade podataka), ni u kom slučaju ne deluju "skroz jednostavno" iz perspektive mlađih programera koji se sa takvim kodovima prvi put sreću (ali verujemo i nadamo se da neće biti ni teški za razumevanje :)), dok, sa druge strane, primeri iz Python-a (kao što je i inače svojstveno ovom popularnom skriptnom jeziku), naizgled deluju jednostavnije ....
Operacije sa tekstualnim datotekama u C-u
Za početak ćemo se usmeriti na teži zadatak, * tj. sagledaćemo nekoliko primera iz programskog jezika C, ali, što je još važnije, upoznaćemo se sa opštim principima za pristup tekstualnim ** datotekama (koji važe u gotovo svim programskim jezicima).
Čitanje datoteke (osnovni primer)
Najjednostavniji primer otvaranja tekstualne datoteke i čitanja sadržaja znak-po-znak, uz ispis na standardni izlaz (tj. 'konzolu' ili 'terminal'), podrazumeva sledeće korake:
- otvaranje datoteke u režimu čitanja (preko funkcije
fopen
) if
grananje preko koga se ispituje da li je otvaranje datoteke proteklo na predviđeni načindo-while
petlju preko koje se znaci čitaju iz datoteke i ispisuju na ekran
#include<stdio.h>
#include<stdlib.h>
void main()
{
int i, n;
FILE *f;
f = fopen("datoteka_1.txt", "r");
if (f == NULL) {
printf("Greška pri čitanju!\n");
return;
}
/* čitanje postojećih znakova iz datoteke */
do {
c = fgetc(f);
printf("%c", c);
} while (c != EOF);
}
Iako primer ne deluje komplikovano, osvrnimo se na određene delove; pre svega, na samu funkciju za otvaranje fopen
(uz komentare u kodu):
f = fopen("datoteka_1.txt", "r");
/*
Argumenti funkcije fopen su:
ime datoteke koju treba otvoriti
i režim pristupa, koji može biti:
"r" - čitanje (read)
"w" - upisivanje (write)
"a" - dodavanje na postojeći
sadržaj (append)
*/
Ukoliko otvaranje datoteke nije proteklo u skladu sa očekivanjima, bitno je odreagovati na odgovarajući način (što je u gornjem primeru rešeno preko jednostavnog if
grananja):
if (f == NULL)
/*
Ukoliko datoteka nije
uspešno kreirana ili otvorena
(u zavisnosti od toga koju operaciju
pokrećemo), pokazivač na datoteku će
imati sistemsku vrednost NULL,
a u primeru koji koristimo (budući da
izvršavanje programa zavisi od sadržaja
datoteke), prekinućemo izvršavanje
programa
*/
Sama operacija čitanja datoteke (onako kako je prikazano u primeru), vrlo je jednostavna ....
do {
c = fgetc(f);
printf("%c", c);
} while (c != EOF);
.... ali - za sobom povlači i pitanje: kako (inače) postupiti ako sadržaj datoteke treba sačuvati u memoriju i obraditi na određeni način (a ne samo "ispisati u konzoli")?!
Na kraju, veoma (!) je bitno da ne zaboravimo da zatvorimo datoteku:
fclose(f);
U suprotnom, može doći do grešaka u izvršavanju programa koji pišemo - ali i do grešaka usled pokušaja drugih programa da naknadno pristupe datoteci koja je otvorena.
Složeniji primer: učitavanje datoteke u RAM
Primer koji smo prethodno videli, sasvim uspešno prikazuje osnovnu funkcionalnost mehanizma za čitanje podataka iz datoteke, ali, ako postoji potreba da se tekst (dalje) obrađuje u programu, podaci iz datoteke se prvo moraju kopirati u operativnu memoriju.
Za manje datoteke (nekoliko desetina redova i sl), mogu se koristiti i statički nizovi (znakova) ....
char sadrzaj_datoteke[1000];
.... međutim, za čuvanje sadržaja iole većih datoteka, u praksi se gotovo uvek koristi dinamička alokacija memorije, najčešće preko funkcije malloc
("memory allocation"), uz prethodno očitavanje veličine datoteke.
Funkcija main
, u implementaciji koju ćemo razmotriti, ima sledeći sadržaj:
// Priprema
char *naziv = "datoteka_1.txt";
FILE *f = fopen(naziv, "r");
int velicina = ocitavanje_velicine_datoteke(f);
char *buffer = ucitavanje_datoteke(f, velicina);
// Obrada
printf("%s", buffer);
// Zatvaranje
fclose(f);
free(buffer);
U prikazanom kodu, posebnu pažnju treba obratiti na sledeće detalje:
- veličina datoteke očitava se preko zasebne funkcije (radije nego da takav kod bude zapisan unutar funkcije
main
) - za smeštaj podataka potrebno je obezbediti "bafer" (blok memorije, odgovarajuće veličine, kome se pristupa preko pokazivača)
- kreiranje bafera takođe se obavlja preko zasebne funkcije (u kojoj se poziva funkcija
malloc
)
Obrada ponovo podrazumeva prost ispis sadržaja datoteke (ali, u primeru koji razmatramo, najvažnija operacija je učitavanje, i stoga nećemo nepotrebno skretati pažnju čitalaca komplikovanijim primerima obrade). *
Na kraju, potrebno je (ponovo) obratiti posebnu pažnju na oslobađanje pokazivača koji je korišćen za datoteku (f
) i, takođe, na oslobađanje pokazivača koji je vezan za tekstualni bafer koji je alociran preko funkcije malloc
(promenljiva buffer
).
Preostaje da implementiramo funkcije za očitavanje veličine datoteke i alokaciju bafera.
Funkcija ocitavanje_velicine_datoteke
vraća:
- veličinu datoteke u bajtovima - u slučaju da je predat pokazivač koji uredno pokazuje na datoteku (ili)
- vrednost
-1
- ukoliko se preda pokazivač sa vrednošćuNULL
(Objašnjenja su u komentarima.)
int ocitavanje_velicine_datoteke(FILE *f)
{
// na početku ("za svaki slučaj"),
// proverava se da li je pokazivač
// na datoteku uredno prosleđen.
if (f == NULL) return -1;
// Ukoliko je pokazivač na datoteku
// uredno prosleđen, "prošetaćemo" ga
// do kraja datoteke.
fseek(f, 0L, SEEK_END);
// Pozicija pokazivača predstavlja
// veličinu datoteke (u bajtovima).
int velicina = ftell(f);
// Da bi pokazivač (f), i dalje mogao
// da se koristi (u drugim funkcijama),
// vratićemo ga na početak.
fseek(f, 0L, SEEK_SET);
return velicina;
}
Funkcija ucitavanje_datoteke
vraća:
- pokazivač na blok memorije u koji je učitan sadržaj datoteke - ukoliko nema "ispada" (ili)
- sistemsku vrednost
NULL
- ukoliko dođe do greške
char *ucitavanje_datoteke(FILE *f, int velicina)
{
// Ukoliko nije prosleđen ispravan
// FILE pokazivač, prekida se izvršavanje.
if (f == NULL) return NULL;
// Pri kreiranju bafera, potrebno je dodati
// jedan bajt (+1), zarad terminišućeg znaka '\0'.
char *buffer = (char*) malloc(velicina + 1);
// Ukoliko funkcija malloc ne vrati pokazivač,
// takođe je potrebno prekinuti izvršavanje.
if (buffer == NULL) return NULL;
// Ostatak je sličan prethodnom primeru učitavanja,
// s tim što se ovoga puta znakovi šalju u bafer
// (odnosno, ne šalju se "na ekran").
int i = 0;
char c;
do {
c = fgetc(f);
*(buffer + i) = c;
++i;
} while (c != EOF);
// Takođe, potrebno je terminisati nisku
// (pre povratka u pozivajuću funkciju):
*(buffer + i - 1) = 0;
return buffer;
}
Promena veličine bafera (realloc)
Pri radu sa tekstom neretko se javlja potreba za promenom veličine postojećeg bafera i, ukoliko bafer (koji je nastao izvršavanjem funkcija malloc
, calloc
* ili realloc
**) - treba proširiti (što je najtipičniji slučaj), ili suziti - najčešće se koristi funkcija realloc
("re-allocation"):
buffer = (char*) realloc(buffer, nova_velicina_u_bajtovima);
Kao što vidimo, ideja je jednostavna (sintaksa takođe), ali, budući da operacija promene veličine bafera može proći neuspešno, potrebno je postupati vrlo pažljivo.
Ukoliko se naredba koju smo videli na prethodnoj slici ne izvrši uspešno - pokazivač buffer
će dobiti vrednost NULL
, ali - bafer će zapravo biti očuvan u memoriji (neće biti oslobođen).
Nepovoljna situacija (koju smo opisali pre napomene), može se izbeći uz 'razmišljanje unapred':
char *buffer_p = (char*) realloc(buffer, nova_velicina);
if (buffer_p != NULL) {
buffer = buffer_p;
}
else {
// obrada greške:
// potencijalno zaustavljanje programa
// (čije je izvršavanje zavisilo od
// uspešne realokacije bafera) i sl.
}
Za početak (kao što vidimo u gornjem primeru), uputno je da se povratna vrednost funkcije realloc
za svaki slučaj poveže sa (novim) pomoćnim pokazivačem:
- ako sve 'prođe po planu', "novi bafer" će zapravo biti proširena verzija dotadašnjeg bafera (posle čega se novi bafer lako može povezati sa starim/prvobitnim pokazivačem) i - u praksi - ukoliko postoji dovoljno slobodne memorije, funkcija
realloc
gotovo uvek uredno vraća ('novi') bafer - ukoliko dođe do greške, pokazivač
buffer_p
će dobiti vrednostNULL
, ali (što je važnije), stari bafer (i sav sadržaj) - biće očuvan(i) - što ostavlja mogućnost da se pravilno odreaguje, u skladu sa okolnostima
Kao što vidimo, programski jezik C nije nimalo sklon tome da od programera sakrije šta se dešava "ispod haube". :)
Upis u datoteku
Upis u datoteku podrazumeva (za početak), otvaranje datoteke uz argumente: "w"
ili "a"
("write" - prepisivanje preko postojeće datoteke; "append" - dodavanje sadržaja na kraj postojeće datoteke).
#include<stdio.h>
#include<stdlib.h>
void main()
{
int i, n;
FILE *f;
f = fopen("rezultat.txt", "w");
if (f == NULL) {
printf("Greška!\n");
return;
}
printf("N = ");
scanf("%d", &n);
for (i = 0; i <= n; i++) {
fprintf(f, "%d\n", rand());
}
printf("Rezultat je upisan u datoteku rezultat.txt");
fclose(f);
}
Bitni "sigurnosni" elementi iz prethodnih primera:
- grananje preko koga se proverava da li je datoteka uspešno otvorena
- obavezno zatvaranje datoteke nakon obavljanja operacija
.... i dalje su prisutni.
Sam upis u datoteku obavlja se preko funkcije fprintf
("file printf"), koja, u odnosu na "običnu" funkciju printf
, ima dodatni parametar (prvi po redu), koji predstavlja pokazivač na datoteku.
Što se tiče sadržaja koji se upisuje u datoteku (u konkretnom primeru) ....
printf("N = ");
scanf("%d", &n);
for (i = 0; i <= n; i++) {
fprintf(f, "%d\n", rand());
}
.... u pitanju je n
nasumično generisanih celobrojnih vrednosti.
Ostale funkcije za rad sa memorijskim blokovima
Osim funkcija malloc
i realloc
(sa kojima ćete se sretati u najvećoj meri), u programskom jeziku C koristi se još i nekolicina drugih funkcija za rad sa memorijskim blokovima (najčešće zarad obrade teksta, ali i u mnogim drugim okolnostima).
U nastavku, osvrnućemo se na neke od takvih funkcija.
Funkcija calloc
Ukoliko je za određeni 'programerski zahvat' potreban bafer koji je inicijalizovan nulama, može se koristiti funkcija calloc
('contiguous allocation'), koja se od funkcije malloc
razlikuje po sledećim svojstvima:
- svaki bajt je inicijalizovan vrednošću 0 (što nije slučaj sa baferom koji je inicijalizovan preko funkcije
malloc
) - pri inicijalizaciji se predaju dva argumenta - broj elemenata (strukture zarad koje se rezerviše memorijski prostor), i veličina pojedinačnog elementa
U praksi, bafer (koji će biti korišćen za tekst), inicijalizuje se preko funkcije calloc
na sledeći način:
char *buffer = (char*) calloc(broj_znakova, sizeof(char));
Međutim, iako naizgled deluje kao 'idealna zamena za funkciju malloc', može se reći da funkcija calloc
nije toliko zastupljena u programskim kodovima kao funkcija malloc
, iz sledećih razloga:
- ako nije obavezno inicijalizovati ceo bafer nulama, postavlja se pitanje u vezi sa vremenom koje se nepotrebno troši na upisivanje nula u memorijske adrese *
- ako jeste potrebno inicijalizovati ceo bafer nulama, '(ne)pisana pravila programiranja u C-u', nalažu da inicijalizaciju programer treba da obavi sam ('ručno')
Funkcija memcpy
Ukoliko je potrebno kopirati određeni broj bajtova iz jednog bafera u drugi, tipično * se koristi funkcija memcpy
:
memcpy(pokazivac_2, pokazivac_1, broj_pozicija);
// pokazivac 1 = memorijska lokacija, unutar
// niske #1, od koje počinje kopiranje
// pokazivac 2 = memorijska lokacija, unutar
// niske #2, od koje počinje upis
// broj_pozicija = broj bajtova koje je
// potrebno kopirati
// niska #1 - niska čiji sadržaj se kopira
// niska #2 - niska u koju se upisuje
// (delimičan ili potpun), sadržaj niske #1
Pre svega, funkcija memcpy
često se koristi za inicijalizaciju memorijskih blokova koji su definisani preko funkcija malloc
ili calloc
....
char *buffer = (char*) calloc(32, sizeof(char));
char *niska = "You shall not pass!";
memcpy(buffer, niska, strlen(niska));
.... a ako se pitate zašto se ne upotrebljava samo središnja naredba ....
char *niska = "You shall not pass!";
.... podsetićemo se na to da je u pitanju inicijalizacija 'string konstante' (to jest, u pitanju je niska koja se ne može naknadno menjati).
Pogledajmo još jedan primer:
char *niska_1 = "matematikom";
char *niska_2 = "Ko se to bavi metafizikom?!";
char *buffer_1 = calloc(32, sizeof(char));
char *buffer_2 = calloc(32, sizeof(char));
memcpy(buffer_1, niska_1, strlen(niska_1));
memcpy(buffer_2, niska_2, strlen(niska_2));
char *pokazivac_1 = buffer_1;
char *pokazivac_2 = buffer_2 + 15;
memcpy(pokazivac_2, pokazivac_1, strlen(niska_1));
Preko prvog argumenta, pokazivac_2
, određeno je da upis u nisku, koja se referencira preko pokazivača buffer_2
, počinje od 16. znaka.
buffer_2: "Ko se to bavi metafizikom?!";
^
char *pokazivac_2;
// buffer_2 + 15
Drugi argument, pokazivac_1
, određuje da kopiranje znakova iz niske, koja se referencira preko pokazivača buffer_1
, počinje (praktično) od 1. znaka.
buffer_1 = "matematikom";
^
char *pokazivac_1;
Treći argument, strlen(niska_1)
, određuje da će ceo sadržaj prve niske biti kopiran u drugu nisku.
Posle izvršavanja, niska #2 ima sledeći sadržaj: "Ko se to bavi matematikom?!"
.
Funkcija memset
Ukoliko je potrebno velikom brzinom upisati niz bajtova u susedne memorijske lokacije (unutar određenog bafera), tipično se koristi funkcija memset
:
memset(adresa, vrednost_za_upis, broj_pozicija)
Da pojasnimo parametre funkcije:
adresa
- pokazivač na memorijsku lokaciju od koje počinje upis (očekuje se da pripada uredno inicijalizovanom baferu)vrednost
- celobrojna promenljiva, koja praktično predstavlja znak koji će biti upisan u svaku memorijsku adresubroj_pozicija
- ukupan broj bajtova u koje će (počevši od lokacije koja je definisana preko prvog parametra, tj. argumenta), biti upisan znak (koji je definisan kao drugi argument)
U sledećem primeru ....
char *buffer_1 = calloc(32, sizeof(char));
char *niska_1 = "Funkcija memset je sjajna!";
memcpy(buffer_1, niska_1, strlen(niska_1));
char *pokazivac_1 = buffer_1 + 9;
int broj_pozicija = strlen("memset");
char novi_znak = '*';
memset(pokazivac_1, novi_znak, broj_pozicija);
.... počevši od 10. pozicije ....
buffer_1: "Funkcija memset je sjajna!";
^
char *pokazivac_1
// buffer_1 + 9
.... u sledećih 6 pozicija (dužina niske "memset"), biće upisan znak '*'
.
Rezultat izvršavanja je niska: "Funkcija ****** je sjajna"
.
Funkcija memset
(kao što ste verovatno već naslutili), često se koristi za 'brzinsko' resetovanje bafera.
Sledeći poziv:
memset(buffer, '\0', velicina_bafera);
.... upisaće vrednost '\0' u sve bajtove (u okviru bafera).
Operacije sa tekstualnim datotekama u Python-u
Ako je programski jezik C bio sklon da prikaže "sve" * što se dešava prilikom učitavanja datoteka, sa Pythonom to nije slučaj, i može se reći da je obavljanje operacija sa datotekama - znatno jednostavnije.
Čitanje datoteke
U Python-u, proces učitavanja datoteke je automatizovan (o detaljima implementacije brine interpretator), i stoga - u praktičnom smislu - nekoliko desetina linija koda i komentara koje smo videli u odeljku o učitavanju datoteka u C-u, staje u svega nekoliko linija koda:
# ----------------------------------------- #
f = open("ulaz.txt", "r") # otvaranje
s = f.read() # čitanje
f.close() # zatvaranje
# ----------------------------------------- #
Upis u datoteku
Programski kod koji se tiče upisa u datoteku, takođe je vrlo jednostavan:
# ----------------------------------------- #
s = "Tekstualni sadrzaj"
f = open("izlaz.txt", "w") # otvaranje
f.write(s) # upis
f.close() # zatvaranje
# ----------------------------------------- #
Za kraj ....
Poslednji odeljak (koji se tiče operacija sa datotekama u Python-u), predstavlja svojevrstan uvod u osvrt na dva različita pristupa:
- Python je pogodan za "brzinsko kreiranje" (sasvim funkcionalnih) programa i skripti
- pisanje kompleksnijih primera u C-u, omogućava da se nauči mnogo više o tome kako programi i računari zapravo obrađuju podatke
Diskusijama na temu "C vs Python" bavićemo se u budućnosti (prva sledeća prilika biće uvodni članak o Python-u, a spremamo i članak o izboru prvog programskog jezika), međutim, već na osnovu prvog ("ne-baš-preterano-obimnog") članka o tekstualnim datotekama, mogu se doneti određeni zaključci.
Na primer, ako se neko prvo usmeri na komplikovaniji jezik (C) i komplikovanije primere, a tek posle (!) na skriptne jezike sa pojednostavljenom sintaksom (Python), * mnogo više će naučiti i mnogo bolje će razumeti programske kodove kojima se bavi (bez obzira na to koji jezik će izabrati u nekom kasnijem trenutku).
U svakom slučaju, sada možete (uz malo truda), čitati podatke iz datoteka i upisivati podatke u datoteke, a to su veštine koje veoma dobro dođu ....