Course home · C++ Basics · Next: ROOT Tutorial I

ROOT 所需的 C++ 对象与指针¶

本页接续《ROOT 所需的 C++ 基础》,介绍《核物理实验方法》ROOT C++ 教程中的对象操作,适合尚未使用过 C++ 类和指针的同学。

内容包括类名、构造函数、方法、指针、引用,以及 ::、-> 等运算符。

阅读范围¶

遇到 ROOT 示例中的以下语法时,可查阅本页:

  • 命名空间与 ::;
  • 类、对象、构造函数与方法;
  • 通过对象使用 .,通过指针使用 ->;
  • 取地址运算符 &、指针、new、delete 和 nullptr;
  • 函数的值传递、引用传递和指针传递;
  • 将数组传给函数;
  • 理解 ROOT 类型关系所需的基本继承概念。

1. 命名空间与 ::¶

大型程序库包含许多名称。命名空间(namespace)将相关名称组织在一起,减少名称冲突;C++ 标准库使用 std 命名空间。

#include <iostream>
#include <cmath>
#include <string>
#include <vector>

{
    double variance = 25.0;
    double sigma = std::sqrt(variance);

    std::cout << "sigma = " << sigma << std::endl;
}
sigma = 5

std::sqrt 表示 std 命名空间中的 sqrt 函数。作用域运算符 :: 也出现在 ROOT 中,例如 TMath::Pi() 调用 TMath 命名空间中的 Pi 函数。

有些示例先写 using namespace std;,之后便可用 cout 代替 std::cout。本页保留明确的前缀,便于看清名称来源,也避免不同程序库之间的冲突。

2. 类与对象¶

类(class)将数据和相关操作组合为一种类型;对象(object)是该类型的一个实例。ROOT 提供 TH1F、TF1、TGraph 和 TFile 等类,分析时再根据需要创建具体对象。

下面用一个简单类表示一次探测器读数,先熟悉 C++ 语法,再进入 ROOT 的物理分析和绘图。

class DetectorReading
{
public:
    DetectorReading(double energy_keV, double time_ns)
        : energy_keV_(energy_keV), time_ns_(time_ns)
    {
    }

    double energy() const
    {
        return energy_keV_;
    }

    double time() const
    {
        return time_ns_;
    }

    bool passes_threshold(double threshold_keV) const
    {
        return energy_keV_ >= threshold_keV;
    }

private:
    double energy_keV_;
    double time_ns_;
};

class DetectorReading 定义一种新类型。public 部分列出外部代码可调用的操作;本例的 private 数据由类内部管理,外部通过公开方法访问。

与类同名的函数是构造函数(constructor),创建对象时自动执行。它的两个参数分别设置能量和时间。下面这种写法

: energy_keV_(energy_keV), time_ns_(time_ns)

称为构造函数的初始化列表。ROOT 类内部也采用类似方式;使用 ROOT 时通常只需按接口要求传入参数。

energy()、time() 和 passes_threshold() 后的 const 表示这些方法不修改对象。

创建对象并调用方法¶

{
    DetectorReading reading(661.7, 104.2);

    std::cout << "energy = " << reading.energy() << " keV" << std::endl;
    std::cout << "time = " << reading.time() << " ns" << std::endl;
    std::cout << "passes 100-keV threshold: "
              << reading.passes_threshold(100.0) << std::endl;
}
energy = 661.7 keV
time = 104.2 ns
passes 100-keV threshold: 1

DetectorReading reading(661.7, 104.2); 创建名为 reading 的对象。参数按构造函数的声明顺序,依次为能量(keV)和时间(ns)。

reading.energy() 中的点号选择对象的方法,圆括号表示调用该方法。阈值 100.0 则是传给 passes_threshold 的参数。

直接创建 ROOT 对象时,也采用相同写法:

histogram.Fill(energy);

3. 地址与指针¶

内存中的每个对象都有地址。指针(pointer)是一种保存地址的变量。

{
    DetectorReading reading(344.3, 98.5);
    DetectorReading* pointer_to_reading = &reading;

    std::cout << "object address = " << pointer_to_reading << std::endl;
    std::cout << "energy through pointer = "
              << pointer_to_reading->energy() << " keV" << std::endl;
}
object address = 0x7ffe6f469be8
energy through pointer = 344.3 keV

DetectorReading* 表示“指向 DetectorReading 对象的指针”。&reading 取得已有对象的地址,并没有创建第二个读数对象;指针指向的仍是同一个对象。

