laboratorium4

Klasy i obiekty

View the Project on GitHub

Klasy

Zalążek programu

Zaczynamy od Makefile. Bardzo ważne jest to, żeby wybrać konkretny standard (-std=c++0x) i nie budować jedynie jednego pliku .cpp, a wszystkie znajdujące się w katalogu roboczym (*.cpp).

Najważniejsza uwaga na świecie: budujemy jedynie pliki .cpp. Jeśli zaczniemy budować pliki .h, świat się zawali. I będzie smutno.

all:
	g++ -std=c++0x *.cpp -o fraction
	./fraction 10

Tworzymy też prosty, początkowy plik main.cpp, który będzie zawierał jedynie kod samego programu. Musi on zachować czytelność i nie powinien zajmować się żadną implementacją ponad realizację zadania.

#include <cstdio>
#include <cstdlib>
#include <ctime>

int main(int argc, char** argv)
{
  srand(time(NULL));
  int n = atoi(argv[1]);
  printf("Wartość n wynosi %i\n", n);
}

Na początek, przekazując do funkcji srand() aktualny czas, zapewniamy sobie pseudolosować. W następnej kolejności pobieramy pierwszy argument wiersza poleceń (argv[1]), wpisujemy go jako liczbę całkowitą (atoi()) do zmiennej n i wyświetlamy na ekranie.

Klasa Fraction

Klasy, w przeciwieństwie do innych typów, nazywamy zawsze od wielkiej litery. Poza tym, pamiętamy, że nazewnictwo zawsze, ale to absolutnie zawsze, powinno być w języku angielskim. Stąd naszą klasę obsługującą ułamek nazwiemy Fraction.

Dla zachowania kultury kodu, tworząc klasy w języku C++, zawsze dzielimy ich opis na dwa pliki:

W związku z tym tworzymy w projekcie pliki Fraction.h oraz Fraction.cpp.

Wszystkie pliki nagłówkowe zawsze rozpoczynamy od dyrektywy preprocesora #pragma once. Dzięki temu, w wygodny sposób unikniemy powtórnych definicji symboli.

#pragma once

Plik z implementacją klasy rozpoczynamy od zaimportowania jej pliku nagłówkowego. Pamiętajmy, że globalne biblioteki importujemy używając nawiasów trójkątnych (<>), a lokalne pliki nagłówkowe, używając cudzysłowu.

#import "Fraction.h"

Implementację klasy rozpoczynamy od jej definicji. Aby to uczynić, w interfejsie klasy (plik nagłówkowy) deklarujemy klasę Fraction.

class Fraction {

};

Ważnym detalem, który łatwo przeoczyć jest średnik na końcu deklaracji.

Klasa jest wzorcem typu, którego zmienne nazywamy obiektami. Jeśli chcemy móc używać go w naszym programie, musimy zaimportować do niej plik nagłówkowy klasy. W związku z tym, zmodyfikujmy nasz plik main.cpp, tworząc w nim nowy obiekt właśnie zadeklarowanego typu.

#include <cstdio>
#include <cstdlib>
#include <ctime>

#include "Fraction.h"

int main(int argc, char** argv)
{
  srand(time(NULL));
  int n = atoi(argv[1]);
  printf("Wartość n wynosi %i\n", n);

  Fraction foo;
}

Atrybuty i akcesory

Na tę chwilę nasza klasa nie robi niczego poza tym, że istnieje. Dodajmy więc do niej dwa atrybuty. Licznik i mianownik.

class Fraction {
  int nominator, denominator;
};

Spróbujmy skorzystać z nich tak samo, jak korzystaliśmy z atrybutów struktury.

Fraction foo;
foo.nominator = 1;
foo.denominator = 2;

Przy próbie skompilowania takiego kodu, kompilator zwróci następujący błąd:

main.cpp:14:7: error: 'nominator' is a private member of 'Fraction'
  foo.nominator = 1;
      ^
./Fraction.h:4:7: note: implicitly declared private here
  int nominator, denominator;
      ^
main.cpp:15:7: error: 'denominator' is a private member of 'Fraction'
  foo.denominator = 2;
      ^
./Fraction.h:4:18: note: implicitly declared private here
  int nominator, denominator;
                 ^
2 errors generated.
make: *** [all] Error 1

Burząc się na nas o taką konstrukcję ma absolutną rację, ponieważ domyślnie, wszystkie zdefiniowane w klasie elementy (zmienne i funkcje) są prywatne, co oznacza, że dostępne jedynie wewnątrz każdego obiektu. Widocznością elementów możemy sterować używając słów kluczowych public, private oraz protected. To trzecie zostawmy sobie na później, a w tej chwili podzielmy interfejs klasy na dwie sekcje. prywatną oraz publiczną.

class Fraction {
private:

public:
  int nominator, denominator;
};

Sekcja prywatna jest pusta, podczas gdy publiczna zawiera obie nasze zmienne. Dzięki temu kod nareszcie się kompiluje.

Nie mamy jednak powodów do radości, ponieważ tak przygotowany kod jest raczej durny i pozwala na dowolną modyfikację wszystkich pól obiektu w każdym miejscu programu. Dlatego też:

Druga najwaniejsza uwaga na świecie: zmienne zawsze deklarujemy w prywatnej sekcji klasy.

class Fraction {
private:
  int nominator, denominator;
public:

};

No dobrze. Ale teraz wróciliśmy do punktu wyjścia i kod znów się nie kompiluje. Musimy więc znaleźć sposób na dostęp do prywatnych pól klasy. Aby to osiągnąć, korzystamy z tak zwanych akcesorów. Są to specjalne metody (funkcje obiektu), które pozwalają na odczytywanie (gettery) i zapisywanie (settery) wartości w zmiennych.

