Podstawy programowania 5

Funkcje

Funkcje w języku C++ (i C) to najmniejsze samodzielne fragmenty kodu. Każda funkcja przyjmuje określone parametry (podobnie jak funkcje w matematyce) i wykonuje pewne operacje. Jeśli trzeba, to może także zwrócić wartość ustalonego typu.

Podstawy

Aby kompilator mógł sprawdzić, czy funkcja jest poprawnie wywoływana musi mieć dostęp do tzw. sygnatury, czyli opisu argumentów i zwracanej wartości. Sygnatura zawiera się w deklaracji funkcji, która wygląda następująco:

TypZwracany nazwa_funkcji(Typ1 arg1, Typ2 arg);

Argumentów może być dowolnie dużo (a nawet można stworzyć funkcję przyjmującą zmienną ilość argumentów, jest to jednak relikt z czasów języka C i nie należy raczej z tego korzystać w C++). Nazwy argumentów można pomijać, ale nie typy. Dla przykładu poniższe napisy poprawnie deklarują funkcję:

double sin(double x);

unsigned int NWD(int a, int b);

void clear(bool);

Powyższa funkcja clear deklaruje, że zwraca wartość typu void. Nie jest to prawdziwy typ, tylko informacja, że funkcja ta nie zwraca żadnej wartości, a jedynie wykonuje pewne operacje (odpowiada to procedurze w Pascalu). Funkcję wywołujemy podając jej konkretne argumenty (jak w matematyce). Przy powyższych deklaracjach poprawne wywołania to:

double x;
double y = 0.1;
x = sin(y);

int a;
int b;
b = NWD(a, b);

clear(true);

Wartość zwracaną zawsze możemy zignorować (w Pascalu jest inaczej). Oczywiście ma to sens jedynie w przypadku, jeżeli funkcja oprócz zwracania wartości wykonuje także inną pożyteczną czynność.

Deklaracja funkcji to nie wszystko, potrzebna jest także jej definicja (sama definicja jest też deklaracją, ale nie na odwrót). Robi się to następująco:

double tan(double x) { //wszystko odtąd to tzw. ciało funkcji
  double t;
  t = sin(x)/cos(x);
  return t;
}

Słowo kluczowe return kończy wykonanie funkcji i zwraca podaną wartość jako wartość funkcji. W przypadku funkcji nie zwracającej wartości możemy napisać return;, chociaż wykonanie skończy się samo, gdy dojdziemy do końca ciała funkcji. Instrukcja return może znajdować się także w środku ciała funkcji, wtedy jej napotkanie od razu kończy wykonanie i zwraca podaną wartość.

Dodajmy, że kompilator do pracy potrzebuje tylko deklaracji, dzięki temu może sprawdzić, czy funkcja jest poprawnie wywoływana i np. zgłosi błąd, jeśli będziemy chcieli napisać sin("aaa"). Definicja jest potrzebna dopiero konsolidatorowi przy tworzeniu pliku wykonywalnego. Jest to tzw. kompilacja rozłączna.

Przeciążanie funkcji

W C++ można zdefiniować kilka funkcji o tej samej nazwie różniących się jedynie przyjmowanymi argumentami. Kompilator sam wybierze najbardziej pasującą wersję. Dla przykładu:

#include <iostream>

int max(int a, int b) {
  if (a >= b)
    return b;
  else
    return a;
}

double max(double a, double b) {
  if (a >= b)
    return b;
  else
    return a;
}

int main() {
  std::cout << max(1, 2) << endl; // Wywołanie funkcji
                                  // int max(int, int)
  std::cout << max(1.0, 2.0) << endl; // Wywołanie funkcji
                                      // double  max(double, double)
  std::cout << max(1, 2.0) << endl; // Która funkcja zostanie wybrana?

  return 0;
}

Możliwość przeciążania nazw funkcji jest cechą odróżniającą język C od C++. Oczywiście każdy program da się tak napisać, aby nazwy funkcji nie były przeciążane – zmieniając nazwy funkcji. Z reguły zmniejsza to jednak czytelność kodu.

Aby rozróżnić dwie funkcje, muszą się one różnić listą argumentów. Nie wystarczą tylko różne wartości zwracane. W takim przypadku, ponieważ wartość zwracana zawsze może być zignorowana, kompilator nie mógłby rozróżnić o którą wersję chodzi.