通过对象调用方法用 .,通过对象指针调用用 ->:

reading.energy();              // object
pointer_to_reading->energy();  // pointer to object

-> 后仍是普通的方法调用。ROOT 经常通过指针操作对象,因此这种写法很常见。

指针解引用¶

* 有两种相关用法:在声明中构成指针类型,如 double*;在表达式中则访问该地址处的对象,称为解引用(dereference)。

{
    double energy_keV = 511.0;
    double* energy_pointer = &energy_keV;

    std::cout << "value through pointer = " << *energy_pointer << std::endl;

    *energy_pointer = 512.0;
    std::cout << "value stored in energy_keV = " << energy_keV << std::endl;
}
value through pointer = 511
value stored in energy_keV = 512

给 *energy_pointer 赋值会改变 energy_keV,因为两者访问的是同一块内存。

nullptr 与有效性检查¶

指针也可能不指向任何对象。现代 C++ 用 nullptr 表示空指针。

{
    DetectorReading* selected_reading = nullptr;

    if (selected_reading == nullptr) {
        std::cout << "No reading has been selected." << std::endl;
    }
}
No reading has been selected.

不能通过 nullptr 调用方法。ROOT 从文件或目录中查找对象时,如果没有找到,可能返回空指针;因此在可能失败的情况下,先检查指针再使用 ->。本例中简写为 if (!selected_reading) 含义相同。

4. 用 new 和 delete 管理动态对象¶

new 动态创建对象,并返回指向它的指针。许多 ROOT macro 和 notebook 都使用这种写法。

{
    DetectorReading* reading = new DetectorReading(1173.2, 210.0);

    std::cout << "dynamic reading energy = "
              << reading->energy() << " keV" << std::endl;

    delete reading;
    reading = nullptr;
}
dynamic reading energy = 1173.2 keV

new 后的表达式调用构造函数,返回的指针保存在 reading 中,因此用 -> 调用方法。

delete reading 销毁动态创建的对象并释放内存。随后将指针设为 nullptr,避免它看起来仍指向有效对象。对象删除后,不要再通过原指针访问它。

交互式 ROOT 示例常用 new 创建对象,使其在当前代码块结束后仍然存在。ROOT 还有对象所有权(ownership)规则:文件、目录、画布等容器可能负责管理对象。具体规则需结合相应示例理解,不能只凭指针语法判断。

这里不需要使用 new[] 创建动态数组。入门分析用定长数组和 std::vector 即可,也更便于管理内存。

5. 函数参数:值、引用与指针¶

C++ 函数可以通过多种方式接收数据,ROOT 接口会用到以下三种方式。

double calibrated_copy(double raw_energy_keV, double gain)
{
    return raw_energy_keV * gain;
}

void apply_gain_in_place(double& energy_keV, double gain)
{
    energy_keV *= gain;
}

void clear_measurement(double* energy_keV)
{
    if (energy_keV != nullptr) {
        *energy_keV = 0.0;
    }
}
{
    double measured_energy_keV = 660.0;

    double copied_result_keV = calibrated_copy(measured_energy_keV, 1.0025);
    std::cout << "original after value call = "
              << measured_energy_keV << " keV" << std::endl;
    std::cout << "returned copy = " << copied_result_keV << " keV"
              << std::endl;

    apply_gain_in_place(measured_energy_keV, 1.0025);
    std::cout << "original after reference call = "
              << measured_energy_keV << " keV" << std::endl;

    clear_measurement(&measured_energy_keV);
    std::cout << "original after pointer call = "
              << measured_energy_keV << " keV" << std::endl;
}
original after value call = 660 keV
returned copy = 661.65 keV
original after reference call = 661.65 keV
original after pointer call = 0 keV

calibrated_copy 接收的是数值的副本,修改形参不会改变调用者的变量;函数通过返回值给出计算结果。

apply_gain_in_place 中的 double& 是引用参数,相当于调用者变量的另一个名称,因此函数可以直接修改原变量。调用时直接写变量名,不加 &。

clear_measurement 中的 double* 是指针参数。调用者通过 &measured_energy_keV 传入地址,函数通过 *energy_keV 访问对应的值。

后续 ROOT 代码中的两种写法会用到这些概念:

rng.Sphere(x, y, z, 1.0);          // x, y, and z are modified by reference
tree->Branch("energy", &energy);    // the branch receives an address

