Następne kroki

Wprowadzenie do programowania i C++

Ten samouczek online stanowi kontynuację bardziej zaawansowanych zagadnień – zapoznaj się z częścią III. W tym module skupimy się na używaniu wskaźników i rozpoczynaniu pracy z obiektami.

Wykorzystaj przykład nr 2

W tym module skoncentrujemy się na nauce z zakresu rozkładu, analizowania wskaźników oraz rozpoczynania pracy z obiektami i klasami. Przeanalizuj podane niżej przykłady. Napisz o programach samodzielnie lub przeprowadź eksperymenty. Nie możemy dostatecznie podkreślić, że podstawą bycia dobrym programistą jest praktyka, ćwiczenie, praktyka.

Przykład 1. Więcej metod rozkładu

Weź pod uwagę te dane wyjściowe z prostej gry:

Welcome to Artillery.
You are in the middle of a war and being charged by thousands of enemies.
You have one cannon, which you can shoot at any angle.
You only have 10 cannonballs for this target..
Let's begin...

The enemy is 507 feet away!!!
What angle? 25<
You over shot by 445
What angle? 15
You over shot by 114
What angle? 10
You under shot by 82
What angle? 12
You under shot by 2
What angle? 12.01
You hit him!!!
It took you 4 shots.
You have killed 1 enemy.
I see another one, are you ready? (Y/N) n

You killed 1 of the enemy.

Pierwsza obserwacja to tekst wprowadzający, który wyświetla się raz na wykonanie programu. Aby określić odległość wroga w każdej rundzie, potrzebujemy generatora liczb losowych. Potrzebujemy mechanizmu pobierania z odtwarzacza danych o kątie. odbywa się to oczywiście w pętli, ponieważ powtarza się, dopóki nie uderzymy wroga. Potrzebujemy też funkcji do obliczania odległości i kąta. Na koniec musimy wiedzieć, ile strzałów zajęło wroga i ilu zabiliśmy wrogów w trakcie realizacji programu. Oto możliwy schemat programu głównego.

StartUp(); // This displays the introductory script.
killed = 0;
do {
  killed = Fire(); // Fire() contains the main loop of each round.
  cout << "I see another one, care to shoot again? (Y/N) " << endl;
  cin >> done;
} while (done != 'n');
cout << "You killed " << killed << " of the enemy." << endl;

Procedura pożaru zajmuje się rozgrywką. W tej funkcji wywołujemy generator liczb losowych, by określić odległość wroga, a następnie konfigurujemy pętlę tak, by uzyskać dane wejściowe gracza i obliczyć, czy trafił on wroga. Warunek obrony w pętli określa, jak blisko udało nam się uderzyć wroga.

In case you are a little rusty on physics, here are the calculations:

Velocity = 200.0; // initial velocity of 200 ft/sec Gravity = 32.2; // gravity for distance calculation // in_angle is the angle the player has entered, converted to radians. time_in_air = (2.0 * Velocity * sin(in_angle)) / Gravity; distance = round((Velocity * cos(in_angle)) * time_in_air);

Ze względu na wywołania cos() i sin() musisz dodać funkcję matematykę.h. Spróbuj napisać ten program – to dobra praktyka w wykonywaniu zadań i wstępnym przeglądzie podstawowej funkcji C++. Pamiętaj, aby dla każdej funkcji wykonać tylko jedno zadanie. Jest to najbardziej zaawansowany program, jaki napisaliśmy do tej pory, więc jego wykonanie może zająć trochę czasu.Tutaj znajdziesz nasze rozwiązanie. 

Przykład 2. Ćwiczenie ze wskaźnikami

Podczas pracy ze wskaźnikami musisz pamiętać o 4 rzeczach:
  1. Wskaźniki to zmienne, które przechowują adresy pamięci. W trakcie wykonywania programu wszystkie zmienne są przechowywane w pamięci, z unikalnym adresem lub miejscem. Wskaźnik to specjalny typ zmiennej, która zawiera adres pamięci, a nie wartość danych. Tak jak w przypadku używania zwykłej zmiennej dane są modyfikowane, tak samo jak wartość adresu zapisanego w wskaźniku jest modyfikowana w wyniku manipulacji zmienną wskaźnika. Oto przykład:
    int *intptr; // Declare a pointer that holds the address
                 // of a memory location that can store an integer.
                 // Note the use of * to indicate this is a pointer variable.
    
    intptr = new int; // Allocate memory for the integer.
    *intptr = 5; // Store 5 in the memory address stored in intptr.
          
  2. Zwykle mówimy, że wskaźnik „wskazuje” lokalizację, w której jest przechowywana („wskazana osoba”). Zatem w przykładzie powyżej intptr wskazuje wskazówkę 5.

    Zwróć uwagę na użycie operatora „new” do przydzielania pamięci dla wskaźnika liczby całkowitej. Musimy to zrobić przed próbą uzyskania dostępu do punktu.

    int *ptr; // Declare integer pointer.
    ptr = new int; // Allocate some memory for the integer.
    *ptr = 5; // Dereference to initialize the pointee.
    *ptr = *ptr + 1; // We are dereferencing ptr in order
                     // to add one to the value stored
                     // at the ptr address.
          

    Operator * służy do usuwania odniesień w komórce C. Jednym z najczęstszych błędów popełnianych przez programistów C/C++ podczas pracy ze wskaźnikami jest zapomnienie zainicjowania punktu. Może to czasami powodować awarię środowiska wykonawczego, ponieważ uzyskujemy dostęp do lokalizacji w pamięci zawierającej nieznane dane. Zmodyfikowanie tych danych może spowodować niewielkie uszkodzenie pamięci, co utrudnia ich wykrycie. 

  3. Przypisanie wskaźnika między dwoma wskaźnikami powoduje, że wskazują one na ten sam punkt. Przypisanie y = x oznacza, że wskazuje na ten sam punkt y co x. Przypisanie wskaźnika nie dotyczy punktu. Zmienia się tylko jeden wskaźnik, aby znajdował się w tej samej lokalizacji co inny wskaźnik. Po przypisaniu wskaźnika oba punkty „udostępniają” je. 
  4. void main() {
     int* x; // Allocate the pointers x and y
     int* y; // (but not the pointees).
    
     x = new int; // Allocate an int pointee and set x to point to it.
    
     *x = 42; // Dereference x and store 42 in its pointee
    
     *y = 13; // CRASH -- y does not have a pointee yet
    
     y = x; // Pointer assignment sets y to point to x's pointee
    
     *y = 13; // Dereference y to store 13 in its (shared) pointee
    }
      

