PROGRAMOWANIE – PRZYKŁADY
IDE C++ WinBGI C# Wpadki

Przykład C++

Suma szeregu liczbowego Funkcja Bessela Deklaracja a definicja Program w Borland C++ Program w MinGW C++ Program w Visual C++ Poprzedni przykład Następny przykład Program w Visual C# Kontakt

Suma szeregu liczbowego

W praktyce obliczeniowej zachodzi niekiedy potrzeba wyznaczenia sumy szeregu liczbowego postaci

Jeżeli ciąg sum cząstkowych tego szeregu zdefiniowanych jako

jest zbieżny do pewnej granicy, to mówimy, że szereg jest zbieżny i ma sumę równą tej granicy. Naturalny schemat obli­czania sumy takiego szeregu polega na itera­cyjnym obli­czaniu kolejnego składnika i dodawaniu go do wartości zmiennej, w której liczona jest suma:

w = kolejny_składnik;
s = s + w;

Podstawową zasadą, którą należy się kierować przy obliczaniu składników sumy, jest próba znale­zienia zależności rekuren­cyjnej pomiędzy dwoma kolejnymi składnikami. Jeżeli taka zależność jest znana, jej wykorzy­stanie prowadzi do zmniej­szenia liczby działań arytme­tycznych potrzebnych do rozwią­zania zadania. Sumowanie należy zakończyć, gdy nieuwzglę­dnione składniki prakty­cznie nie wpływają na wartość sumy. Zazwyczaj nie można z góry określić liczby iteracji – zależy ona od szybkości zbieżności szeregu i żądanej dokła­dności wyniku. Obli­czanie sumy szeregu liczbowego za pomocą kompu­tera wymaga dużej ostro­żności, ponieważ zbyt wolna zbieżność szeregu wymaga uwzglę­dnienia dużej liczby składników, co może prowadzić do znacznej kumulacji błędów zaokrągleń wynika­jących z realizacji operacji arytme­tycznych na liczbach rzeczywistych.

Funkcja Bessela

Przykładem szeregu liczbowego jest szereg potęgowy definiujący jedną z funkcji Bessela:

w którym

oznacza silnię. Łatwo sprawdzić, że składniki sumy tego szeregu można wyznaczać rekuren­cyjnie:

Co więcej, mają one naprzemienne znaki i szybko dążą do zera. Posłu­gując się metodami analizy matema­tycznej można wykazać, że właści­wość ta zapewnia zbieżność szeregu dla każdej liczby rzeczywistej x. Zazwyczaj sumowanie szeregów kończy się, gdy ostatni składnik uwzglę­dniony w końcowej sumie cząstkowej jest w porównaniu z nią względnie mały, czyli gdy nie przekracza tej sumy pomno­żonej przez zadaną dokładność:

Rozważania te prowadzą do następującego podprogramu obliczania wartości funkcji Bessela w języku C i C++:

double Jo(double x)
{
    double w = 1.0,  s = w;
    for (int k = 1; fabs(w) > EPS * fabs(s); k++)
    {
        w = -0.25 * x * x / (k * k) * w;
        s = s + w;
    }
    return s;
}

Nagłówek funkcji określa, że jest ona typu double (zwraca wartości typu double), ma nazwę Jo oraz jeden argument typu double o nazwie x i wartości wyzna­czonej w trakcie wywołania tej funkcji. Argument x trakto­wany jak zmienna lokalna w bloku występu­jącym tuż za nagłówkiem funkcji obejmu­jącym w nawiasach {} wszystkie instru­kcje odpowia­dające za jej działanie (ciało funkcji).