ROOT 教程将介绍这些操作的具体作用。目前先理解:为什么有的实参需要写 &,有的不需要。

6. 向函数传递数组¶

当函数需要指向首元素的指针时,可以传入 C 风格数组。数组长度不会自动传入,因此还要单独提供元素个数。

void print_calibration_points(const double channels[],
                              const double energies_keV[],
                              int n_points)
{
    for (int i = 0; i < n_points; ++i) {
        std::cout << channels[i] << "  " << energies_keV[i] << std::endl;
    }
}
{
    const int n = 4;
    double channels[n] = {244.0, 688.0, 1326.0, 2665.0};
    double energies_keV[n] = {121.8, 344.3, 661.7, 1332.5};

    print_calibration_points(channels, energies_keV, n);
}
244  121.8
688  344.3
1326  661.7
2665  1332.5

调用时,channels 和 energies_keV 前面不加 &;在这里,数组名会提供首元素地址。明确传入 n,可使循环保持在合法范围内。

这也解释了 ROOT 图对象的构造方式:

TGraph graph(n, x_values, y_values);

第一个参数是点数,第二、三个参数分别是保存对应坐标的数组。

7. 长度可变的容器:std::vector¶

当元素个数在运行时发生变化,通常用 std::vector 比手动分配数组更方便,因为它会管理自身的内存。

{
    std::vector<double> deposited_energies_keV = {1.2, 4.8, 2.6};
    deposited_energies_keV.push_back(3.1);

    for (std::size_t i = 0; i < deposited_energies_keV.size(); ++i) {
        std::cout << "deposit " << i << " = "
                  << deposited_energies_keV[i] << " keV" << std::endl;
    }
}
deposit 0 = 1.2 keV
deposit 1 = 4.8 keV
deposit 2 = 2.6 keV
deposit 3 = 3.1 keV

push_back 在末尾追加一个元素,size() 返回当前元素个数。std::size_t 是适合表示数组或容器大小的无符号整数类型。

vector 离开作用域时会释放自身存储,不需要手动 delete。作业 2.1 中,事例时间列表的长度取决于产生的事例数,适合使用 vector;作业 4.1 的三个探测器能量和作业 5.1 的 250 个波形采样点则使用定长数组。

8. 函数重载¶

C++ 允许参数列表不同的函数使用同一个名称,这称为重载(overload)。编译器根据调用时传入的参数选择对应版本。

void report_energy(double energy_keV)
{
    std::cout << "E = " << energy_keV << " keV" << std::endl;
}

void report_energy(double energy_keV, double sigma_keV)
{
    std::cout << "E = " << energy_keV
              << " +/- " << sigma_keV << " keV" << std::endl;
}
report_energy(661.7);
report_energy(661.7, 1.2);
E = 661.7 keV
E = 661.7 +/- 1.2 keV

第一次调用传入一个参数,匹配第一个函数;第二次传入两个参数,匹配第二个函数。ROOT 的 Draw、Fit 和直方图构造函数也有重载,因此文档中会列出多个同名接口。

重载并不意味着可以随意传参;参数个数、顺序和类型仍要与文档中的某个接口匹配。

9. 读懂一个 ROOT 对象¶

下面借助 ROOT 直方图说明 C++ 语法。直方图的创建、bin 设置、填充、绘制和统计含义,将在 ROOT Tutorial I 中系统介绍。

#include <TH1F.h>

{
    TH1F* spectrum = new TH1F(
        "spectrum_cpp_bridge",
        "Measured energy;Energy (keV);Counts",
        100,
        0.0,
        1000.0
    );

    spectrum->Fill(661.7);
    std::cout << "entries = " << spectrum->GetEntries() << std::endl;

    delete spectrum;
    spectrum = nullptr;
}
entries = 1

从左到右看对象创建语句:

  • TH1F 是 ROOT 类。
  • TH1F* spectrum 声明名为 spectrum 的指针。
  • new TH1F(...) 创建直方图并返回其地址。
  • "spectrum_cpp_bridge" 是对象在 ROOT 中的内部名称。ROOT 用名称查找和保存对象,同一目录中的名称应避免重复。
  • 第二个字符串依次写图的标题、x 轴标题和 y 轴标题,用分号分隔。
  • 100 是 bin 的个数。
  • 0.0 和 1000.0 分别给出 x 轴下限和上限,单位为 keV。