Oto ślad tego kodu:

1. Przydziel dwa wskaźniki x i y. Przydzielenie wskaźników nie rozdziela żadnych punktów.
2. Przydziel punkt i ustaw x tak, aby go wskazywał.
3. Odniesienie x do zapisu 42 w jego punkcie. To jest podstawowy przykład operacji dereference. Zacznij od x, a potem podążaj za strzałką, aby przejść do punktu wyjścia.
4. Spróbuj przywrócić 13 w wskaźniku. Awaria, ponieważ nie masz wskaźnika, ponieważ nigdy nie został on przypisany.
5. Przypisz y = x tak, aby y kierował do punktu x. Teraz X i Y wskazują ten sam punkt – są to „udostępnianie”.
6. Spróbuj przywrócić 13 w wskaźniku. Tym razem się sprawdza, bo poprzednie przypisanie dało Ci wskazówkę.

Jak widać, obrazy są bardzo pomocne w zrozumieniu użycia wskaźników. Oto kolejny przykład.

int my_int = 46; // Declare a normal integer variable.
                 // Set it to equal 46.

// Declare a pointer and make it point to the variable my_int
// by using the address-of operator.
int *my_pointer = &my_int;

cout << my_int << endl; // Displays 46.

*my_pointer = 107; // Derefence and modify the variable.

cout << my_int << endl; // Displays 107.
cout << *my_pointer << endl; // Also 107.

Zwróć uwagę, że w tym przykładzie nigdy nie przydzieliliśmy pamięci operatorowi „new”. Zadeklarowaliśmy zwykłą zmienną liczby całkowitej i zmodyfikowaliśmy ją za pomocą wskaźników.

W tym przykładzie pokazujemy użycie operatora usuwania, który eliminuje pamięć sterty, oraz sposób przydzielania do bardziej złożonych struktur. Na innej lekcji omówimy porządkowanie pamięci (stos i środowisko wykonawcze). Na razie potraktuj stertę jako wolny magazyn pamięci dostępnej dla uruchomionych programów.

int *ptr1; // Declare a pointer to int.
ptr1 = new int; // Reserve storage and point to it.

float *ptr2 = new float; // Do it all in one statement.

delete ptr1; // Free the storage.
delete ptr2;

W ostatnim przykładzie pokazujemy, jak wskaźniki są używane do przekazywania wartości do funkcji przez odwołanie. W ten sposób modyfikujemy wartości zmiennych w funkcji.

// Passing parameters by reference.
#include <iostream>
using namespace std;

void Duplicate(int& a, int& b, int& c) {
  a *= 2;
  b *= 2;
  c *= 2;
}

int main() {
  int x = 1, y = 3, z = 7;
  Duplicate(x, y, z);
  // The following outputs: x=2, y=6, z=14.
  cout << "x="<< x << ", y="<< y << ", z="<< z;
  return 0;
}

Gdybyśmy zostawili bez znaku & w definicji funkcji zduplikowanej, przekazujemy zmienne „według wartości”, czyli z wartości zmiennej tworzona jest kopia. Wszelkie zmiany w zmiennej w funkcji modyfikują kopię. Nie modyfikują pierwotnej zmiennej.

Gdy zmienna jest przekazywana przez odwołanie, nie przekazujemy kopii jej wartości, tylko przekazujemy do funkcji adres zmiennej. Każda modyfikacja lokalnej zmiennej spowoduje modyfikację przekazanej pierwotnej zmiennej. 

Jeśli jesteś programistą C, mamy dla Ciebie nową nowość. To samo można zrobić w języku C, zadeklarując Duplikat() jako Duplikat(int *x). W takim przypadku x jest wskaźnikiem do int, a następnie wywołasz funkcję Duplikat() z argumentem &x (adres x) i usuń odwołanie do argumentu x w elemencie Duplikat() (patrz poniżej). Jednak w C++ prostszy sposób przekazywania wartości do funkcji przez odwołanie jest nadal prostszy.

void Duplicate(int *a, int *b, int *c) {
  *a *= 2;
  *b *= 2;
  *c *= 2;
}

int main() {
  int x = 1, y = 3, z = 7;
  Duplicate(&x, &y, &z);
  // The following outputs: x=2, y=6, z=14.
  cout << "x=" << x << ", y=" << y << ", z=" << z;
  return 0;
}

Zwróć uwagę, że w przypadku odwołań w C++ nie musimy przekazywać adresu zmiennej ani odwoływać się do zmiennej w wywołanej funkcji.

Co daje poniższy program? Narysuj obraz wspomnień, aby to odgadnąć.

void DoIt(int &foo, int goo);

int main() {
  int *foo, *goo;
  foo = new int;
  *foo = 1;
  goo = new int;
  *goo = 3;
  *foo = *goo + 3;
  foo = goo;
  *goo = 5;
  *foo = *goo + *foo;
  DoIt(*foo, *goo);
  cout << (*foo) << endl;
}

void DoIt(int &foo, int goo) {
  foo = goo + 3;
  goo = foo + 4;
  foo = goo + 3;
  goo = foo;
} 

Uruchom program, aby sprawdzić, czy udało Ci się odpowiedzieć poprawnie.

Przykład 3: przekazywanie wartości według odwołania

Napisz funkcję o nazwie przyspieszanie(), która jako dane wejściowe prędkości pojazdu i wartości ma oblicza. Funkcja zwiększa prędkość pojazdu, aby przyspieszyć. Parametr prędkości powinien być przekazywany przez odwołanie, a wartość jako wartość. Tutaj znajdziesz nasze rozwiązanie.

Przykład 4. Klasy i obiekty

Weź pod uwagę te zajęcia:

// time.cpp, Maggie Johnson
// Description: A simple time class.

#include <iostream>
using namespace std;

class Time {
 private:
  int hours_;
  int minutes_;
  int seconds_;
 public:
  void set(int h, int m, int s) {hours_ = h; minutes_ = m; seconds_ = s; return;}
  void increment();
  void display();
};

void Time::increment() {
  seconds_++;
  minutes_ += seconds_/60;
  hours_ += minutes_/60;
  seconds_ %= 60;
  minutes_ %= 60;
  hours_ %= 24;
  return;
}

