Języki programowania - ćwiczenia 3
Temat zajęć:
Typy użytkownika, struktury i unie, dyrektywy preprocesora, łańcuchy
tekstowe, parametry funkcji main, wskaźniki na funkcje, nieobiektowe
elementy C++.
Materiały dodatkowe:
- Opis standardowej biblioteki C (wersja on-line) >>
Typy użytkownika
Język C pozwala na definicję nowych typów danych, które
od momentu ich zdefiniowania mogą być używane tak jak wbudowane typy
języka. Składnia definicji typu jest następująca:
typedef definicja_typu nazwa_typu;
Przykładowo, możemy zdefiniować typ wskaznik jako
typedef void* wskaznik;
Nieco inną składnię ma definicja typu tablicowego, w której
nawiasy kwadratowe należy umieścić po nazwie nowego typu, a nie po
nazwie typu definiującego elementy tablicy:
typedef int[10] tablica_calkowita; /* <-- źle! błąd składni! */
typedef int tablica_calkowita[10]; /* <-- dobrze */
Częstą praktyką jest definiowanie własnych typów wyliczeniowych (enum), np.
typedef enum dni_tygodnia (pon, wto, sro, czw, pia, sob, nie);
Zmiennym typu dni_tygodnia można przypisywać wyłącznie stałe (wartości)
wymienione jako dopuszczalne wartości typu wyliczeniowego. W praktyce
wartości typu wyliczeniowego mapowane są na liczby całkowite. W
powyższym przykładzie wartości pon odpowiada wartość liczbowa 0,
wartości wto wartość 1 itd. Można jawnie przypisać wartości liczbowe
wartościom typu wyliczeniowego. Gdybyśmy chcieli, aby dni_tygodnia
numerowane były od 1, a nie od 0, możemy zastosować jedną z poniższych
konstrukcji:
typedef enum dni_tygodnia (pon=1, wto, sro, czw, pia, sob, nie);
typedef enum dni_tygodnia (pon=1, wto=2, sro=3, czw=4, pia=5, sob=6, nie=7);
Należy pamiętać, że symboliczne oznaczenia wartości typu wyliczeniowego
nie mają reprezentacji tekstowej, zatem poniższa konstrukcja nie jest
poprawna:
dni_tygodnia dzien;
dzien = sro;
printf("%s\n", dzien); /* <-- błąd! sro to nie to samo co "sro" */
Typy wyliczeniowe są stosowane głównie na wewnętrzne potrzeby
logiki aplikacji (np. nie chcemy dopuścić aby ktoś pod dzień tygodnia
mógł podstawić wartość mniejszą od 1 lub większą od 7). Można
stosować relacje na zmiennych typów wyliczeniowych, np.
dzien_tygodnia d1, d2;
d1 = sob;
d2 = pon;
if (d1 < d2) printf("d1 jest przed d2\n");
else if (d1 > d2) printf("d1 jest po d2\n");
else printf("d1 i d2 to ten sam dzien tygodnia\n");
Struktury i unie
Struktura jest odpowiednikiem rekordu z języka Pascal, a unia to
odpowiednik rekordu wariantowego. Struktura grupuje dane różnych
typów, powiązane ze sobą z punktu widzenia programisty. Blok
pamięci zajmowany przez strukturę to suma bloków zajmowanych
przez poszczególne jej składowe (nazywamy je polami).
Unia służy głównie do ułatwienia stosowania różnej
interpretacji tego samego bloku pamięci. Wszystkie pola unii
przechowywane są w tym samym obszarze pamięci (nakładają się na
siebie), zatem rozmiar unii jest równy rozmiarowi jej
największego pola.
O ile zatem struktura faktycznie grupuje dane różnych
typów, o tyle unia umożliwia dostęp do tych samych danych w
różny sposób.
Składnia definicji struktury (jedna z możliwych):
typedef struct MojaStruktura {
typ1 pole1;
typ2 pole2;
...
typn polen;
} MojaStruktura;
Składnia definicji unii (jedna z możliwych):
typedef union MojaUnia {
typ1 pole1;
typ2 pole2;
...
typn polen;
} MojaUnia;
Jeśli s jest zmienną typu strukturalnego (lub unijnego), to w celu odwołania się do pola polex struktury (unii) s należy zastosować operator . (kropka), np. s.polex = wartosc; czy zmienna = s.polex;. Jeśli w jest wskaźnikiem na strukturę (unię), to możemy albo zastosować kombinację kropki i dereferencji, np. (*w).polex = wartosc;, albo zastosować specjalny operator języka C dostępu do pola wskazywanej struktury ->, np. w->polex = wartosc; czy zmienna = w->polex;.
Przykład 1
Program A oblicza kwadraty i sześciany liczb od 0 do n-1 (n jest podawane przez użytkownika), a następnie zapisuje je do pliku wart.txt w bieżącym katalogu. Program B wczytuje dane z pliku wart.txt i wyświetla je na konsoli. Ponieważ definicja struktury wartosci jest wspólna dla programów A i B, została umieszczona w pliku nagłówkowym c3p1.h, który dołączany jest przez preprocesor zarówno do modułu programu A, jak i do modułu programu B. Proszę przy okazji zwrócić uwagę na fakt, że treść pliku nagłówkowego c3p1.h umieszczona jest wewnątrz dyrektywy procesora #ifndef. Powoduje ona, że zawartość pliku nagłówkowego dołączana jest tylko wtedy, gdy nie została jeszcze zdefiniowana stała __C3P1_H__ (w przeciwnym wypadku jest ignorowana). Stała __C3P1_H__
jest definiowana (w wierszu 2) przy pierwszym dołączeniu tego pliku
nagłówkowego do kompilowanego modułu. Jest to standardowy
sposób zabezpieczania plików nagłówkowych przed
ich wielokrotnym dołączaniem do kompilowanego modułu (często występuje
bowiem sytuacja, w ktorej w pliku x.h znajduje się #include <y.h>, natomiast w kompilowanym pliku źródłowym występuje #include <x.h> oraz #include <y.h> - wówczas y.h byłby dołączony dwukrotnie, o ile nie posiada zabezpieczenia, o którym właśnie mówimy).
Solution Visual Studio (zawiera 2 projekty): pobierz
Plik c3p1.h pobierz
#ifndef __C3P1_H__
#define __C3P1_H__
typedef struct wartosci {
int liczba;
int kwadrat;
int szescian;
} wartosci;
#endif
Plik c3p1a.cpp pobierz
#include <stdio.h>
#include <stdlib.h>
#include "../c3p1.h"
int main()
{
int n, i;
wartosci *TablicaWartosci;
FILE *plik;
printf("Podaj n : ");
scanf("%d",&n);
if (n <= 0) {
printf("Nieprawidlowe n\n");
return 1;
}
TablicaWartosci = (wartosci*) malloc(n * sizeof(wartosci));
for (i = 0; i < n; i++) {
TablicaWartosci[i].liczba = i;
(TablicaWartosci+i)->kwadrat = i * i;
(*(TablicaWartosci+i)).szescian = i * i * i;
}
plik = fopen("wart.txt", "w");
if (plik == NULL) {
printf("Nie moge otworzyc wart.txt do zapisu\n");
return 1;
}
for (i = 0; i < n; i++) {
fprintf(plik, "%d %d %d\n",
TablicaWartosci[i].liczba,
TablicaWartosci[i].kwadrat,
TablicaWartosci[i].szescian
);
}
fclose(plik);
printf("Dane zapisane.\n");
free(TablicaWartosci);
system("PAUSE");
return 0;
}
Plik c3p1b.cpp pobierz
#include <stdio.h>
#include <stdlib.h>
#include "../c3p1.h"
int main()
{
wartosci wartosc;
FILE *plik;
plik = fopen("wart.txt", "r");
if (plik == NULL) {
printf("Nie mozna otworzyc wart.txt do odczytu\n");
return 1;
}
while (! feof(plik)) {
fscanf(plik, "%d", &(wartosc.liczba));
fscanf(plik, "%d", &(wartosc.kwadrat));
fscanf(plik, "%d", &(wartosc.szescian));
printf("%d : ^2=%d, ^3=%d\n",
wartosc.liczba, wartosc.kwadrat, wartosc.szescian);
}
fclose(plik);
system("PAUSE");
return 0;
}
Przykład 2
Program wyświetla reprezentację liczby 32-bitowej w formie czterech kolejnych bajtów wykorzystując do tego celu unię.
Projekt Visual Studio pobierz
Plik c3p2.c pobierz
#include <stdio.h>
#include <stdlib.h>
typedef union reprezentacja {
int liczba32;
struct {
unsigned char bajt1;
unsigned char bajt2;
unsigned char bajt3;
unsigned char bajt4;
} bajty;
} reprezentacja;
int main() {
reprezentacja wartosc;
printf("Podaj liczbe : ");
scanf("%d", &(wartosc.liczba32));
printf("Jako bajty: %d %d %d %d\n",
wartosc.bajty.bajt1,
wartosc.bajty.bajt2,
wartosc.bajty.bajt3,
wartosc.bajty.bajt4
);
system("PAUSE");
return 0;
}
Definicje stałych i makrodefinicje
Stałe w języku C definiowane są za pomocą dyrektywy preprocesora #define. Na przykład:
#define LICZBA_PI 3.14
#define MOJE_IMIE "Tomek"
Odtąd wszędzie w programie, gdzie wystąpi napis LICZBA_PI (za wyjątkiem sytuacji, gdy jest on częścią łańcucha tekstowego), zostanie on zamieniony przez preprocesor na napis 3.14 (analogicznie z MOJE_IMIE). Zatem definicja stałych nie ogranicza się tylko do wartości, ale również do fragmentów kodu. Na przykład:
#define PR printf
Takie "stałe" możemy również parametryzować - nazywamy je wówczas makrodefinicjami. Makrodefinicje również działają na zasadzie prostej zamiany tekstu, mogą jednak posiadać parametry.
Przykłady:
#define PISZ_STR(X) printf("%s\n",(X))
#define MAX(A,B) ((A)>(B) ? (A) : (B))
Fragment kodu
PISZ_STR("TTT");
zostanie więc rozwinięty przez preprocesor do
printf("%s\n",("TTT"));
zaś
x = MAX(y,z+2);
zostanie rozwinięty do
x = ((y)>(z+2) ? (y) : (z+2));
Podkreślam raz jeszcze: makrodefinicje nie są funkcjami - ich działanie opiera się na prostej zasadzie zamiany napisów w kodzie źródłowym przez preprocesor jeszcze przed
właściwą kompilacją programu. Nie zapewniają one kontroli typów
i nie jest możliwe podanie nazwy makrodefinicji tam, gdzie wymagany
jest np. wskaźnik na funkcję.
Operacje na łańcuchach tekstowych
Łańcuchy tekstowe w języku C przechowywane są w zmiennych typu
char[] lub char*. Długość łańcucha nie jest jawnie zapisana w samym
łańcuchu, dlatego aby oznaczyć jego koniec stosuje się znak o kodzie 0
(0x00, '\0') jako ostatni, nadmiarowy znak w łańcuchu (nazywa się to
również reprezentacją ANSI).
Na przykład, łańcuch "Tomek" w rzeczywistości zapisany jest w pamięci jako znaki 'T', 'o', 'm', 'e', 'k', '\0'.
Dlatego aby operować na łańcuchu wystarczy nam wskaźnik na pierwszy
znak tego łańcucha (czyli zmienna typu char* lub char[], jak wcześniej
wspomnieliśmy). Jako łańcuch traktowane będą wszystkie znaki (bajty)
począwszy od adresu zawartego we wskaźniku aż do napotkania znaku o
kodzie 0.
UWAGA:
W związku z coraz powszechniejszym wykorzystaniem standardu Unicode,
niektóre nowoczesne platformy definiują typ dwubajtowy jako typ
znakowy - zwykle nosi on nazwę wchar (wide character).
Przykład 3
Program demonstruje znaczenie znaku '\0' jako terminatora łańcucha w języku C.
Projekt Visual Studio pobierz
Plik c3p3.c pobierz
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
int main()
{
char *tekst;
tekst = (char*) malloc(100);
strcpy(tekst,"ABCDEFGHIJK"); /* = "ABCDEFGHIJK\0" */
printf("%s\n",tekst);
tekst[3] = 0; /* można także tekst[3] = '\0' */
/* albo np. *(tekst+3) = 0 */
printf("%s\n",tekst);
free(tekst);
system("PAUSE");
return 0;
}
W przypadku stałych tekstowych występujących literalnie w kodzie
programu znak '\0' jest niejawnie dodawany na końcu każdej takiej
stałej, więc łańcuch "ABC" zajmuje faktycznie 4 bajty, a nie 3. Należy
o tym pamiętać projektując tablice przechowujące łańcuchy.
Kolejną niedogodnością w operowaniu na łańcuchach tekstowych w języku C
(w C++ można ją częściowo zniwelować przeciążając operatory) jest fakt,
że nie wolno stosować operatorów arytmetycznych w operowaniu na łańcuchach. Operator + w przypadku danych typu char* nie działa jak łączenie łańcuchów (co znamy np. z języka Pascal), a przypisanie typu s1 = s2 powoduje, że s1 i s2 wskazują na ten sam łańcuch w pamięci, a nie na dwie jego kopie.
Ważniejsze funkcje operujące na łańcuchach (prototypy w string.h):
- int strlen(const char *str) - podaje długość łańcucha str, nie wliczając w to znaku '\0' na jego końcu (zatem aby przechować łańcuch o długości strlen(s) potrzeba faktycznie strlen(s)+1 bajtów w pamięci)
- char* strcpy(char *dest, const char *src) - kopiuje łańcuch src do dest (oba muszą już być zaalokowane i dest musi być blokiem pamięci o długości co najmniej strlen(src)+1)
- char* strdup(const char *str) - tworzy duplikat łańcucha str, alokując dla niego pamięć (trzeba go później zwolnić przez free)
- char* strcat(char *str1, const char *str2) -
dołącza str2 na koniec str1 (blok pamięci zaalokowany na str1 musi mieć długość co najmniej strlen(str1)+strlen(str2)+1 bajtów)
- int strcmp(const char *s1, const char *s2) -
porównuje leksykograficznie s1 z s2 (zwraca wartość ujemną jeśli
s1<s2, zero jeśli s1=s2 i dodatnią jeśli s1>s2)
- sprintf(str, format_str, ...) - działa jak printf, jednak wynikowy łańcuch umieszczany jest w str
(który musi być wcześniej zaalokowanym blokiem pamięci o
wystarczającej długości aby pomieścić sformatowany łańcuch wyjściowy)
- int atoi(const char *str) - zamienia liczbę zapisaną jako tekst na jej wartość liczbową (całkowitą)
- double atof(const char *str) - zamienia liczbę zapisaną jako tekst na jej wartość liczbową (rzeczywistą)
Funkcje operujące na blokach pamięci (prototypy w stdlib.h):
- void memset(void *ptr, int val, int cnt) -
wypełnia cnt bajtów bloku wskazywanego przez ptr wartością val
- void* memcpy(void *dest, const void *src, int cnt)
- kopiuje cnt bajtów z bloku wskazywanego przez src do bloku
wskazywanego przez dest
Parametry funkcji main
Dotychczas używaliśmy funkcji main bez parametrów. Funkcja ta może jednak przyjmować dwa parametrty:
int main(int argc, char **argv)
Parametr argc określa liczbę łańcuchów zawartych w tablicy argv
(przypomnijmy, że char **argv jest równoważne char* argv[]).
Pierwszym elementem w argv (czyli argv[0]) jest nazwa (i ew. ścieżka)
pliku wykonywalnego, w którym zawarty jest wykonywany program. Kolejne elementy zawierają parametry
podane przy starcie programu w wierszu poleceń.
Zadanie 1
Załóżmy, że użytkownik uruchamiając nasz program poda
w wierszu poleceń parametry będące liczbami rzeczywistymi. Proszę
napisać program, który wyświetli te wartośći w dwóch
kolumnach, posortowane leksykograficznie i numerycznie.
Przykładowo, dla parametrów 2.3 10.1 5 wyjściem programu powinno być
10.1 2.3
2.3 5
5 10.1
Zadanie 2
Napisać program, który dokona faktoryzacji liczby całkowitej, czyli przedstawi zadaną liczbę całkowią v w postaci v = f1 * f2 * ... * fn, przy czym f1,...,fn>1 i wszystkie fi są liczbami pierwszymi. Czynniki powinny być posortowane niemalejąco.
Wskaźniki na funkcje
W języku C możemy korzystać ze zmiennych (wskaźników),
którym mogą być przypisywane funkcje. Zmienną taką traktujemy
wówczas jak funkcję (można ją wywołać, czyli uruchomić).
Wskaźniki na funkcje stosowane są zwykle w pewnych ogólnych
schematach obliczeń, w których pewne elementy są zależne np. od
typów danych (patrz przykład 1). Siłą wskaźników na
funkcje jest fakt, że można je przekazywać jako parametry do innych
funkcji. Prosty (trywialny) przykład zawierający wskaźnik na funkcję
został przedstawiony poniżej.
#include <stdio.h>
/* każda funkcja bezwynikowa przyjmująca jeden parametr
typu wskaźnik na int jest tego typu */
typedef void (*FunkcjaPrzetwarzajaca)(int*);
int tab[10];
void fp1(int *x)
{
*x = *x - 1;
}
void fp2(int *y)
{
printf("%d\n",*y);
}
void Przetwarzaj(int *t, int n, FunkcjaPrzetwarzajaca f)
{
int i;
for (i = 0; i < n; i++)
f(t+i);
}
int main(void)
{
int i;
for (i = 0; i < 10; i++)
tab[i] = i;
Przetwarzaj(tab,10,fp1);
Przetwarzaj(tab,10,fp2);
return 0;
}
Zdefiniowaliśmy nazwany typ funkcyjny: FunkcjaPrzetwarzajaca. Pasuje do niego każda funkcja, która nie zwraca wyniku (void) i przyjmuje jako parametr jeden wskaźnik na int. Każda taka funkcja może być przekazana jako parametr do funkcji Przetwarzaj,
która jest ogólnym schematem przechodzenia tablicy i
wykonywania pewnej (podanej jako parametr) operacji dla każdego
elementu tablicy. Ten sam efekt można osiągnąć bez definiowania
osobnego typu wskaźnikowego, prototyp funkcji przetwarzaj powinien
wówczas wyglądać tak:
void Przetwarzaj(int *t, int n, void (*f)(int*))
Typ wskazujący na funkcje komplikuje się jeszcze bardziej gdy chcemy pominąć nazwę zmiennej (parametru):
void Przetwarzaj(int*, int, void (*)(int*))
Nawiasy wokół * są obowiązkowe - służą rozróżnieniu void* i void (*),
czyli wskaźnika na blok pamięci i wskaźnika na bezwynikową funkcję. W
typowym przypadku wygodniej jest zdefiniować osobny typ funkcyjny niż
tworzyć zawiłe definicje typów nienazwanych.
Przykład 4
Załóżmy, że chcemy napisać funkcję sortującą elementy
dowolnej tablicy (dokładniej: tablicy zawierającej elementy dowolniego,
czyli nie znanego z góry, typu). Zauważmy, że w algorytmie
sortowania jedynym miejscem, w którym musimy znać typ (naturę)
elementów tablicy jest porównanie (do wykonania zamiany
wystarczy znajomość rozmiarów poszczególnych
elementów tablicy - wiemy już jak kopiować bloki pamięci).
Projekt Visual Studio pobierz
Plik unisort.cpp pobierz
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* ma zwracac >0 jesli pierwszy element
jest wiekszy, 0 jesli sa rowne
i <0 jesli drugi jest wiekszy */
typedef int (*porownaj_t)(void*, void*);
void sort(void *tablica,
int liczba_elementow,
int rozmiar_elementu,
porownaj_t porownaj)
{
int i,j,k;
char *t;
char tmp[4096];
t = (char*) tablica;
for (i=0; i<liczba_elementow; i++) {
for (j=0; j<liczba_elementow-1; j++) {
if ( porownaj( t+j*rozmiar_elementu,
t+(j+1)*rozmiar_elementu
) > 0) {
memcpy(tmp,
t+j*rozmiar_elementu,
rozmiar_elementu);
memcpy(t+j*rozmiar_elementu,
t+(j+1)*rozmiar_elementu,
rozmiar_elementu);
memcpy(t+(j+1)*rozmiar_elementu,
tmp,
rozmiar_elementu);
}
}
}
}
int porownaj_liczby(void *l1, void *l2)
{
int *li1, *li2;
li1 = (int*) l1;
li2 = (int*) l2;
if (*li1 > *li2) return 1;
else if (*li1 < *li2) return -1;
else return 0;
}
int porownaj_lancuchy(void *l1, void *l2)
{
char *la1, *la2;
la1 = (char*) l1;
la2 = (char*) l2;
return strcmp(la1, la2);
}
int main()
{
int tab1[] = { 3, 5, 2, 7, 9, 1, 1, 4 };
char* tab2[] = {"Kasia", "Jola", "Beata", "Ala"};
int i;
sort(tab1, 8, sizeof(int), porownaj_liczby);
sort(tab2, 4, sizeof(char*), porownaj_lancuchy);
printf("\n\nPosortowane:\n");
for (i=0; i<8; i++) printf("%d\n",tab1[i]);
for (i=0; i<4; i++) printf("%s\n",tab2[i]);
return 0;
}
Nieobiektowe rozszerzenia języka C++ w stosunku do C.
- W języku C++ istnieje wygodniejszy niż malloc i free sposób tworzenia i niszczenia zmiennych dynamicznych (choć oczywiście malloc i free mogą nadal być wykorzystywane, jednak nie do dynamicznej alokacji obiektów). Służą do tego nowe operatory new i delete. Przykładowo, przy deklaracji char *x można tworzyć tablice o dynamicznej długości za pomocą x = new char[100] czy x = new char[rozmiar] (rozmiar może być zmienną). Usunięcie takiej zmiennej to po prostu delete[] x.
UWAGA: Operatory new i delete mogą służyć do alokacji i zwalniania skalarów i tablic. Konstrukcje x = new int oraz y = new int[1] nie są tożsame! W pierwszym przypadku aby zwolnić pamięć przydzieloną skalarowi x należy użyć konstrukcji delete x, a w drugim, aby zwolnić pamięć przydzieloną tablicy (choć jednoelementowej, ale jednak tablicy) y należy użyć konstrukcji delete[] y.
- Można deklarować zmienne lokalne w blokach programu (każdy blok może mieć swoje zmienne lokalne), np:
for (int i=0; i<100; i++) {
int j;
... // tutaj można korzystać z i,j
}
// tutaj i oraz j nie są już widoczne
- Język C++ wprowadza pojęcie referencji czyli pewnego
abstrakcyjnego wskaźnika na obiekt (nie jest to wskaźnik w sensie
adresu pamięci, a jedynie reprezentant danego obiektu czy zmiennej;
traktując zmienną jako pewne oznaczenie w programie fragmentu pamięci
przechowującego pewną wartość, referencje umożliwiają używanie wielu
oznaczeń na to samo miejsce w pamięci). Z praktycznego punktu widzenia
referencja jest więc wskaźnikiem, do którego stosuje się
składnię jak w przypadku zwykłej zmiennej. Przykład:
int x = 10;
int& y = x; // y jest referencją do x
y = 15; // tym samym x = 15, uwaga: y to nie wskaźnik,
// więc nie piszemy *y = 15
Referencje można wykorzystywać np. do przekazywania parametrów do funkcji "przez zmienną" (w zasadzie nazywa się to by reference,
czyli przez referencję - pojęcie "przez zmienną" używane jest tylko w
kontekście języka Pascal). Przykładowo, jeśli funkcja ma postać jak
niżej
void f(int& a)
{
a++;
}
to po wykonaniu poniższego kodu
int x=10;
f(x);
zmienna x będzie miała wartość 11.
- W C++ można przeciążać operatory, czyli dostarczać własną
implementację dla standardowych operatorów języka (np.
arytmetycznych). Zajmiemy się tym zagadnieniem osobno na zajęciach.
- C++ posiada potężne narzędzie zwane szablonami, które pozwala na konstrukcję funkcji i klas parametryzowanych typem danych
(typ ten nie jest znany w momencie implementowania szablonu). Z tym
narzędziem również zapoznamy się (w podstawowym zakresie),
ponieważ jest to technika nietypowa a dająca ogromne możliwości (cała
biblioteka STL, która obecnie wchodzi w skład standardowej
biblioteki C++, oparta jest na szablonach).