spectrum 是指针,所以用 -> 调用方法。Fill(661.7) 将一次能量测量填入对应 bin,使计数增加一;GetEntries() 返回传给 Fill 的值的总数。

10. 读懂 ROOT 中的继承关系¶

ROOT 的类具有继承关系。例如 TH1F 从更通用的 TH1 派生而来;因此,指向基类的指针也可以指向派生类对象。

#include <TH1.h>

{
    TH1F histogram("inheritance_example", "", 20, 0.0, 10.0);
    TH1* general_histogram = &histogram;

    std::cout << "number of bins = "
              << general_histogram->GetNbinsX() << std::endl;
}
number of bins = 20

histogram 是 TH1F 对象,general_histogram 是指向同一对象的 TH1* 指针。通过基类指针,可以调用 TH1 中定义的 GetNbinsX 等方法。

11. 常见的 ROOT 类型名¶

ROOT 示例常用类型别名表示基本 C++ 类型:

Int_t event_id = 0;          // integer, normally equivalent to int
Float_t signal = 0.0F;       // single-precision floating point
Double_t energy = 0.0;       // double-precision floating point
Bool_t accepted = false;     // Boolean value

0.0F 中的后缀 F 表示该常量为 float;不加后缀的小数常量通常是 double。这些 ROOT 类型别名在旧 macro 和 TTree 示例中很常见,普通的 int、float、double 和 bool 仍然可以使用。

12. ROOT 指针用法实例¶

下面这段代码代表了后续教程中的一种常见写法:

TFile* input = TFile::Open("data.root", "READ");

if (input == nullptr || input->IsZombie()) {
    std::cout << "The file could not be opened." << std::endl;
}

TFile* 声明指针,TFile::Open 通过 :: 调用类的静态函数,不需要预先创建 TFile 对象。打开成功后,input 指向文件对象,input->IsZombie() 调用其方法;若返回空指针,前面的检查可避免无效调用。

本节只解释 C++ 结构。文件打开模式、对象所有权、关闭文件和读回对象,将在 ROOT 文件读写部分介绍。

13. 综合示例:处理探测器读数对象¶

下面创建多个探测器读数对象,通过方法检查阈值,并累加通过筛选的能量,将对象操作与前一讲的循环、条件判断结合起来。

{
    const int n_readings = 5;
    DetectorReading readings[n_readings] = {
        DetectorReading(42.0, 100.1),
        DetectorReading(121.8, 101.4),
        DetectorReading(344.3, 102.0),
        DetectorReading(78.5, 103.2),
        DetectorReading(661.7, 104.1)
    };

    int accepted = 0;
    double energy_sum_keV = 0.0;

    for (int i = 0; i < n_readings; ++i) {
        if (readings[i].passes_threshold(100.0)) {
            ++accepted;
            energy_sum_keV += readings[i].energy();
        }
    }

    std::cout << "accepted readings = " << accepted << std::endl;
    std::cout << "mean accepted energy = "
              << energy_sum_keV / accepted << " keV" << std::endl;
}
accepted readings = 3
mean accepted energy = 375.933 keV

readings[i] 是对象,用 . 调用方法;若数组中保存的是指针,则使用 ->。阈值作为参数传入方法,通过筛选的能量与前一讲一样逐个累加。

在 ROOT 中,直方图对象可以代替手工计数。例如 spectrum->Fill(readings[i].energy()) 会找到对应 bin 并增加计数。下一讲将进一步说明 Fill 的作用。

练习¶

  1. 创建能量为 511.0 keV 的 DetectorReading 对象,用 . 输出其能量。
  2. 用 & 取得该对象的地址,再通过指针用 -> 输出同一能量。
  3. 在 ROOT 直方图示例中,将能量范围改为 0.0–1500.0 keV,仍保留 100 个 bin。bin 宽度由 10 keV 变为 15 keV,填入的事例数不变。
  4. 说明 double& value 与 clear_measurement(&energy) 中 & 的区别:前者声明引用参数,后者取得变量地址。

小结¶

现在应能区分类和对象,读懂构造函数调用,区分 . 与 ->,理解 new 返回的指针和 & 传入的地址,并理解数组接口为什么通常还需要长度参数。

掌握这些语法后,即可阅读 ROOT Tutorial I — C++,进一步学习 TF1、TGraph、随机数、直方图、文件和 TTree 的功能与应用。