void Time::display() {
  cout << (hours_ % 12 ? hours_ % 12:12) << ':'
       << (minutes_ < 10 ? "0" :"") << minutes_ << ':'
       << (seconds_ < 10 ? "0" :"") << seconds_
       << (hours_ < 12 ? " AM" : " PM") << endl;
}

int main() {
  Time timer;
  timer.set(23,59,58);
  for (int i = 0; i < 5; i++) {
    timer.increment();
    timer.display();
    cout << endl;
  }
}

Zwróć uwagę, że zmienne członków zajęć mają na końcu podkreślenie. Ma to na celu odróżnienie zmiennych lokalnych i klas.

Dodaj do tej klasy metodę zmniejszania. Tutaj znajdziesz nasze rozwiązanie.

Cuda nauki: informatyka

Ćwiczenia

Podobnie jak w pierwszym module tego kursu nie zapewniamy rozwiązań dotyczących ćwiczeń ani projektów.

Pamiętaj, że dobry program...

... jest logicznie rozłożona na funkcje, w których dowolna funkcja wykonuje jedno i tylko jedno zadanie.

... ma główny program, który określa jego zastosowanie w formie zarysu jego działania.

... ma nazwy funkcji opisowych, stałych i zmiennych.

... używa stałych, by uniknąć „magicznych” liczb w programie.

... ma przyjazny interfejs użytkownika.

Ćwiczenia rozgrzewkowe

  • Ćwiczenie 1

    Liczba całkowita 36 ma szczególne właściwości: kwadrat idealny i suma liczb całkowitych od 1 do 8. Kolejna taka liczba to 1225, czyli 352, i suma liczb całkowitych z zakresu od 1 do 49. Znajdź kolejną liczbę, która jest kwadratem i sumę ciągu 1...n. Ta następna liczba może być większa niż 32 767. Aby przyspieszyć działanie programu, możesz użyć znanych Ci funkcji biblioteki (lub formuł matematycznych). Program można też napisać za pomocą pętli for, aby określić, czy liczba jest kwadratem idealnym czy sumą ciągów. (Uwaga: w zależności od komputera i programu znalezienie tego numeru może trochę potrwać).

  • Ćwiczenie 2

    Księgarnia uniwersytecka potrzebuje Twojej pomocy przy szacowaniu wysokości działalności na przyszły rok. Doświadczenie wskazuje, że sprzedaż zależy w dużym stopniu od tego, czy książka jest wymagana do udziału w kursie czy tylko opcjonalna, oraz od tego, czy była ona już używana na zajęciach. Nowy, wymagany podręcznik zostanie sprzedany na 90% potencjalnych rejestracji, ale jeśli był już używany na zajęciach, kupi tylko 65% osób. Podobnie 40% potencjalnych zapisów kupi nowy, opcjonalny podręcznik, ale jeśli zostanie on użyty w zajęciach, tylko 20% dokona zakupu. (Określenie „używane” nie oznacza książek z drugiej ręki).

  • Napisz program, który przyjmuje jako dane wejściowe serię książek (dopóki użytkownik nie wpisze wskaźnika). W przypadku każdej książki należy podać: kod książki, koszt jej pojedynczego egzemplarza, aktualną liczbę książek dostępnych w zasięgu, listę potencjalnych zapisów na zajęcia oraz dane wskazujące, czy książka jest wymagana/opcjonalna, nowa/używana w przeszłości. W wynikach wyszukiwania wszystkie informacje wejściowe powinny pojawić się na dobrze sformatowanym ekranie wraz z liczbą książek, które trzeba uporządkować (jeśli są dostępne, pamiętaj, że zamawiane są tylko nowe książki). Łączny koszt każdego zamówienia.

    Po wprowadzeniu wszystkich danych podaj łączny koszt wszystkich zamówień książek i przewidywany zysk, jeśli sklep zapłaci 80% rynkowej ceny detalicznej. Nie omawialiśmy jeszcze żadnego sposobu radzenia sobie z dużym zbiorem danych przekazywanych do programu (więcej informacji należy wkrótce), więc przetwarzaj książki pojedynczo i wyświetlaj ekran z danymi wyjściowymi. Następnie, gdy użytkownik zakończy wpisywanie wszystkich danych, Twój program powinien wyświetlić wartości łącznych i zysków.

    Zanim zaczniesz pisać kod, zastanów się nad zaprojektowaniem tego programu. Rozłóż na zestaw funkcji i utwórz funkcję main(), która będzie wyglądać jak zarys rozwiązania problemu. Upewnij się, że każda funkcja wykonuje jedno zadanie.

    Oto przykładowe dane wyjściowe:

    Please enter the book code: 1221
     single copy price: 69.95
     number on hand: 30
     prospective enrollment: 150
     1 for reqd/0 for optional: 1
     1 for new/0 for used: 0
    ***************************************************
    Book: 1221
    Price: $69.95
    Inventory: 30
    Enrollment: 150
    
    This book is required and used.
    ***************************************************
    Need to order: 67
    Total Cost: $4686.65
    ***************************************************
    
    Enter 1 to do another book, 0 to stop. 0
    ***************************************************
    Total for all orders: $4686.65
    Profit: $937.33
    ***************************************************

Projekt bazy danych

W tym projekcie tworzymy w pełni funkcjonalny program w języku C++, który implementuje prostą aplikację bazy danych.

Nasz program pozwala nam zarządzać bazą kompozytorów i istotnymi informacjami o nich. Funkcje programu obejmują:

  • Możliwość dodawania nowego kompozytora
  • Możliwość określenia rankingu kompozytora (tzn.wskazania, jak bardzo jego muzyka podoba się lub nie podoba).
  • Możliwość wyświetlania wszystkich kompozytorów w bazie danych
  • Możliwość wyświetlania wszystkich kompozytorów według pozycji w rankingu

„Są 2 sposoby konstruowania oprogramowania: jeden z nich powinien być tak prosty, aby oczywiście nie miał żadnych wad, a drugi – tak skomplikowany, żeby nie było żadnych oczywistych wad. Pierwsza metoda jest znacznie trudniejsza”. – C.A.R. Hoare

