Podstawy programowania 3

Zmienne

Deklarowanie zmiennych

Absolutnie kluczową funkcją każdego języka programowania jest możliwość tworzenia zmiennych, a więc możliwość przechowywania danych w pamięci komputera i zmieniania tych danych w razie potrzeby.

Deklaracja zmiennej w języku C++ wygląda następująco:

modyfikator typZmiennej nazwaZmiennej;

Po takiej deklaracji kompilator rezerwuje odpowiednią ilość pamięci i pozwala odwoływać się do tego jej fragmentu poprzez nazwę. Od miejsca deklaracji do końca zasięgu można już używać naszej nowej zmiennej.

Modyfikator jest opcjonalny i najczęściej nie występuje.

Najważniejsze typy wbudowane

Typy stałoprzecinkowe

Podstawowym typem zmiennej w każdym języku jest liczba całkowita. Wynika to stąd, że potrzebne są one nie tylko do obliczeń, ale przede wszystkim jako indeksy tablic itp. W C++ podstawowe typy całkowitoliczbowe to:

Nazwa typu Długość
(bajty)
signed char 1
unsigned char 1
char 1
short int 2
unsigned short int 2
int 4
unsigned int 4
long int 4
unsigned long int 4
long long int 8
unsigned long long int 8

Standard C++ nie określa wielkości żadnego z tych typów, a jedynie, że typy dłuższe nie mogą być krótsze niż typy krótsze. Jest to spowodowane faktem, że kompilatory C++ pracują na różnych platformach – od superkomputerów do systemów wbudowanych, a więc na komputerach o rożnych możliwościach. Rozmiary typów w powyższej tabelce to najczęściej spotykane wartości na 32-bitowych komputerach osobistych. Wszystkie typy poza char są w standardzie języka domyślnie ze znakiem, jedynie dla typu char standard tego nie określa. W praktyce najczęściej char to również liczba ze znakiem.

Zadanie. Przeczytać i uruchomić na komputerze poniższy program. Przyjrzeć się otrzymanym wynikom, czyli zobaczyć jak duże liczby mogą być przechowywane w poszczególnych typach.

Do czego służy + w wyrażeniach rodzaju +numeric_limits<signed char>::min()?

#include <iostream>
#include <limits>

using namespace std;

int main() {
  cout << "Typ signed char ma długość "
       << sizeof(signed char)
       << " bajtów i może przechowywać liczby od "
       << +numeric_limits<signed char>::min()
       << " do "
       << +numeric_limits<signed char>::max()
       << "." << endl;
  cout << "Typ unsigned char ma długość "
       << sizeof(unsigned char)
       << " bajtów i może przechowywać liczby od "
       << +numeric_limits<unsigned char>::min()
       << " do "
       << +numeric_limits<unsigned char>::max()
       << "." << endl;
  cout << "Typ char ma długość "
       << sizeof(char)
       << " bajtów i może przechowywać liczby od "
       << +numeric_limits<char>::min()
       << " do "
       << +numeric_limits<char>::max()
       << "." << endl;
  cout << "Typ short int ma długość "
       << sizeof(short int)
       << " bajtów i może przechowywać liczby od "
       << numeric_limits<short int>::min()
       << " do "
       << numeric_limits<short int>::max()
       << "." << endl;
  cout << "Typ unsigned short int ma długość "
       << sizeof(unsigned short int)
       << " bajtów i może przechowywać liczby od "
       << numeric_limits<unsigned short int>::min()
       << " do "
       << numeric_limits<unsigned short int>::max()
       << "." << endl;
  cout << "Typ int ma długość "
       << sizeof(int)
       << " bajtów i może przechowywać liczby od "
       << numeric_limits<int>::min()
       << " do "
       << numeric_limits<int>::max() << "." << endl;
  cout << "Typ unsigned int ma długość "
       << sizeof(unsigned int)
       << " bajtów i może przechowywać liczby od "
       << numeric_limits<unsigned int>::min()
       << " do "
       << numeric_limits<unsigned int>::max()
       << "." << endl;
  cout << "Typ long int ma długość "
       << sizeof(long int)
       << " bajtów i może przechowywać liczby od "
       << numeric_limits<long int>::min()
       << " do "
       << numeric_limits<long int>::max()
       << "." << endl;
  cout << "Typ unsigned long int ma długość "
       << sizeof(unsigned long int)
       << " bajtów i może przechowywać liczby od "
       << numeric_limits<unsigned long int>::min()
       << " do "
       << numeric_limits<unsigned long int>::max()
       << "." << endl;
  cout << "Typ long long int ma długość "
       << sizeof(long long int)
       << " bajtów i może przechowywać liczby od "
       << numeric_limits<long long int>::min()
       << " do "
       << numeric_limits<long long int>::max()
       << "." << endl;
  cout << "Typ unsigned long long int ma długość "
       << sizeof(unsigned long long int)
       << " bajtów i może przechowywać liczby od "
       << numeric_limits<unsigned long long int>::min()
       << " do "
       << numeric_limits<unsigned long long int>::max()
       << "." << endl;
  return 0;
}

