Klasy - wstęp teoretyczny

Jedną z zalet C++ jest możliwość stosowania klas. ROOT obsługuje zwykłe klasy, ale jednocześnie wprowadza tu pewien nowy ich rodzaj.

Roboczo nazwę je "naszymi klasami ROOTowymi", czym się one różnią? otóż po pierwsze dziedziczą po TObject co zapewnia dodatkową funkcjonalność np. można je kopiować przy pomocy Clone, co więcej można je zapisywać do drzew ROOTowych, konstruować na podstawie wartości stringu albo umieszczać w TClonesArray. Zasadniczo dla przyzwoitości należy wspomnieć, że istnieją klasy które można zapisywać do drzew ROOTowych i nie dziedziczą po TObject (muszą one jednak posiadać słownik), są one jednak bardzo rzadko spotykane.

Co trzeba zrobić aby mieć taką "pełną klasę ROOTową"? Otóż:

  • klasa powinna dziedziczyć po TObject
  • na końcu headera klasy należy dodać ClassDef(nazwa_klasy,ID), jeśli ID = 0 to nasza klasa nie będzie wspierała I/O, jeśli wartość ta jest większa możemy ją zapisywać do plików, domyślna wartość ID to 1, jest ona inkrementowana za każdym razem, gdy zmieniamy strukturę naszej kasy. Dzięki inkrementowaniu można odczytywać "starsze wersje" naszej klasy nowszym kodem - w przeciwnym razie kod się prawdopobnie wysypie).
  • należy stworzyć słownik. Słownik to kod, który zapewnia ekstra funkcjonalność naszej klasy (np. zapis do pliku rootowego). Słownik w teorii można napisać samemu, w praktyce wszyscy używają aplikacji rootcint, gdyż robi to ona automatycznie. Aplikacja ta jest częścią ROOTa.
  • trzeba stworzyć plik linkdef (mówi rootcintowi dla których klas trzeba tworzyć słownik)

Jeśli nie jest koniecznie aby nasza klasa dziedziczyła po TObject i była zapisywalna nie musimy tworzyć słownika.

Kompliowany kod

Zacznijmy więc od napisania makefile - jest to coś w rodzaju specjalnego skryptu do kompilacji kodu, wywoływany gdy wpiszemy make. Nasz makefile wygląda tak:

CC    = g++
LD    = g++
COPTS    = `root-config --cflags` -I/usr/local/root/include -g
LDOPTS    = `root-config --libs` -g
#C++ Files
SOURCES =  Main.cpp  Dict.cpp MyParticle.cpp
OBJECTS = $(SOURCES:.cpp=.o)
#Dictionary classes
HEADERS = MyParticle.h SimpleLinkDef.h
EXECUTABLE=main.exe
all: $(EXECUTABLE)
$(EXECUTABLE): $(OBJECTS)
	$(LD) -o $@ $^ $(LDOPTS)
#C++ files
.cpp.o:
	$(CC) -o $@ $^ -c $(COPTS)
#Dictionary for ROOT classes
Dict.cpp: $(HEADERS)
	@echo "Generating dictionary ..."
	@rootcint -f  Dict.cpp -c -P -I$ROOTSYS  $(HEADERS)
clean:;         @rm -f $(OBJECTS)  $(EXECUTABLE) *.o *.d Dict.cpp Dict.h

Tutaj uwaga techniczna: w liniach 13/16/19/20 są taby a nie spacje, niestety tutaj musiałem je wrzucić dla czytelności. Jeśli kopiujecie makefile zamieńcie puste miejsce na początku na taby bo inaczej make się będzie rzucało.