Projektując i kodując, wielu z nas nauczyło się korzystać z metody opartej na procedurze. Najważniejszym pytaniem, jakie zaczynamy od tego, jest „Co musi robić program?”. Rozwiązanie problemu dzielimy na zadania, z których każde rozwiązuje jego część. Te zadania są mapowane na funkcje w naszym programie, które są wywoływane sekwencyjnie z poziomu main() lub innych funkcji. To podejście krok po kroku sprawdza się idealnie w przypadku niektórych problemów, które musimy rozwiązać. Jednak zwykle nasze programy nie są tylko liniowymi sekwencjami zadań czy zdarzeń.

W przypadku metody opartej na obiektach zaczynamy od pytania „Jakie rzeczywiste obiekty modeluję?”. Zamiast dzielić program na zadania w opisany powyżej sposób, dzielimy go na modele obiektów fizycznych. Te obiekty fizyczne mają stan zdefiniowany przez zestaw atrybutów i zestaw zachowań lub działań, które mogą wykonywać. Te działania mogą zmienić stan obiektu lub wywoływać działania innych obiektów. Podstawowym założeniem jest to, że obiekt „wie”, jak działać samodzielnie. 

W projekcie OO obiekty fizyczne definiujemy w kategoriach klas i obiektów, atrybutów i zachowań. Zwykle w programie OO znajduje się duża liczba obiektów. Wiele z tych obiektów jest jednak zasadniczo takich samych. Weź pod uwagę te kwestie.

Klasa to zbiór ogólnych atrybutów i zachowań obiektu, który może istnieć fizycznie w świecie rzeczywistym. Na ilustracji powyżej mamy klasę Apple. Wszystkie jabłka, niezależnie od rodzaju, mają atrybuty koloru i smaku. Zdefiniowaliśmy też zachowanie, w którym Apple wyświetla swoje atrybuty.

Na tym diagramie zdefiniowaliśmy 2 obiekty należące do klasy Apple. Każdy obiekt ma te same atrybuty i działania co klasa, ale określa atrybuty konkretnego typu jabłka. Dodatkowo działanie Wyświetl wyświetla atrybuty tego obiektu, np. „Zielony” i „kwaśny”.

Projekt OO składa się z zestawu klas, danych powiązanych z tymi klasami oraz zestawu działań, które mogą wykonywać. Musimy też określić sposoby interakcji różnych klas. Tę interakcję mogą wykonać obiekty klasy, które wywołują działania obiektów innych klas. Możemy na przykład mieć klasę Appleoutputer, która zwraca kolor i smak tablicy obiektów Apple przez wywołanie metody Display() każdego obiektu Apple.

Oto czynności, które wykonujemy podczas projektowania OO:

  1. Zidentyfikuj klasy i określ ogólnie, co obiekt każdej klasy przechowuje jako dane i co może robić.
  2. Zdefiniuj elementy danych poszczególnych klas
  3. Zdefiniuj działania poszczególnych klas oraz sposób wdrażania niektórych działań jednej klasy za pomocą działań innych powiązanych klas.

W dużym systemie te czynności są wykonywane iteracyjnie na różnych poziomach szczegółowości.

W przypadku systemu bazy danych narzędzia Composer potrzebna jest klasa Composer, która obejmuje wszystkie dane, które mają być przechowywane w danym komponencie Composer. Obiekt tej klasy może awansować lub degradować się (zmieniać swoją pozycję) oraz wyświetlać swoje atrybuty.

Potrzebujemy też kolekcji obiektów w usłudze Composer. Na tym etapie definiujemy klasę bazy danych, która zarządza poszczególnymi rekordami. Obiekt tej klasy może dodawać lub pobierać obiekty Composer, a także wyświetlać poszczególne z nich, wywołując działanie „display” obiektu Composer.

Na koniec potrzebujemy interfejsu umożliwiającego interaktywne operacje w bazie danych. To jest klasa zastępcza, tj. nie wiemy jeszcze, jak będzie wyglądać interfejs użytkownika, ale wiemy, że będzie nam potrzebna. Być może będą miały formę graficzną, tekstową lub tekstową. Na razie tworzymy obiekt zastępczy, który możemy wypełnić później.

Po zidentyfikowaniu klas dla aplikacji bazy danych narzędzia composer następnym krokiem jest zdefiniowanie atrybutów i działań dla klas. W bardziej złożonych aplikacjach zajęliśmy się ołówkiem i papierem, kartami UML lub CRC lub OOD, aby określić hierarchię klas i sposób interakcji obiektów.

W bazie danych narzędzia Composer definiujemy klasę Composer zawierającą odpowiednie dane, które chcemy przechowywać w każdym komponencie Composer. Zawiera on też metody manipulowania rankingami i wyświetlania danych.

Klasa Database wymaga pewnej struktury do przechowywania obiektów Composer. Musimy mieć możliwość dodania do struktury nowego obiektu Composer, a także pobrania określonego obiektu Composer. Wszystkie obiekty chcemy też wyświetlać według pozycji lub pozycji w rankingu.

Klasa interfejsu użytkownika implementuje interfejs oparty na menu z modułami obsługi, które wywołują działania w klasie Database. 

Jeśli klasy są łatwe do zrozumienia, a ich atrybuty i działania są jasne, tak jak w aplikacji Composer, klasyfikacja klas jest stosunkowo łatwa. Jeśli jednak masz wątpliwości co do relacji między klasami i interakcji, najlepiej jest najpierw narysować je i dopracować szczegóły, zanim zaczniesz kodować.

Po uzyskaniu jasnego obrazu i ocenienia projektu (wkrótce więcej na ten temat) definiujemy interfejs poszczególnych klas. Na tym etapie nie skupiamy się na szczegółach implementacji. Chodzi nam tylko o atrybuty i działania oraz o to, które części stanu i działań klasy są dostępne dla innych klas.

W języku C++ zwykle trzeba to zrobić, definiując plik nagłówka dla każdej klasy. Klasa Composer ma użytkowników prywatnych danych, które obejmują wszystkie dane, które mają być przechowywane w komponencie Composer. Potrzebujemy akcesorów (metody get) i mutatorów („metody set”), a także głównych działań dla klasy.

// composer.h, Maggie Johnson
// Description: The class for a Composer record.
// The default ranking is 10 which is the lowest possible.
// Notice we use const in C++ instead of #define.
const int kDefaultRanking = 10;

class Composer {
 public:
  // Constructor
  Composer();
  // Here is the destructor which has the same name as the class
  // and is preceded by ~. It is called when an object is destroyed
  // either by deletion, or when the object is on the stack and
  // the method ends.
  ~Composer();

