Kontakt  |  PRS 170  |  ALK 420  |  PZR 420  |  SIK 420  |  JPR 222  |  SOP 121


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).


Valid HTML 4.01!