W powyższym przykładzie wpisaliśmy dwa razy ten sam kod w ciałach funkcji max, zmieniając tylko deklarowane typy. W języku C sposobem na unikanie tego powtórzenia było pisanie tzw. makr preprocesora. Makra te nie maję jednak pojęcia o typach wartości, bo wykonywane są przez preprocesor, a nie przez kompilator. Czasem prowadzi to do bardzo trudnych do wykrycia błędów. Tworząc C++ wymyślono mechanizm, który pozwala wyeliminować to powtarzanie kodu w sposób zachowujący bezpieczeństwo typów. Są to tzw. szablony (ang. templates), którymi zajmiemy się później.

Wywoływanie funkcji

Do wywoływania funkcji służą nawiasy okrągłe (). Aby wywołać funkcję należy podać wartości dla wszystkich argumentów, podobnie jak dla funkcji w matematyce. Należy jednak pamiętać, że zachowanie funkcji może zależeć od czegoś więcej niż argumentów (np. od czasu systemowego) oraz, że zwracanie wartości to nie wszystko co funkcja może robić – funkcja może także zmieniać stan programu czy stan systemu operacyjnego, są to tzw. efekty poboczne. Czasem efekty poboczne są tym na czym najbardziej zależy. Dla przykładu standardowa funkcja języka C wyświetlająca napisy na ekranie, printf, zwraca jako wartość ilość wypisanych znaków. Funkcję printf często wywołuje się bez sprawdzania w ogóle tej wartości.

W języku C argumenty funkcji są zawsze kopiowane (mówimy o przekazaniu przez wartość). Tym samym funkcja w C nigdy nie może zmienić wartości z którymi została wywołana, za to argumenty można traktować jako zainicjalizowane zmienne w ciele funkcji, w szczególności można je bez zmartwień zmieniać w razie potrzeby. Z tego samego powodu, nazwy argumentów i nazwy zmiennych, z którymi wywołujemy funkcję nie mają ze sobą nic wspólnego – mogą być zupełnie dowolne. Kompilator sprawdzi jedynie zgodność typów.

Czasem jednak zachodzi potrzeba, aby funkcja mogła modyfikować swoje argumenty. W języku C można to osiągnąć pośrednio, przekazując wskaźnik (który sam jest przekazywany przez wartość). W języku C++ istnieje mechanizm, który pozwala na modyfikację argumentu – tzw. referencje. O wskaźnikach jak i o referencjach będziemy uczyć się później.

Proszę spojrzeć na poniższy przykład:

#include <iostream>

double twiceTheValue(double a) {
  a = 2*a;
  return a;
}

int main() {

  int a = 1;

  std::cout << "Przed wywołaniem funkcji: " << a << endl;
  std::cout << "Wynik funkcji: " << twiceTheValue(a) << endl;
  std::cout << "Po wywołaniu funkcji: " << a << endl;

  return 0;
}

Argumenty domyślne

Czasem zdarza się, że funkcja jest z reguły wywoływana z pewnymi argumentami przyjmującymi określoną wartość. Dla przykładu, niech funkcja:

double pierwiastek(double x, unsigned int n)

liczy pierwiastek n-tego stopnia z x. Spodziewamy się najczęściej potrzebować pierwiastka kwadratowego. Zapisanie funkcji jako:

double pierwiastek(double x, unsigned int n = 2)

spowoduje, że wywołanie pierwiastek(x) zostanie zamienione na pierwiastek(x, 2). W tym przypadku n jest tzw. argumentem domyślnym.

Argumenty domyślne mogą pojawić się tylko na końcu listy argumentów – choć może być ich więcej niż jeden, to za argumentem domyślnym na liście argumentów funkcji wszystkie argumenty muszą mieć wartości domyślne. Przy wywołaniu można pominąć tylko argumenty domyślne począwszy od pewnego miejsca.

Wyrażenia

Nie tylko funkcje zwracają w C++ wartości. Wiele napisów zwraca jakieś wartości, co czasem jest bardzo użyteczne.

Polecenia języka C++ dzielą się na instrukcje i wyrażenia. Poznaliśmy już wiele przykładów zarówno jednych jak i drugich. Instrukcje to m.in. instrukcja warunkowa, pętle czy instrukcja return. Instrukcje to te fragmenty kodu, które same w sobie nie mają żadnej wartości, nie da się tak zdefiniować a aby napis a = return 5 miał sens.

Wyrażenia z kolei to kawałki kodu, które mają sensowną wartość. W miarę logiczne jest, że wyrażeniami są m.in.

Trochę mniej intuicyjne jest, że pozostałe operatory też tworzą wyrażenia. Przyjrzymy się tutaj bliżej dwóm takim operatorom.

Operator przypisania

Napis x = ... jest wyrażeniem zwracającym wartość zmiennej x po przypisaniu. Dlatego kod:

if (x = 0) {
  //instrukcje
}