  // Accessors and Mutators
  void set_first_name(string in_first_name);
  string first_name();
  void set_last_name(string in_last_name);
  string last_name();
  void set_composer_yob(int in_composer_yob);
  int composer_yob();
  void set_composer_genre(string in_composer_genre);
  string composer_genre();
  void set_ranking(int in_ranking);
  int ranking();
  void set_fact(string in_fact);
  string fact();

  // Methods
  // This method increases a composer's rank by increment.
  void Promote(int increment);
  // This method decreases a composer's rank by decrement.
  void Demote(int decrement);
  // This method displays all the attributes of a composer.
  void Display();

 private:
  string first_name_;
  string last_name_;
  int composer_yob_; // year of birth
  string composer_genre_; // baroque, classical, romantic, etc.
  string fact_;
  int ranking_;
};

Klasa Database też jest prosta.

// database.h, Maggie Johnson
// Description: Class for a database of Composer records.
#include  <iostream>
#include "Composer.h"

// Our database holds 100 composers, and no more.
const int kMaxComposers = 100;

class Database {
 public:
  Database();
  ~Database();

  // Add a new composer using operations in the Composer class.
  // For convenience, we return a reference (pointer) to the new record.
  Composer& AddComposer(string in_first_name, string in_last_name,
                        string in_genre, int in_yob, string in_fact);
  // Search for a composer based on last name. Return a reference to the
  // found record.
  Composer& GetComposer(string in_last_name);
  // Display all composers in the database.
  void DisplayAll();
  // Sort database records by rank and then display all.
  void DisplayByRank();

 private:
  // Store the individual records in an array.
  Composer composers_[kMaxComposers];
  // Track the next slot in the array to place a new record.
  int next_slot_;
};

Zwróć uwagę na to, jak starannie umieściliśmy dane specyficzne dla kompozytora w oddzielnej klasie. Mogliśmy umieścić w klasie Database struct lub klasę, która reprezentowała rekord Composer, i uzyskać do niego bezpośredni dostęp. Byłoby to jednak „znajdowanie zastrzeżeń”, czyli nie modelowaliśmy obiektów tak często, jak to możliwe.

Po rozpoczęciu pracy nad implementacją klas Composer i Database zauważysz, że znacznie łatwiej jest mieć oddzielną klasę Composer. W szczególności wykonywanie osobnych operacji atomowych na obiekcie Composer znacznie upraszcza implementację metod Display() w klasie Database.

Oczywiście istnieje też coś takiego jak „nadmierne uwidocznienie”, w którym staramy się przekształcić wszystko w klasę lub mamy więcej klas, niż potrzebujemy. Znajdowanie tej równowagi wymaga praktyki. Przekonasz się, że różni programiści mają różne opinie.

Aby łatwo można rozwiązać ten problem, często umieszczasz klas na diagramie. Jak wspomnieliśmy wcześniej, ważne jest opracowanie projektu klas przed rozpoczęciem kodowania. Może to ułatwić przeanalizowanie Twojego podejścia. Typowym zapisem używanym do tego celu jest UML (Unified Modeling Language). Mamy już zdefiniowane klasy dla obiektów Composer i Database, dlatego potrzebujemy interfejsu umożliwiającego użytkownikowi interakcję z bazą danych. Wystarczy proste menu:

Composer Database
---------------------------------------------
1) Add a new composer
2) Retrieve a composer's data
3) Promote/demote a composer's rank
4) List all composers
5) List all composers by rank
0) Quit

Interfejs użytkownika możemy wdrożyć jako klasę lub program proceduralny. Nie wszystko w programie C++ musi być klasą. Jeśli przetwarzanie odbywa się sekwencyjnie lub z zadaniami, tak jak w tym programie menu, możesz to robić w sposób proceduralny. Ważne jest, aby zaimplementować go w taki sposób, aby pozostało „obiektem zastępczym”, czyli jeśli w którymś momencie zechcesz utworzyć graficzny interfejs użytkownika, nie będzie trzeba wprowadzać żadnych zmian w systemie poza interfejsem.

Ostatnią rzeczą, jaką musimy wypełnić zgłoszenie, jest program do testowania zajęć. W przypadku klasy Composer chcemy uzyskać program main(), który pobiera dane wejściowe, wypełnia obiekt Composer, a następnie go wyświetla, aby sprawdzić, czy klasa działa prawidłowo. Chcemy też wywoływać wszystkie metody klasy Composer.

// test_composer.cpp, Maggie Johnson
//
// This program tests the Composer class.

#include <iostream>
#include "Composer.h"
using namespace std;

int main()
{
  cout << endl << "Testing the Composer class." << endl << endl;

  Composer composer;

  composer.set_first_name("Ludwig van");
  composer.set_last_name("Beethoven");
  composer.set_composer_yob(1770);
  composer.set_composer_genre("Romantic");
  composer.set_fact("Beethoven was completely deaf during the latter part of "
    "his life - he never heard a performance of his 9th symphony.");
  composer.Promote(2);
  composer.Demote(1);
  composer.Display();
}

Potrzebujemy podobnego programu testowego dla klasy bazy danych.

// test_database.cpp, Maggie Johnson
//
// Description: Test driver for a database of Composer records.
#include <iostream>
#include "Database.h"
using namespace std;

int main() {
  Database myDB;

  // Remember that AddComposer returns a reference to the new record.
  Composer& comp1 = myDB.AddComposer("Ludwig van", "Beethoven", "Romantic", 1770,
    "Beethoven was completely deaf during the latter part of his life - he never "
    "heard a performance of his 9th symphony.");
  comp1.Promote(7);

  Composer& comp2 = myDB.AddComposer("Johann Sebastian", "Bach", "Baroque", 1685,
    "Bach had 20 children, several of whom became famous musicians as well.");
  comp2.Promote(5);

  Composer& comp3 = myDB.AddComposer("Wolfgang Amadeus", "Mozart", "Classical", 1756,
    "Mozart feared for his life during his last year - there is some evidence "
    "that he was poisoned.");
  comp3.Promote(2);

  cout << endl << "all Composers: " << endl << endl;
  myDB.DisplayAll();
}

Te proste programy testowe to dobry pierwszy krok, ale wymagają od nas ręcznego sprawdzenia danych wyjściowych, aby mieć pewność, że program działa prawidłowo. W miarę powiększania się systemu ręczna kontrola danych wyjściowych szybko staje się niepraktyczna. Na kolejnej lekcji omówimy programy do samodzielnego sprawdzania wiedzy w formie testów jednostkowych.