Zakres liczb przechowywanych przez zmienną jest bardzo istotny. Wynik działania, którego wynik nie mieści się w założonym typie danych (np. pomnożenie dwóch zmiennych int przez siebie) zależy od typu danych. Typy unsigned po prostu się zawijają, podczas gdy przepełnienie typu signed powoduje niezdefiniowane zachowanie (standard nie określa co powinno się stać, może stać się cokolwiek). Przepełnienia mogą powodować bardzo trudne do wykrycia błędy. Jest to szczególnie istotne, gdy zmienna nie służy do prowadzenia obliczeń. Takie błędy często prowadzą do poważnych luk w bezpieczeństwie programów i systemów.

Typy zmiennoprzecinkowe

Liczby zmiennoprzecinkowe pozwalają w komputerach na prowadzenie obliczeń inżynieryjnych i numerycznych. Liczby zmiennoprzecinkowe zapewniają tylko określoną precyzję, więc nie da się np. zapamiętać dokładnie liczby π, dostaniemy tylko przybliżenie. Standard C++ określa następujące typy zmiennoprzecinkowe:

Nazwa typu Długość
(bajty)
float 4
double 8
long double 10

Zadanie. Przeczytać i uruchomić na komputerze poniższy program. Przyjrzeć się otrzymanym wynikom, obejrzeć możliwości typów zmiennoprzecinkowych.

#include <iostream>
#include <limits>

using namespace std;

int main() {
  cout << "Typ float ma długość "
       << sizeof(float)
       << " bajtów i może przechowywać liczby od "
       << numeric_limits<float>::min()
       << " do "
       << numeric_limits<float>::max()
       << "." << endl;
  cout << "Typ double ma długość "
       << sizeof(unsigned char)
       << " bajtów i może przechowywać liczby od "
       << numeric_limits<double>::min()
       << " do "
       << numeric_limits<double>::max()
       << "." << endl;
  cout << "Typ long double ma długość "
       << sizeof(long double)
       << " bajtów i może przechowywać liczby od "
       << numeric_limits<long double>::min()
       << " do "
       << numeric_limits<long double>::max()
       << "." << endl;
  return 0;
}

Wyniki obliczeń używających typów zmiennoprzecinkowych mogą być czasem dość zaskakujące.

Zadanie. Poniższy program liczy sumę odwrotności sześcianów liczb od 1 do 100000000. Suma jest liczona najpierw zaczynając od liczb największych, potem od najmniejszych. Aby lepiej zaobserwować problem używamy typu float. Proszę poniższy program skompilować i uruchomić.

#include <iostream>

using namespace std;

int main() {
  float suma = 0.0;
  for (int i = 1; i <= 100000000; ++i) {
    suma += 1.0/i;
  }
  cout << suma << endl;
  suma = 0.0;
  for (int i = 100000000; i >= 1; --i) {
    suma += 1.0/i;
  }
  cout << suma << endl;
  return 0;
}

Przyczyną rozbieżności jest niedokładność typów zmiennoprzecinkowych (operujemy tylko na przybliżeniach). Nie będziemy się na tym przedmiocie zajmować tym zagadnieniem dokładniej, w wypadku problemów proszę jednak pamiętać, że winna może być także arytmetyka zmiennoprzecinkowa.

Typ logiczny

Zmienne typu logicznego przechowują informację prawda/fałsz, do wykorzystania w instrukcjach pętli i instrukcjach warunkowych.

Typ logiczny w C++ to bool. Zmienne przyjmują wartości true (prawda) lub false (fałsz). Wartości zmiennych logicznych najczęściej są wynikami operatorów relacyjnych.

Kolekcje obiektów