Pierwsze dwie linijki mówią jakim poleceniem tworzymy biblioteki i plik wyjściowy z programem. Następnie COPTS to ścieżki do headerów (plików nagłówkowych). Ścieżki te zasadniczo  podaje się ręcznie np. -I/us/rlocal/include", w przypadku ROOT'a nie musimy wpisywać tych ścieżek ręcznie, gdyż aplikacja root-config wywołana z --cflags zwraca ściężki do headerów (ale tylko ROOTowych!). LDOPTS to flagi do linkowania, tutaj linkujemy tylko biblioteki ROOTowe więc używamy root-config --libs zamiast ręcznie podawać ścieżki. Następnie definiuujemy sobie zmienne, "all" to flaga która mówi jaki ma być finalny produkt kompilacji. Kolejne linijki opisują reguły przy tworzeniu obiektów i tak:

  • linia 12 mówi, że aby stworzyć docelowy obiekt $(EXECUTABLE) potrzebujemy $(OBJECTS), linia 13 to recepta jak to zrobić
  • linia 15 mówi, że obiekty .cpp.o (to są nasze $(OBJECTS) tworzy się przy pomocy komend z linii 16
  • linia 18 mówi jak tworzyć słownik

Ostatnia linijka opisuje reguły przy czyszczeniu projektu (gdy wywołamy make clean). W linijce 20 widać że wywołujemy rootcinta, stworzy on słownik Dict.cpp na podstawie headerów $(HEADERS). Należy tu dodać, że zawsze na końcu HEADERS musi być plik linkdef.

Plik linkdef to specjalny header, powinien kończyć się na  *LinkDef.h. Linkdef zasadniczo zawiera listę klas/enumów dla których trzeba wygenerować słownik (samo ClassDef nie wystarczy).

#ifdef __CINT__

#pragma link off all globals;
#pragma link off all classes;
#pragma link off all functions;

#pragma link C++ class MyParticle + ;  // chcemy slownik dla MyParticle

#endif

Teraz stwórzmy header naszej klasy:

#ifndef TUTORIAL_MAKEFILE_MYPARTICLE_H_
#define TUTORIAL_MAKEFILE_MYPARTICLE_H_

#include <TLorentzVector.h>
#include <TNamed.h>
#include <TVector3.h>

class MyParticle : public TNamed {  // TNamed dziedziczy po TObject
  Int_t fNHits;
  TVector3 *fHits;  //[fNHits]
  TLorentzVector *fMom;

 public:
  MyParticle();
  MyParticle(TString name);
  void AddHit(TVector3 hit);
  TLorentzVector *GetMomentum() const { return fMom; };
  virtual ~MyParticle();
  ClassDef(MyParticle, 1)
};

#endif /* TUTORIAL_MAKEFILE_MYPARTICLE_H_ */

W zasadzie nasz header wygląda jak w przypadku każdej innej klasy - z dwoma wyjątkami - po pierwsze mamy dodane ClassDef, po drugie w linii 10 mamy komentarz przy polu fHits. Nie jest to jednak zwykły komentarz który nie ma znaczenia. Komentarz ten jest instrukcją dla rootcinta i mówi że fHits to tablica TVector3 o rozmiarze fNHits. Inaczej rooticnt założy ,że mamy tylko "zwykły wskaźnik" na jeden TVector3.

#include "MyParticle.h"

MyParticle::MyParticle() : TNamed(), fNHits(0), fHits(nullptr), fMom(nullptr) {}

MyParticle::MyParticle(TString name)
    : TNamed(name, name),
      fNHits(0),
      fHits(nullptr),
      fMom(new TLorentzVector()) {}

void MyParticle::AddHit(TVector3 hit) {
  TVector3 *old_array = fHits;
  fHits = new TVector3[fNHits + 1];
  for (int i = 0; i < fNHits; i++) {
    fHits[i] = old_array[i];
  }
  fHits[fNHits++] = hit;
  if (old_array) delete[] old_array;
}

MyParticle::~MyParticle() {
  if (fHits) delete[] fHits;
  if (fMom) delete fMom;
}

Normalnie bym nie komentował kodu źródłowego ale tutaj pokuszę się o wyjaśnienie paru "dziwnych rzeczy". Pierwszą - po co mi dwa konstruktory? Otóż chciałem tu pokazać że ROOT zawsze wymaga posiadania konstruktora bez argumentów albo takiego gdzie argumenty są domyślne, oraz że zalecane jest aby taki "must be" konstruktor nie alokował pamięci. Po co? cytując klasyka "nie wiem ale się domyślam" - bo dokumentacja jest uboga. Otóż MyParticle() będzie używany przez ROOTa do odczytu pliku, brak alokacji oznacza że ROOT sobie nadpisze puste pola i wszystko będzie ok. Co będzie jak tam już coś będzie - no w sumie nie wiem, może zostanie skasowane a może będzie "wisieć" w pamięci. MyParticle(TString) jest  konstruktorem "dla użytkownika". Jeśli jednak nasz bezargumentowy konstruktor alokuje pamięć to dramatu również nie będzie  Druga dziwna rzecz to sprawdzanie czy pola klasy istnieją w destruktorze - to zalecenie deweloperów wynikające zapewne z zalecenia tworzenia konstruktorów które nie alokują pamięci.

Poniżej kod przykładowej aplikacji. Na tym etapie ROOT jest tylko zestawem bibliotek dlatego mamy headery (obowiązkowo) i funkcję int main od której zaczyna się każdy program w C++. Pewną nowością jest pojawienie się TApplication - jest ona konieczna jeśli chcemy używać GUI, bez niej program się wykona, ale nie zobaczymy żadnego histogramu.

#include <TApplication.h>
#include <TCanvas.h>
#include <TH1D.h>
#include <TRandom.h>
#include "MyParticle.h"
using namespace std;
int main(int argc, char *argv[]) {
  TH1D *histogram = new TH1D("histogram", "histogram", 50, 0.5, 1.5);
  histogram->SetFillStyle(3004);

  TRandom *random = new TRandom();
  MyParticle **czastki = new MyParticle *[1000];

  Double_t masa = 0.232;
  for (Int_t i = 0; i < 1000; i++) {
    czastki[i] = new MyParticle("particle");
    czastki[i]->GetMomentum()->SetXYZM(random->Exp(0.3), random->Exp(0.3),
                                       random->Exp(0.3), 0.938);
    Int_t hits = random->Uniform(10, 20);
    for (int j = 0; j < hits; j++) {
      czastki[i]->AddHit(
          TVector3(random->Gaus(0, 1), random->Gaus(0, 1), random->Gaus(0, 1)));
    }
  }
  for (Int_t i = 0; i < 1000; i++) {
    histogram->Fill(czastki[i]->GetMomentum()->E());
    delete czastki[i];
  }
  delete[] czastki;

  TApplication *rootapp = new TApplication("My Application", &argc, argv);
  TCanvas *c1 = new TCanvas();
  histogram->Draw();
  rootapp->Run();
  return 0;
}

Aby uruchomić nasz program wystarczy użyć dwóch komend - make do kompilacji i ./main.exe aby uruchomić skompilowany program. Należy tu pamiętać, że nasz program wciąż jest zależny od ROOTa - tzn. aby działał musimy mieć w systemie zmienne środowiskowe konieczne do działania ROOTa (np. LD_LIBRARY_PATH).