Projekt naszej aplikacji jest już gotowy. Następnym krokiem jest implementacja plików .cpp na potrzeby klas i interfejsu użytkownika.Aby rozpocząć, skopiuj i wklej kod .h, a następnie przetestuj kod sterownika do plików i skompiluj je.Przetestuj swoje zajęcia za pomocą kierowców testowych. Następnie zaimplementuj ten interfejs:

Composer Database
---------------------------------------------
1) Add a new composer
2) Retrieve a composer's data
3) Promote/demote a composer's rank
4) List all composers
5) List all composers by rank
0) Quit

Aby wdrożyć interfejs, użyj metod zdefiniowanych w klasie bazy danych. Upewnij się, że stosowane metody są bezpieczne. Na przykład ranking zawsze powinien mieścić się w przedziale od 1 do 10. Nie zezwalaj też nikomu na dodawanie list 101 kompozytorów, chyba że planujesz zmienić strukturę danych w klasie Database.

Pamiętaj, że cały Twój kod musi być zgodny z naszymi konwencjami kodowania, które dla Twojej wygody powtarzamy tutaj:

  • Każdy program, który tworzymy, zaczyna się od komentarza w nagłówku, w którym podano imię i nazwisko autora, jego dane kontaktowe, krótki opis oraz sposób użycia (w stosownych przypadkach). Każda funkcja/metoda zaczyna się od komentarza na temat operacji i zastosowania.
  • Komentarze objaśniające dodajemy pełne zdania, jeśli kod sam się nie udokumentuje, np. gdy przetwarzanie jest trudne, nieoczywiste, interesujące lub ważne.
  • Zawsze używaj nazw opisowych: zmienne to słowa zapisane małymi literami rozdzielone znakiem _, jak w przypadku zmiennej moja_zmienna. Nazwy funkcji lub metod używają do oznaczania słów wielkimi literami, tak jak w przykładzie MyExcitingFunction(). Stałe zaczynają się od „k” i oznaczają słowa wielkimi literami, np. kDaysInWeek.
  • Wcięcie jest wielokrotnością liczby 2. Pierwszy poziom to 2 spacje; jeśli potrzebne jest dalsze wcięcie, użyjemy 4 spacji, 6 spacji itd.

Witamy w prawdziwym świecie!

W tym module omawiamy dwa bardzo ważne narzędzia używane w większości organizacji zajmujących się inżynierią oprogramowania. Pierwszy z nich to narzędzie do kompilacji, a drugi to system zarządzania konfiguracją. Oba te narzędzia są niezbędne w przemysłowej inżynierii oprogramowania, w której wielu inżynierów często pracuje na jednym dużym systemie. Narzędzia te pomagają koordynować i kontrolować zmiany w bazie kodu oraz zapewniają efektywne sposoby kompilowania i łączenia systemu z wielu plików programów i plików nagłówka.

Pliki tworzenia

Proces kompilowania programu jest zwykle zarządzany za pomocą narzędzia do kompilacji, które kompiluje i łączy wymagane pliki we właściwej kolejności. Pliki C++ często są zależne. Na przykład funkcja wywołana w jednym programie znajduje się w innym. Być może plik nagłówka jest potrzebny w przypadku kilku różnych plików .cpp. Narzędzie do kompilacji ustala prawidłową kolejność kompilacji na podstawie tych zależności. Kompiluje też tylko te pliki, które zmieniły się od ostatniej kompilacji. W systemach składających się z setek, a nawet tysięcy plików można zaoszczędzić sporo czasu.

Powszechnie używane jest narzędzie open source do kompilacji o nazwie Make. Aby dowiedzieć się więcej na ten temat, przeczytaj ten artykuł. Sprawdź, czy możesz utworzyć wykres zależności dla aplikacji Composer Database, a następnie przełożyć go na plik Makefile.Tutaj znajdziesz nasze rozwiązanie.

Systemy zarządzania konfiguracją

Drugim narzędziem stosowanym w industrialnej inżynierii oprogramowania jest zarządzanie konfiguracją (Config Management, CM). Służy do zarządzania zmianą. Załóżmy, że Robert i Susanka piszą o technikach i pracują nad aktualizacją podręcznika technicznego. Podczas spotkania menedżer przypisuje każdej sekcji tego samego dokumentu do zaktualizowania.

Instrukcja techniczna jest przechowywana na komputerze, do którego mają dostęp zarówno Robert, jak i Zuzanny. Bez narzędzia i procesu CM mogą pojawić się różne problemy. Jednym z możliwych scenariuszy jest konfiguracja komputera, na którym Robert i Susanka są przechowywane, w taki sposób, że nie będą mogli jednocześnie pracować nad podręcznikiem. To ich znacznie spowolni.

Sytuacja bardziej niebezpieczna powstaje, gdy komputer pamięci masowej pozwala na otwarcie dokumentu zarówno przez Roberta, jak i Susan w tym samym czasie. Oto, co może się zdarzyć:

  1. Robert otwiera dokument na komputerze i pracuje nad swoją sekcją.
  2. Zuzanna otwiera dokument na komputerze i pracuje nad swoją sekcją.
  3. Robert wprowadza zmiany i zapisuje dokument na komputerze.
  4. Zuzanna wprowadza zmiany i zapisuje dokument na komputerze.

Ilustracja pokazująca problem, który może wystąpić, jeśli w jednej kopii podręcznika technicznego nie ma elementów sterujących. Kiedy Susan zapisze zmiany, zastąpi te wprowadzone przez Roberta.

Właśnie taką sytuację może kontrolować system CM. W ramach systemu Menedżera Społeczności Bob i Zuzanna „sprawdzają” własną kopię podręcznika technicznego i pracują nad nimi. Kiedy Robert sprawdza zmiany, system wie, że Susan ma wymeldowaną własną kopię. Gdy Susan sprawdza swój tekst, system analizuje zmiany wprowadzone zarówno przez Roberta, jak i Susankę, a potem tworzy nową wersję, która scala oba zestawy zmian.