Czasami przychodzi potrzeba posiadania nie jednej zmiennej, ale całej ich rodziny. Tu z pomocą przychodzą tablice i tzw. klasy kontenerowe. Tablice pochodzą jeszcze z języka C, klasy kontenerowe są zawarte w bibliotece standardowej języka C++. Biblioteka standardowa języka C++ oparta jest o szablony, którymi na razie nie będziemy się zajmować, jako że jest to temat znacznie bardziej zaawansowany. Mimo to dwa narzędzia są na tyle przydatne, że ciężko byłoby się nam bez nich obyć nawet na poziomie podstawowym (omówimy std::vector i std::string).

Tablice

Tablica to grupa zmiennych zapisanych tego samego typu zapisanych w ciągłym obszarze pamięci pod jedną nazwą, indeksowaną liczbami naturalnymi. Poza tym nie różnią się niczym od normalnych zmiennych. Jest to bardzo przydatne przy przetwarzaniu większych ilości danych (stanie się to jasne, gdy poznamy pętle). Największą wadą tablic jest to, że nie można zmienić ich rozmiaru. Nawet więcej, nie można w zgodzie ze standardem zadeklarować tablicy o rozmiarze nieznanym na etapie kompilacji. Później poznamy operatory new i delete, które na to pozwalają. Najłatwiej poznać tablice w działaniu:

Zadanie. Czytając poniższy program, poznać podstawy działania tablic.

#include <iostream>

using namespace std;

int main() {
  int t[10] = {0, 11, 22, 333, 4444, 55555, 666666,
               7777777, 88888888, 999999999};
             //tablica dziesięciu intów
             //t[0], t[1], ..., t[9]
  cout << t[3] << endl;
  t[3] = 0;
  cout << t[3] << endl;
  t[1] = t[2] + t[4]*t[5];
  cout << t[1] << endl;
  cout << t[1000] << endl;
             // Jest to błędne wywołanie, tablica nie zawiera
             // takiego elementu.
             // Niemniej kompilator na nie pozwala,
             // indeksy tablicy nie są sprawdzane,
             // ani na etapie kompilacji
             // ani w czasie wykonywania programu.
  return 0;
}

Poniższy program nie jest zgodny ze standardem C++, choć g++ go skompiluje (jest to niestandardowe rozszerzenie języka przez kompilator):

#include <iostream>

using namespace std;

int main() {
  unsigned int size;
  cin >> size;
  int t[size];
  return 0;
}

Wektory

Klasa std::vector to podstawowy kontener (lub pojemnik, po angielsku container) biblioteki standardowej C++. Udostępnia zmiennej wielkości tablicę obiektów ustalonego typu – a więc taką do której można dodawać obiekty lub je usuwać. Podobnie jak std::string samodzielnie zarządza pamięcią. Proszę spojrzeć na poniższy przykład:

#include <iostream>
#include <vector>

using namespace std;

//Poniżej znajduje się szablon specjalnej funkcji.
//Dzięki niej możemy niżej pisać cout << v;
//Na tym etapie nie trzeba tego rozumieć...
template<typename T>
ostream& operator<<(ostream& os, const vector<T>& v) {
  os << "[";
  if (!v.empty()) {
    typename vector<T>::const_iterator i = v.begin();
    while (true) {
      os << *i;
      ++i;
      if (i == v.end()) {
        break;
      }
      os << ", ";
    }
  }
  os << "]";
  return os;
}

int main() {
  vector<int> v;
  v.push_back(4);
  v.push_back(11);
  v.push_back(-8);
  cout << v[0] << endl;
  cout << v.at(1) << endl;

  v.push_back(16);
  v.push_back(46);
  v.push_back(45);
  cout << v << endl;


  v.pop_back();
  cout << v << endl;

  //Poniżej używamy tzw. iteratora.
  //Niestety jest to jedyny sposób
  //usunięcia danych ze środka wektora.
  //Oczywiście nie trzeba usuwać danych przed
  //końcem programu, poza przypadkiem
  //gdy boimy się braku pamięci operacyjnej.
  v.erase(v.begin()+1);
  cout << v << endl;
  return 0;
}

Łańcuchy znaków

Język C++ dla zgodności wstecz z językiem C przechowuje łańcuchy znaków (napisy) w postaci ciągu pojedynczych znaków (char) zakończonych bajtem zerowym. Rozwiązanie to jest co prawda kompaktowe, a biblioteka języka C zawiera wiele funkcji pozwalających na operowanie takimi łańcuchami, jednak w praktyce często jest to źródłem błędów i luk w bezpieczeństwie pisanych programów (tzw. buffer overflow bierze się często właśnie z niewłaściwego używania takich łańcuchów i próby przechowania większej ilości danych niż jest zarezerwowanego miejsca w pamięci).