Obliczenia są wykonywane iteracyjnie w pętli for. Po wykonaniu k-tego kroku wartością zmiennej lokalnej w jest kolejny składnik wk, a wartością zmiennej s kolejna suma cząstkowa sk. Wstępnie obie zmienne są inicjali­zowane składni­kiem w0 (wartością 1). Iteracja kończy się, gdy ostatni składnik podzie­lony przez końcową sumę cząstkową nie przekracza danej dokła­dności (wartości EPS zdefinio­wanej poza funkcją), która powinna być dostate­cznie mała, by błąd pominięcia dalszych składników prawdziwej sumy szeregu mieścił się w rozsą­dnych granicach. Wartością zwracaną przez funkcję Jo jest ostatnia suma cząstkowa przypi­sana zmiennej s.

Funkcja fabs z biblioteki matema­tycznej math języka C zwraca wartość bezwzględną argu­mentu typu double. Dla innych typów liczbowych istnieją w tej biblio­tece podobne funkcje, np. funkcja abs zwraca wartość bezwzglę­dną typu int dla argu­mentu typu int. Biblio­teka matema­tyczna cmath języka C++ zawiera przecią­żone wersje funkcji abs dla różnych typów, m.in. dla typu double, którą można używać zamiast fabs (nie w Borland C++ 5.5). Przecią­żanie funkcji, zwane również przełado­waniem, polega na utwo­rzeniu kilku funkcji o tej samej nazwie różniących się liczbą lub typem argumentów przekazy­wanych do tych funkcji. Dodatkowo funkcje przecią­żone mogą różnić się typem zwraca­nych przez nie wartości.

Kod powyższej funkcji Bessela nie jest optymalny, ponieważ w wyrażeniu określa­jącym kolejny składnik przypi­sywany zmiennej w występuje czynnik, który jest wyznaczany wielokrotnie, chociaż się nie zmienia. Ten niepo­trzebny koszt można wyelimi­nować w ten sposób, że wartość czynnika oblicza się jeden raz przed rozpo­częciem iteracji i przypisuje się ją zmiennej pomocni­czej, którą zastępuje się każde wystą­pienie czynnika w pętli for. Można również trady­cyjny operator przypi­sania = wystę­pujący dwukrotnie w kroku itera­cyjnym zastąpić opera­torami *=+=, obie instru­kcje przypi­sujące nowe wartości zmiennym ws zastąpić jedną, a na koniec pominąć zbędne nawiasy {}:

double Jo(double x)
{
    double w = 1.0,  s = w,  m4xx = -0.25 * x * x;
    for (int k = 1; fabs(w) > EPS * fabs(s); k++)
        s += (w *= m4xx / (k * k));
    return s;
}

Priorytety operatorów arytmetycznych i operatorów przypi­sania oraz prawo­stronna łączność ostatnich w języku C++ pozwalają na pomi­nięcie pary nawiasów zewnętrznych w tak rozbu­dowanej instrukcji, ale pozosta­wienie ich polepsza czytelność kodu.

Deklaracja a definicja

Każda nazwa użyta w programie w języku C++ powinna być najpierw zadekla­rowana. Deklaracja nazwy informuje kompi­lator, do jakiego rodzaju elementów ta nazwa się odnosi. Oto kilka przykładów deklaracji:

int k;

double dx = 0.01;

extern double var;

double f(int, double);

double sqr(double x)
{
    return x * x;
}

Definicja nazwy definiuje element, do którego się ta nazwa odnosi. Definicja zmiennej powoduje przydział odpowie­dniej ilości pamięci dla zmiennej, może również tę zmienną zainicja­lizować, czyli przypisać jej początkową wartość. Definicja funkcji precyzuje jej nazwę, typ zwracanej przez nią wartości, jej argumenty i operacje, jakie ta funkcja ma wyko­nywać. Każda defi­nicja jest jedno­cześnie deklaracją, ale nie odwrotnie. W powyższym przykła­dzie są trzy definicje:

Natomiast pozostałe dwie deklaracje

extern double var;

double f(int, double);