Systemy CM mają wiele innych funkcji niż zarządzanie równoczesnymi zmianami, jak opisano powyżej. Wiele systemów przechowuje archiwa wszystkich wersji dokumentu od momentu jego utworzenia. Podręcznik techniczny może być bardzo pomocny, gdy użytkownik ma jego starszą wersję i zadaje pytania autorowi tekstu. System CM umożliwiłby autorowi tekstu technicznego dostęp do starej wersji i możliwość sprawdzenia, co widzi użytkownik.

Systemy CM są szczególnie przydatne do kontrolowania zmian wprowadzanych w oprogramowaniu. Takie systemy są nazywane systemami zarządzania konfiguracją oprogramowania (SCM). Biorąc pod uwagę ogromną liczbę pojedynczych plików kodu źródłowego w dużej organizacji zajmującej się inżynierią oprogramowania oraz ogromną liczbę inżynierów, którzy muszą wprowadzać w nich zmiany, oczywiste, że system SCM ma kluczowe znaczenie.

Zarządzanie konfiguracją oprogramowania

Systemy SCM opierają się na prostej koncepcji: ostateczne kopie plików są przechowywane w centralnym repozytorium. Użytkownicy sprawdzają kopie plików z repozytorium, pracują nad nimi, a po zakończeniu pracy sprawdzają je ponownie. Systemy SCM zarządzają wersjami przez wiele osób i śledzą je w odniesieniu do jednego zbioru głównego. 

Wszystkie systemy SCM oferują te podstawowe funkcje:

  • Zarządzanie równoczesnością
  • Obsługa wersji
  • Synchronizacja

Przyjrzyjmy się teraz dokładniej każdej z tych funkcji.

Zarządzanie równoczesnością

Równoczesność oznacza jednoczesne edytowanie pliku przez więcej niż jedną osobę. W przypadku dużego repozytorium chcemy umożliwić użytkownikom wykonywanie tych czynności, ale może to wiązać się z pewnymi problemami.

Rozważmy prosty przykład w domenie inżynierii: załóżmy, że zezwalamy inżynierom na jednoczesne modyfikowanie tego samego pliku w centralnym repozytorium kodu źródłowego. Klient1 i Klient2 muszą wprowadzać zmiany w pliku jednocześnie:

  1. Klient1 otwiera plik bar.cpp.
  2. Klient2 otwiera plik bar.cpp.
  3. Klient1 zmienia plik i go zapisuje.
  4. Klient2 zmienia plik i zapisuje go, zastępując zmiany wprowadzone przez klienta Client1.

Oczywiście nie chcemy, aby do tego doszło. Nawet jeśli udało nam się kontrolować sytuację, pracując nad oddzielnymi kopiami, a nie bezpośrednio na zbiorze głównym (jak na ilustracji poniżej), kopie muszą zostać w jakiś sposób uzgodnione. Większość systemów SCM rozwiązuje ten problem, umożliwiając wielu inżynierom sprawdzenie pliku („synchronizację” lub „aktualizację”) i wprowadzenie odpowiednich zmian. System SCM uruchamia algorytmy, które scalają zmiany, gdy pliki są sprawdzane z powrotem w repozytorium („Prześlij” lub „Zatwierdź”).

Algorytmy mogą być proste (poproś inżynierów o rozwiązanie sprzecznych zmian) lub niezbyt proste (określ, jak inteligentne scalanie sprzecznych zmian jest możliwe, i zapytaj inżyniera tylko o to, czy system faktycznie się utknie). 

Obsługa wersji

Obsługa wersji polega na śledzeniu wersji pliku, co umożliwia odtworzenie (lub przywrócenie) poprzedniej wersji pliku. Robi się to przez utworzenie kopii archiwalnej każdego pliku, gdy jest on sprawdzany w repozytorium, lub zapisanie każdej zmiany wprowadzonej w pliku. W każdej chwili możemy użyć archiwów lub zmienić informacje, aby utworzyć poprzednią wersję. Systemy obsługujące wersje mogą też tworzyć raporty logu z informacjami o tym, kto i kiedy sprawdzał zmiany oraz jakie zmiany zostały wprowadzone.

Synchronizacja

W niektórych systemach SCM poszczególne pliki są rejestrowane w repozytorium i z niego usuwane. Bardziej wydajne systemy umożliwiają jednoczesne sprawdzanie więcej niż 1 pliku. Inżynierowie sprawdzają własne, pełne i kompletne repozytorium (lub jego część) i w razie potrzeby pracują nad plikami. Następnie okresowo zatwierdzają zmiany z powrotem do repozytorium głównego i aktualizują swoje osobiste kopie, aby mieć aktualne informacje o zmianach wprowadzonych przez inne osoby. Ten proces nosi nazwę synchronizacji lub aktualizacji.

Podejście

Subversion (SVN) to system kontroli wersji typu open source. Oferuje wszystkie wymienione powyżej funkcje.

W przypadku konfliktów SVN stosuje prostą metodologię. Konflikt występuje, gdy co najmniej 2 inżynierów wprowadzi różne zmiany w tym samym obszarze bazy kodu, a następnie obaj wpiszą swoje zmiany. SVN tylko powiadamia inżynierów o konflikcie, którego rozwiązanie zależy od inżynierów.

W trakcie tego kursu będziemy korzystać z SVN, aby pomóc Ci zapoznać się z zarządzaniem konfiguracją. Takie systemy są bardzo powszechne w branży.

Najpierw musisz zainstalować SVN w swoim systemie. Kliknij tutaj, aby uzyskać instrukcje. Znajdź swój system operacyjny i pobierz odpowiedni plik binarny.

Terminologia SVN

  • Wersja: zmiana w pliku lub zbiorze plików. Wersja to jeden z „migawek” w stale zmieniającym się projekcie.
  • Repozytorium: kopia główna, w której SVN przechowuje pełną historię zmian projektu. Każdy projekt ma 1 repozytorium.
  • Kopia robocza: kopia, w której inżynier wprowadza zmiany w projekcie. Może istnieć wiele kopii roboczych danego projektu, z których każda należy do jednego inżyniera.
  • Wymeldowanie: aby poprosić o działającą kopię z repozytorium. Kopia robocza jest taka sama jak stan projektu w momencie wymeldowania.
  • Zatwierdzenie: wysyła zmiany z kopii roboczej do centralnego repozytorium. Inna nazwa to meldowanie lub przesyłanie.
  • Aktualizacja: aby przenieść z repozytorium do kopii roboczej zmiany innych użytkowników lub wskazać, czy kopia robocza zawiera niezatwierdzone zmiany. Działa to tak samo jak w przypadku synchronizacji, jak opisano powyżej. Dzięki takiemu procesowi kopia robocza zaktualizuje się do kopii repozytorium.
  • Konflikt: sytuacja, w której dwóch inżynierów próbuje wprowadzić zmiany w tym samym obszarze pliku. SVN wskazuje konflikty, ale inżynierowie muszą je rozwiązać.
  • Komunikat logu: komentarz dołączany do wersji po jej zatwierdzeniu, który opisuje wprowadzone zmiany. Log zawiera podsumowanie tego, co się dzieje w projekcie.