Przykładowo:

public:
  int getNominator()
  {
    return nominator;
  }
  int getDenominator()
  {
    return denominator;
  }

  void setNominator(int nominator)
  {
    this -> nominator = nominator;
  }
  void setDenominator(int denominator)
  {
    this -> denominator = denominator;
  }

Do sekcji publicznej klasy dodaliśmy po dwie metody dla każdego atrybutu.

Spróbujmy z nich poprawnie skorzystać, zmieniając plik main.cpp:

Fraction foo;
foo.setNominator(1);
foo.setDenominator(2);

printf("%i/%i\n", foo.getNominator(), foo.getDenominator());

Gratulacje. Udało nam się dokonać enkapsulacji danych.

Rodzielenie interfejsu od implementacji

Nasza klasa zyskała nareszcie metody. Jakkolwiek, plik interfesu został przez to pokalany implementacją. Nie możemy sobie na to pozwolić. Pozostawmy więc w nim jedynie prototypy metod.

public:
  int getNominator();
  int getDenominator();

  void setNominator(int nominator);
  void setDenominator(int denominator);

A całą implementację przenieśmy do pliku Fraction.cpp.

#import "Fraction.h"

int getNominator()
{
  return nominator;
}

int getDenominator()
{
  return denominator;
}

void setNominator(int nominator)
{
  this -> nominator = nominator;
}

void setDenominator(int denominator)
{
  this -> denominator = denominator;
}

Kod znów przestał się budować. Wszystko to dlatego, że nazwanie pliku z implementacją tak samo jak klasy (Fraction.cpp) i zaimportowanie w nim pliku nagłówkowego klasy, są jedynie konwencją, która ma ułatwić nam zachowanie czytelności kodu. Implementując jakąkolwiek metodę poza interfejsem klasy, nadal, musimy bezpośrednio wskazać, którą klasę zamierzamy implementować. Aby to uczynić, do musimy odrobinę zmodyfikować implementację naszych metod. Przykładowo:

int Fraction::getNominator()
{
  return nominator;
}

Dzięki przedrostkowi Fraction:: dajemy znać kompilatorowi, że funkcja getNominator() nie jest dowolną, leżącą luzem funkcją, a właśnie metodą klasy Fraction.

Konstruktor

Każda klasa, nawet w ukryciu przed nami, na początku istnienia każdego obiektu, wywołuje funkcję, która ma za zadanie wypełnić go wstępnie danymi, lub zaalokować pamięc na zmienne przechowywane dynamicznie. W wielu językach taka metoda nazywa się inicjalizatorem. W wypadku C++, nazywamy ją konstruktorem.

Konstruktor jest metodą, która nie posiada typu, nazywa się identycznie jak klasa i powinna być zadeklarowana w sekcji publicznej klasy.

public:
  Fraction();

Podczas jej implementacji, mamy do dyspozycji wszystkie pola zdefiniowane w klasie. Sprawmy więc, że domyślny nowy obiekt, będzie ułamkiem 1/1.

Fraction::Fraction(){
  nominator = 1;
  denominator = 1;
}

Zmodyfikujmy też kod programu tak, aby nie ustawiać ręcznie parametrów obiektu.

Fraction foo;
printf("%i/%i\n", foo.getNominator(), foo.getDenominator());

Jeśli wszystko poszło dobrze, na ekranie wyświetli się:

1/1

W poprzednim laboratorium tworzyliśmy losowe ułamki, testując ich poprawność. W związku z tym, skorzystajmy z kodu, który już mamy i uzupełnijmy naszą klasę o dodatkową metodę is_correct().

public:
  Fraction();
  int getNominator();
  int getDenominator();

  void setNominator(int nominator);
  void setDenominator(int denominator);

  bool is_correct();

Nie zapomnijmy też o implementacji.

bool Fraction::is_correct()
{
  return (denominator != 0) && (abs(nominator) < abs(denominator));
}

Kod znów przestał się budować, komunikując o błędzie:

Fraction.cpp:30:50: error: use of undeclared identifier 'abs'

Dzieje się tak dlatego, że korzystamy z funkcji abs(), która znajduje się w bibliotece standardowej. Mimo, że importujemy ją w pliku main.cpp, plik Fraction.cpp nie ma pojęcia o jej istnieniu. Przypomnijmy mu więc o tym.

#include <cstdlib>

W pliku z implementacją klasy, dodajmy sobie także funkcję pomocniczną, która będzie potrafiła losować wartości całkowite z zadanego zakresu.

int random_in_range(int from = -9, int to = 9)
{
  return from + rand() % (to - from);
}

Zauważmy, że funkcja ta nie jest metodą klasy. Nie musimy uwzględniać jej w interfejsie i nie używamy na niej przedrostka klasy. Jest jednak zadeklarowana w pliku z implementacją klasy, więc wszystkie jej metody będą mogły z niej swobodnie korzystać. Wykorzystajmy ją przy losowaniu wartości ułamka, w konstruktorze.

Fraction::Fraction()
{
  do {
    nominator = random_in_range();
    denominator = random_in_range();
  } while(!is_correct());
}

Zadanie

Uzupełnij program o skracanie ułamków, a w głównej funkcji programu, podobnie jak ostatnio, zamiast jednej zmiennej, stwórz jednowymiarową tablicę obiektów, o rozmiarze zadanym przez parametr n.