nie są definicjami. Pierwsza określa, że pamięć dla zmiennej var typu double jest gdzieś przydzie­lona przez jakąś defi­nicję. Druga dekla­racja jest tzw. prototypem funkcji, który informuje, że kod funkcji f jest gdzieś wyspecy­fikowany, oraz że zwraca ona wartości typu double, jej pierwszy argument jest typu int, drugi typu double. Prototyp funkcji jest po prostu nagłówkiem funkcji zakoń­czonym średnikiem (nazwy argu­mentów mogą być pominięte).

Deklaracja nazwy ma pewien zasięg widoczności w kodzie programu. Nazwa zadekla­rowana wewnątrz funkcji (nazwa lokalna) ma zasięg od punktu dekla­racji do końca bloku, w którym znajduje się ta dekla­racja. Nazwa zadekla­rowana poza funkcją (nazwa globalna) ma zasięg od punktu dekla­racji do końca pliku zawiera­jącego tę dekla­rację. Dekla­racja nazwy w bloku może zasłonić dekla­rację znajdującą się na zewnątrz tego bloku, czyli nazwa może zostać zdefi­niowana ponownie i odnosić się wewnątrz bloku do innego elementu. Na zewnątrz tego bloku nazwa odzyskuje swoje poprze­dnie znaczenie. Takie zasła­nianie nazw zdarza się często podczas pisania dużych programów.

Deklaracja nazwy może wystąpić w tym samym zasięgu wielo­krotnie pod warunkiem, że określa ten sam element, natomiast defi­nicja powinna pojawić się w danym zasięgu tylko jeden raz. Kompi­lacja zakończy się niepowo­dzeniem, gdy kompilator napotka niezadekla­rowaną nazwę. W szczególności dekla­racja funkcji powinna poprze­dzać jej wywołanie, czyli funkcją wywołującą. Jeżeli dekla­racja jest prototypem, zadekla­rowana przezeń funkcja może znajdować się w tym samym pliku za funkcją wywołującą albo w oddzielnym pliku. Proto­typy funkcji są często umieszczane w plikach nagłów­kowych włączanych do programu za pomocą dyrektyw #include, a definicje funkcji w plikach źródłowych, półskompi­lowanych lub biblio­tecznych.

Program w Borland C++

Program tworzący w przedziale [0, 5] tablicę wartości funkcji Bessela z krokiem ¼ i korzystający z opracowanej wyżej funkcji Jo może wyglądać następująco:

#include <stdio.h>
#include <math.h>
#include <conio.h>

#define  N    20
#define  H    0.25
#define  EPS  0.5e-8

double Jo(double x)
{
    double w = 1.0,  s = w,  m4xx = -0.25 * x * x;
    for (int k = 1; fabs(w) > EPS * fabs(s); k++)
        s += (w *= m4xx / (k * k));
    return s;
}

int main()
{
    printf("  x       Jo(x)\n-----------------\n");
    for (int i = 0; i <= N; i++)
    {
        double x = i * H;
        printf("%5.2lf %10.6lf\n", x, Jo(x));
    }
    printf("-----------------\n");
    _getch();
    return 0;
}

Trzy dyrektywy #define wystąpujące na początku programu po dyrektywach #include definiują nazwy symbo­liczne, z którymi wiążą ciągi znaków repre­zentujące:

Każde pojawienie się takiej nazwy w tekście programu jest przez prepro­cesor zastę­powane skoja­rzonym z nią ciągiem znaków. Ostatni ciąg jest notacją naukową liczby rzeczy­wistej odpowia­dającą konwencjo­nalnemu zapisowi ½.10-8. Formato­wanie wydruku uzyskuje się za pomocą funkcji printf w ten sposób, że w każdym wierszu tablicy wartość zmiennej x wypro­wadza się do pola o szerokości 5 znaków z dwoma cyframi części ułamkowej, a odpowiadającą jej wartość Jo(x) do pola o szero­kości 10 znaków z sześcioma cyframi części ułamkowej. Efekt wykonania programu obrazuje poniższy rysunek.

Dyrektywy #define bywają źródłem niełatwych do wykrycia błędów, dlatego najlepiej ich nie używać, jeśli nie jest to konieczne. To, że są one niebezpie­czne, można zobaczyć już na prostym przykładzie:

#define  A    7
#define  B    3
#define  ApB  A + B
...
cout << 4 * ApB << endl;

Zapewne część początkujących programistów zdziwi się, gdy na wyjściu ujrzy wynik 31 zamiast 40. Druga liczba pojawi­łaby się na wyjściu, gdyby trzecia dyrektywa miała postać

#define  ApB  (A + B)

W drugiej wersji programu tablicowania funkcji Bessela zamiast trady­cyjnych w C dyrektyw #define zleca­jących preproce­sorowi zastą­pienie wszystkich wystąpień nazwy symbo­licznej wskazanym ciągiem znaków użyjemy stałych definio­wanych za pomocą słowa kluczo­wego const. Ponadto zamiast standar­dowej w C biblio­teki stdio skorzy­stamy z biblioteki strumie­niowej iostream języka C++. Oto kompletny kod źródłowy tego programu:

#include <iostream>
#include <iomanip>
#include <cmath>
#include <conio.h>

using namespace std;

const int    N   = 20;
const double H   = 0.25;
const double EPS = 0.5e-8;

double Jo(double x)
{
    double w = 1.0,  s = w,  m4xx = -0.25 * x * x;
    for (int k = 1; fabs(w) > EPS * fabs(s); k++)
        s += (w *= m4xx / (k * k));
    return s;
}

int main()
{
    cout << "  x       Jo(x)\n-----------------\n";
    cout.setf(ios::fixed, ios::floatfield);
    cout.setf(ios::showpoint);
    for (int i = 0; i <= N; i++)
    {
        double x = i * H;
        cout << setw(5) << setprecision(2) << x << setw(11) << setprecision(6) << Jo(x) << endl;
    }
    cout << "-----------------\n";
    _getch();
    return 0;
}

Strumieniowe formatowanie liczb rzeczywistych może wydawać się nieco skompli­kowane. Na początku, jeszcze przed wyprowa­dzaniem liczb w pętli for, za pomocą funkcji składowej setf obiektu cout ustawiane są znaczniki (flagi) klasy ios przecho­wującej informacje o stanie formato­wania. Pierwsze wywołanie dwuargu­mentowej funkcji setf ustawia format stałopo­zycyjny (znacznik fixed) dla liczb rzeczy­wistych (znacznik floatfield). Drugie wywołanie nieco innej, bo jednoargu­mentowej funkcji setf, wymusza druko­wanie kropki dziesiętnej i zer końcowych (znacznik showpoint). Kompi­lator traktuje obie funkcje setf jako różne, które są jedynie tak samo nazwane, a decyzję, która ma być wywołana, podejmuje na podstawie liczby lub typu występu­jących w wywołaniu argu­mentów (przecią­żanie funkcji). Szerokość pola i liczba cyfr części ułamkowej są ustawiane za pomocą manipu­latorów setwsetprecision.

Program w MinGW C++

Kod źródłowy pierwszej wersji programu konsolowego tworzącego tablicę wartości funkcji Bessela i używającego biblio­teki standar­dowej stdio jest niemal taki sam jak kod jego odpowie­dnika w Borland C++. Nie ma jedynie potrzeby bloko­wania zamknięcia okna konsoli na końcu wyko­nania programu, możemy więc zrezy­gnować z biblioteki conio i funkcji _getch.

#include <stdio.h>
#include <math.h>>

#define  N    20
#define  H    0.25
#define  EPS  0.5e-8

double Jo(double x)
{
    double w = 1.0,  s = w,  m4xx = -0.25 * x * x;
    for (int k = 1; fabs(w) > EPS * fabs(s); k++)
        s += (w *= m4xx / (k * k));
    return s;
}

int main()
{
    printf("  x       Jo(x)\n-----------------\n");
    for (int i = 0; i <= N; i++)
    {
        double x = i * H;
        printf("%5.2lf %10.6lf\n", x, Jo(x));
    }
    printf("-----------------\n");
    return 0;
}