Po zainstalowaniu SVN przejdziemy do podstawowych poleceń. Najpierw musisz skonfigurować repozytorium w określonym katalogu. Oto polecenia:

$ svnadmin create /usr/local/svn/newrepos
$ svn import mytree file:///usr/local/svn/newrepos/project -m "Initial import"
Adding         mytree/foo.c
Adding         mytree/bar.c
Adding         mytree/subdir
Adding         mytree/subdir/foobar.h

Committed revision 1.

Polecenie import kopiuje zawartość katalogu mytree do projektu katalogu w repozytorium. Możemy zobaczyć katalog w repozytorium za pomocą polecenia list

$ svn list file:///usr/local/svn/newrepos/project
bar.c
foo.c
subdir/

Import nie utworzy działającej kopii. Aby to zrobić, musisz użyć polecenia svn_checkout. Spowoduje to utworzenie działającej kopii drzewa katalogów. Zrobimy to teraz:

$ svn checkout file:///usr/local/svn/newrepos/project
A    foo.c
A    bar.c
A    subdir
A    subdir/foobar.h
…
Checked out revision 215.

Po utworzeniu kopii roboczej możesz wprowadzić zmiany w plikach i katalogach. Kopia robocza wygląda tak samo jak każdy inny zbiór plików i katalogów – możesz dodawać nowe, edytować, przenosić, a nawet usunąć całą kopię roboczą. Pamiętaj, że w przypadku kopiowania i przenoszenia plików w kopii roboczej należy używać kopii svn i svnmove, a nie poleceń systemu operacyjnego. Aby dodać nowy plik, użyj polecenia svn add, a usunąć plik – polecenia svn delete. Jeśli chcesz wprowadzić zmiany, po prostu otwórz plik w edytorze i zacznij edytować.

Istnieje kilka standardowych nazw katalogów często używanych z Subversion. Katalog „trunk” zawiera główną linię tworzenia projektu. Katalog „branches” zawiera dowolną wersję gałęzi, nad którą możesz pracować.

$ svn list file:///usr/local/svn/repos
/trunk
/branches

Załóżmy więc, że chcesz zsynchronizować kopię roboczą ze wszystkimi wymaganymi zmianami. Jeśli w tym obszarze repozytorium pracuje wielu innych inżynierów, ważne jest, aby Twoja kopia robocza była aktualna. Aby wyświetlić wprowadzone zmiany, możesz użyć polecenia svn status.

A       subdir/new.h      # file is scheduled for addition
D       subdir/old.c        # file is scheduled for deletion
M       bar.c                  # the content in bar.c has local modifications

Pamiętaj, że polecenie stanu zawiera wiele flag służących do sterowania tymi danymi wyjściowymi. Jeśli chcesz wyświetlić określone zmiany w zmodyfikowanym pliku, użyj różnicy svn.

$ svn diff bar.c
Index: bar.c
===================================================================
--- bar.c	(revision 5)
+++ bar.c	(working copy)
## -1,18 +1,19 ##
+#include
+#include

 int main(void) {
-  int temp_var;
+ int new_var;
...

Na koniec, aby zaktualizować kopię roboczą z repozytorium, użyj polecenia svn update.

$ svn update
U  foo.c
U  bar.c
G  subdir/foobar.h
C  subdir/new.h
Updated to revision 2.

Jest to miejsce, w którym może wystąpić konflikt. W powyższych danych wyjściowych litera „U” oznacza, że nie wprowadzono żadnych zmian w wersjach tych plików przeznaczonych do repozytorium i nie wykonano aktualizacji. Litera „G” oznacza, że doszło do scalenia. Wersja repozytorium została zmieniona, ale zmiany nie kolidowały z Twoją. „C” oznacza konflikt. Oznacza to, że zmiany z repozytorium nakładały się na Twoje, i teraz musisz wybrać jedno z nich.

Dla każdego pliku powodującego konflikt Subversion umieszcza 3 pliki w Twojej kopii roboczej:

  • file.mine: plik, który istniał w kopii roboczej przed zaktualizowaniem kopii roboczej.
  • file.rOLDREV: to plik usunięty z repozytorium przed wprowadzeniem zmian.
  • file.rNEWREV: ten plik to bieżąca wersja w repozytorium.

Konflikt możesz rozwiązać na 3 sposoby:

  • Przejrzyj pliki i scal je ręcznie.
  • Skopiuj jeden z plików tymczasowych utworzonych przez SVN i zastąp swoją wersję roboczą.
  • Uruchom polecenie svn restore, aby odrzucić wszystkie zmiany.

Gdy rozwiążesz konflikt, powiadom o tym SVN, uruchamiając polecenie svn rozwiązane. Spowoduje to usunięcie 3 plików tymczasowych, a SVN nie będzie już wyświetlać pliku w stanie konfliktu.

Na koniec prześlij ostateczną wersję do repozytorium. Do tego celu służy polecenie svn Commit. Gdy zatwierdzasz zmianę, musisz podać komunikat logu opisujący zmiany. Ten komunikat logu jest dołączony do utworzonej przez Ciebie wersji.

svn commit -m "Update files to include new headers."  

Dowiedz się więcej o SVN i o tym, jak może ona pomóc w realizacji dużych projektów z zakresu inżynierii oprogramowania. W internecie jest dużo dostępnych materiałów – wystarczy wyszukać w Google hasło „Subversion”.

Na przykład utwórz repozytorium dla systemu bazy danych Composer i zaimportuj wszystkie pliki. Następnie przejdź do kasy działającej kopii i wykonaj polecenia opisane powyżej.

Odniesienia

Poradnik online

Artykuł w Wikipedii na temat SVN

Strona Subversion

Zastosowanie: badanie anatomii

Zajrzyj na kanał eSkeletons przygotowany przez University of Texas w Austin.