jest jak najbardziej poprawny. Ale wbrew temu jak na pierwszy rzut oka wygląda, wcale nie sprawdza, czy x jest równe zero (do tego służy operator relacyjny ==). Powyższa instrukcja najpierw przypisuje do x wartość zero a następnie ją zwraca. Ponieważ zero jest interpretowane jako fałsz, powyższy warunek nigdy nie będzie spełniony!

Co prawda kod w rodzaju if (x = y) może czasem być przydatny, to poza uzasadnionymi przypadkami raczej nie należy używać takiej konstrukcji. Znacznie zmniejsza ona czytelność i daje trudno uchwytne błędy. Przy każdej takiej konstrukcji powinien być odpowiedni komentarz. Dobry kompilator w przypadku napotkania takiej instrukcji zgłosi ostrzeżenie. Ostrzeżenie można wyłączyć pisząc if((x = y)).

Operator trójargumentowy

Czasem potrzebna jest wersja instrukcji warunkowej, która tworzyłaby wyrażenia. W tym celu wprowadzono do języka C operator trójargumentowy:

warunek ? wynik1 : wynik2;

Wartość tego wyrażenia zależy od warunku. Jeżeli warunek jest prawdziwy, to wartością jest wynik1, w przeciwnym wypadku wynik2. Dla przykładu funkcję max można krótko zapisać następująco:

int max(int a, int b) {
  return (a > b ? a : b);
}

Operatory strumieni

Do obsługi wejścia i wyjścia korzystamy w bibliotece IOStreams z operatorów >> i <<. Operatory te zwracają tak naprawdę swój pierwszy argument, co pozwala łatwo łączyć kilka wywołań. Instrukcja:

cout << 1 << "Hello" << endl;

jest przez kompilator rozumiana jako

((cout << 1) << "Hello") << endl;

i realizowana następująco:

  1. Do cout przekazywane jest 1. Operator << zwraca cout jako wartość.
  2. Do zwróconej wartości, więc znowu do cout przekazywane jest "Hello" i znowu zwracane jest cout.
  3. Na końcu wykonywane jest cout << endl, co powoduje, że wypisywany jest znak nowej linii i napis wędruje na ekran (co naprawdę się tutaj dzieje, to będziemy w stanie powiedzieć znacznie później).

Kompilacja rozłączna

Proszę sobie wyobrazić, że mamy ogromny projekt, którego kompilacja pochłania długie godziny. Byłoby bardzo niewygodne, gdyby nawet drobna zmiana w jednym miejscu wywoływała konieczność kompilowania całego projektu od nowa. Aby temu zaradzić wprowadzono tzw. kompilację rozłączna, czyli rozdzielenie kompilatora i konsolidatora. Rozdzielenie projektu na osobne pliki (w tym kontekście – osobne jednostki translacji) powoduje, że trzeba jedynie przeprowadzić ponowną kompilację zmienionego pliku i ponowną konsolidację projektu zamiast kompilacji całości. Dla przykładu poniższy projekt składa się z trzech plików: funkcja.h, funkcja.cpp, main.cpp:

// funkcja.h
double f(double); // Deklaracja funkcji


// funkcja.cpp
#include "funkcja.h"
double f(double x) { // Implementacja funkcji
  return 2*x;
}


// main.cpp
#include <iostream>
#include "funkcja.h"

int main() {
  double y;
  std::cin >> y;
  std::cout << f(y);
  return 0;
}

W kompilatorze g++ wywoływalibyśmy kolejno:

# Kompilacja jednostek translacji
$ g++ -c -o funkcja.o funkcja.cpp
$ g++ -c -o main.o    main.cpp

# Konsolidacja całego programu
$ g++ funkcja.o main.o

W normalnych projektach oczywiście nie robi się tego ręcznie. Uniksowym standardem jest narzędzie make, którego troszkę szerszy opis można znaleźć tutaj. Większość środowisk programistycznych korzysta albo z make, albo zawiera jakieś własne podobne rozwiązanie (np. nmake w Visual Studio), nawet jeśli środowisko ukrywa jego działanie przed użytkownikiem.

Narzędzie to ma wiele zastosowań także poza programowaniem, w szczególności ta strona też jest tworzona przy użyciu m.in. programu make.

Mechanizm rozłącznej kompilacji pozwala też na rozprowadzanie swojej biblioteki bez udostępniania kodu źródłowego – innym programistom dajemy tylko skompilowane pliki obiektowe (w powyższym przykładzie – pliki *.o) i nagłówki (*.h), aby mogli z nich korzystać. Nie rozprowadzamy plików źródłowych (*.cpp). Oczywiście czyni to poszukiwanie ewentualnego błędu w bibliotece znacznie trudniejszym.