A oto druga wersja programu tablicowania funkcji Bessela dla kompila­tora MinGW C++ używa­jąca biblio­teki iostream zamiast stdio i przeciążonej funkcji abs z biblioteki matema­tycznej cmath:

#include <iostream>
#include <iomanip>
#include <cmath>

using namespace std;

const int    N   = 20;
const double H   = 0.25;
const double EPS = 0.5e-8;

double Jo(double x)
{
    double w = 1.0,  s = w,  m4xx = -0.25 * x * x;
    for (int k = 1; abs(w) > EPS * abs(s); k++)
        s += (w *= m4xx / (k * k));
    return s;
}

int main()
{
    cout << "  x       Jo(x)\n-----------------\n";
    cout.setf(ios::fixed, ios::floatfield);
    cout.setf(ios::showpoint);
    for (int i = 0; i <= N; i++)
    {
        double x = i * H;
        cout << setw(5) << setprecision(2) << x << setw(11) << setprecision(6) << Jo(x) << endl;
    }
    cout << "-----------------\n";
    return 0;
}

Program w Visual C++

Kod źródłowy pierwszej wersji programu konsolowego tworzącego tablicę wartości funkcji Bessela i używającego biblio­teki stdio mógłby być tożsamy z jego odpowie­dnikiem w MinGW C++, oczywiście pod warunkiem zrezy­gnacji z prekompi­lowanego nagłówka i plików stdafx.h, stdafx.cpptargetver.h (lub pch.hpch.cpp). Wielu progra­mistów woli, by defi­nicja funkcji main była pierwszą w programie, żywiąc przeko­nanie, że taka kolejność ulepsza przejrzy­stość programu. Zamieńmy zatem miejscami funkcje Jomain. Kompi­lacja programu po tej modyfi­kacji kończy się niepowo­dzeniem (rys.), w funkcji main pojawiła się bowiem nazwa Jo, która nie została wcześniej zadeklarowana.

Błąd eliminujemy, umieszczając prototyp funkcji Jo przed defi­nicją funkcji main, co prowadzi do następu­jącego kodu źródłowego programu:

#include <stdio.h>
#include <math.h>>

#define  N    20
#define  H    0.25
#define  EPS  0.5e-8

double Jo(double);

int main()
{
    printf("  x       Jo(x)\n-----------------\n");
    for (int i = 0; i <= N; i++)
    {
        double x = i * H;
        printf("%5.2lf %10.6lf\n", x, Jo(x));
    }
    printf("-----------------\n");
    return 0;
}

double Jo(double x)
{
    double w = 1.0,  s = w,  m4xx = -0.25 * x * x;
    for (int k = 1; fabs(w) > EPS * fabs(s); k++)
        s += (w *= m4xx / (k * k));
    return s;
}

Druga wersja programu konsolowego tworzącego tablicę wartości funkcji Bessela używa­jącego biblio­teki strumie­niowej iostream i przeciążonej funkcji abs z biblioteki matema­tycznej cmath może wyglądać następująco:.

#include <iostream>
#include <iomanip>
#include <cmath>

using namespace std;

const int    N   = 20;
const double H   = 0.25;
const double EPS = 0.5e-8;

double Jo(double);

int main()
{
    cout << "  x       Jo(x)\n-----------------\n";
    cout.setf(ios::fixed, ios::floatfield);
    cout.setf(ios::showpoint);
    for (int i = 0; i <= N; i++)
    {
        double x = i * H;
        cout << setw(5) << setprecision(2) << x << setw(11) << setprecision(6) << Jo(x) << endl;
    }
    cout << "-----------------\n";
    return 0;
}

double Jo(double x)
{
    double w = 1.0,  s = w,  m4xx = -0.25 * x * x;
    for (int k = 1; abs(w) > EPS * abs(s); k++)
        s += (w *= m4xx / (k * k));
    return s;
}

Opracowanie przykładu: lipiec 2018