Dlatego twórcy języka C++ postanowili dodać do biblioteki nowy typ specjalnie zaprojektowany dla bezpiecznej obsługi napisów. Typ ten to klasa std::string zdefiniowana w nagłówku string. Obiekty tej klasy zarządzają automatycznie pamięcią, więc programista nie musi się o to martwić. Kosztem tej automatyki jest niższa wydajność i zajmowanie troszkę większej pamięci (nazywamy to narzutem), dlatego jest to rozwiązanie opcjonalne a nie domyślne – domyślne są łańcuchy z C. W praktyce jednak nie należy się tym przejmować. Poza bardzo szczególnymi zastosowaniami zalety używania std::string znacznie przewyższają ewentualne koszty.

Nie należy przesadnie przejmować się wydajnością na etapie pisania kodu. W szczególności, jeżeli szybsze rozwiązanie prowadzi także łatwiej do problemów i błędów wydłużając czas pisania aplikacji. Optymalizacją kodu zajmujemy się, jeżeli są problemy z wydajnością już napisanego programu. Nawet doświadczeni programiści mają czasem problemy ze wskazaniem problematycznych miejsc „na oko”. Do ich znajdowania służą narzędzia do tzw. profilowania kodu.

Używanie std::string jest w miarę intuicyjne, ale ciężko wypisać tutaj wszystkie możliwości. W tym celu należy korzystać z dokumentacji używanej biblioteki (np. tutaj).

Klasy to specjalne typy przechowujące razem dane i funkcje na tych danych operujące. Są podstawą tzw. obiektowego paradygmatu programowania – najczęściej spotykanego dzisiaj w praktyce. Obiekty to po prostu zmienne o typie będącym klasą.

Standardowym sposobem zapisu łańcucha znakowego w C++ jest użycie podwójnych cudzysłowów:

"to jest łańcuch znaków"

Niestety, jak napisano wyżej z powodów historycznych, typem takiego napisu nie jest std::string. Odpowiedni obiekt trzeba dopiero na podstawie takiego napisu skonstruować. Z reguły nie jest to problemem, ale nie należy o tym zapominać, gdyż czasem rodzi to problemy.

Następny standard C++14 języka C++ będzie pozwalał napisać "to jest obiekt klasy std::string"s. Składnia taka wspierana jest już przez większość bardzo nowych kompilatorów.

Zadanie. Przeczytać poniższy kod i zapoznać się z podstawowymi operacjami na łańcuchach znaków poprzez uruchomienie programu.

#include <iostream>
#include <string>

using namespace std;

int main() {
  string s1 = "Początek napisu";
  string s2("koniec napisu");
  string s3 = s1 + s2;
  cout << s3 << endl;
  s3 = s1 + ", a teraz " + s2;
  cout << s3 << endl;
  cout << "A teraz pierwszy znak zmiennej s1: " << s1[0] << endl;
    // Elementy numerujemy od zera...
  string s4 = "1234567890";
  cout << "Długość zmiennej s4: " << s4.length() << endl;
  cout << "Ile bajtów można przechować w s4"
          " bez przydziału nowej pamięci: " << s4.capacity() << endl;
  cout << "Jak maksymalnie długi może być łańcuch s4: "
       << s4.max_size() << endl;
  cout << "Napisz coś: ";
  cin >> s1;
  cout << "Napisałeś: " << endl;
  return 0;
}

Jeżeli chcemy zapisać jeden znak, to należy skorzystać z typu char wspomnianego w typach stałoprzecinkowych. W kodzie jeden znak zapisujemy za pomocą apostrofów:

'a'

Zadanie. Uruchomić poniższy kod i w kontekście tego co napisano tutaj o łańcuchach spróbować wytłumaczyć wyniki. Jak można się domyślić operator sizeof wyświetla rozmiar obiektu w bajtach.

#include <iostream>
#include <string>

using namespace std;

int main() {
  string a("a");
  cout << "sizeof(\"a\"): " << sizeof("a") << endl;
  cout << "sizeof(\'a\'): " << sizeof('a') << endl;
  cout << "sizeof(a): "     << sizeof(a)   << endl;
  return 0;
}