Cycoe@Home

与 C++ 的第七类接触-类

1.

类的基本思想是数据抽象和封装。数据抽象是一种依赖于接口和实现分离的编程技术。类的 接口包括用户所能执行的操作;类的实现则包括类的数据成员、负责接口实现的函数体以及 定义类所需的各种私有函数

1.1. 定义抽象数据类型

1.1.1. 使用 Sales_data 类的接口

Sales_data total;                      // 保存当前求和结果的变量
if (read(cin, total)) {                // 读入一笔交易
  Sales_data trans;                    // 保存下一条交易数据的变量
  while (read(cin, trans)) {           // 读入剩余的交易
    if (total.isbn() == trans.isbn())  // 检查 isbn
      total.combine(trans);            // 合并入 total
    else {
      print(cout, total) << endl;      // 输出结果
      total = trans;                   // 处理下一本书
    }
  }
  print(cout, total) << endl;          // 输出最后一条交易
 } else {
  cerr << "No data!" << endl;
}

1.1.2. 改进 Sales_data

#include <string>

struct Sales_data {
  // Sales_data 的成员函数:返回 book_no
  // 定义在类内部的函数是隐式的 inline 函数
  std::string isbn() const { return book_no; }
  // Sales_data 的成员函数:合并数据
  Sales_data& combine(const Sales_data&);
  double avg_price() const;

  std::string book_no;
  unsigned units_sold = 0;
  double revenue = 0.0;
};

// Sales_data 的非成员接口函数
Sales_data add(const Sales_data&, const Sales_data&);
std::ostream &print(std::ostream&, const Sales_data&);
std::istream &read(std::istream&, Sales_data&);

在 C++ 中对于成员函数的调用,可以认为编译器将该调用重写为了

// 伪代码
Sales_data::isbn(&total);

调用 isbn 方法时传入了 total 的地址,与 Python 的对象方法的机制一致。在 Python 中使用对象的成员变量使用 self ,在 C++ 中可以使用 this 隐式指针,不同之处在于 this 指针在形参中无需写出,在使用变量时可写可不写。因此 isbn 方法也可写为

std::string isbn() const { return this->book_no; }

另一个特殊之处是 isbn 方法声明中的 const ,此处的 const 其实修饰的是 isbnthis 指针。默认情况下, this 的类型为 Sales_data *const 是一个指针常量,在 isbn 方法中,我们并不会通过 this 指针对对象的成员进行修改,因此将 this 声明为 const Sales_data *const 可提高适用范围,因此上述声明可以想象为

// 伪代码
std::string Sales_data::isbn(const Sales_data *const this) { return this->book_no; }

下一步我们可以在类的外部定义成员函数,返回类型、参数列表和函数名都要与类内声明一 致

// 此处 :: 说明 avg_price 是声明在类 Sales_data 的作用域内
double Sales_data::avg_price() const {
  return units_sold? revenue / units_sold: 0;
}

在学习运算符重载之前,我们处理两个 Sales_data 对象相加定义了一个 combine 函数。 我们希望该函数能将调用函数的对象与作为参数传入的对象相加并返回前面的对象,因此此 处返回 this 对象

Sales_data &Sales_data::combine(const Sales_data &rhs) {
  units_sold += rhs.units_sold;
  revenue += rhs.revenue;
  return *this;            // 返回调用该函数的对象
}

1.1.3. 定义类相关的非成员函数

此处定义了一些类的辅助函数,它们与类密切相关。从操作上来说这些函数属于类的接口的 组成部分,但并不属于类本身。

// 输入的交易信息包括 ISBN、售出总数和售出价格
// 此处 iostream 对象使用的都是引用,因为 iostream 对象属于不能拷贝的对象
istream &read(istream &is, Sales_data &item) {
  double price = 0;
  is >> item.book_no >> item.units_sold >> price;
  item.revenue = price * item.units_sold;
  return is;
}

ostream &print(ostream &os, const Sales_data &item) {
  os << item.isbn() << " " << item.units_sold << " "
     << item.revenue << " " << item.avg_price();
  return os;
}

Sales_data add(const Sales_data &lhs, const Sales_data &rhs) {
  // 拷贝 lhs 的副本
  Sales_data sum = lhs;
  sum.combine(rhs);
  return sum;
}

1.1.4. 构造函数

类通过一个或几个特殊的成员函数来控制对象的初始化过程,这些函数被称为构造函数。无 论何时只要类的对象被创建就会执行构造函数。

若无显式地声明定义构造函数,编译器会为我们合成一个 默认构造函数 。若已经定义了一 个初始化函数,则编译器不会再为我们合成 默认构造函数 。这句话隐含的意思就是,如果 你定义了一个初始化函数,你就要全权负责初始化过程。默认构造函数的规则为:

  • 若存在类内初始值,则用它来初始化成员
  • 否则默认初始化该成员
// book_no 执行默认初始化为空字符串
std::string book_no;
// units_sold 初始化为 0
unsigned units_sold = 0;
// revenue 初始化为 0.0
double revenue = 0.0;

接下来为 Sales_data 类添加构造函数

class Sales_data {
  // 新增的构造函数
  // 使用 default 要求编译器生成默认构造函数
  Sales_data() = default;
  // 冒号之后花括号之前的部分被称为 =构造函数初始值列表=
  Sales_data(const std::string &s): book_no(s) { }
  Sales_data(const std::string &s, unsigned n, double p):
    book_no(s), units_sold(n), revenue(p*n) { }
  // 之前已有的成员
  std::string isbn() const { return book_no; }
  Sales_data& combine(const Sales_data&);
  double avg_price() const;
  std::string book_no;
  unsigned units_sold = 0;
  double revenue = 0.0;
};

也可以在类外进行构造函数的定义,但此时必须指明构造函数是哪个类的成员

Sales_data::Sales_data(std::istream &is) {
  read(is, *this);    // read 函数从 is 中读取一条交易并存入 this 对象
}

1.1.5. 拷贝、赋值和析构

与构造函数类似,拷贝对象的函数也会自动合成。一般来说,编译器生成的版本将对对象的 每个成员执行拷贝、赋值和销毁操作

// total = trans;  等价于
total.book_no = trans.book_no;
total.units_sold = trans.units_sold;
total.revenue = trans.revenue;

当类需要分配类对象之外的资源时,合成的版本常常会失效,管理动态内存的类通常不能依 赖合成版本

1.2. 访问控制与封装

1.2.1. 访问说明符

访问说明符

  • public 说明符之后的成员在整个程序可被访问,=public= 成员定义类的接口
  • private 说明符之后的成员可以被类的成员函数访问,但是不能被使用该类的代码访问, private 部分封装了类的实现细节

再一次定义 Sales_data

class Sales_data {
public:       // 接口
  Sales_data() = default;
  Sales_data(const std::string &s): book_no(s) { }
  Sales_data(const std::string &s, unsigned n, double p):
    book_no(s), units_sold(n), revenue(p*n) { }

  std::string isbn() const { return book_no; }
  Sales_data& combine(const Sales_data&);

private:
  double avg_price() const
  { return units_sold? revenue / units_sold: 0; }
  std::string book_no;
  unsigned units_sold = 0;
  double revenue = 0.0;
};

此处与前面例子的另一个区别是由 struct 改为了 class ,使用 structclass 都可以 定义类,区别在于访问权限的控制, class 定义的类中第一个访问说明符之前的成员默认 是 private 的,而 struct 定义的类中第一个访问说明符之前的成员默认是 public

1.2.2. 友元

其它函数和类无法直接访问 Sales_data 类中的 private 成员,但也有一种方法可以使其 它函数访问 Sales_data 类中成员,那就是使该函数成为 Sales_data 类的友元函数 (friend)。其中一个应用是重载针对 Sales_data 类的 <<>> 运算符

#include <iostream>

class Sales_data {
// 对 << 的重载函数并不是 Sales_data 类的成员,因此想要访问 Sales_data 的成员需
// 要声明为友元。友元不是类成员因此不受类的区域访问控制的约束,可放在类的任意位
// 置,但一般放在类的开头。友元的声明仅仅指定了访问的权限,而非通常意义上的函数
// 声明
friend std::ostream &operator<<(std::ostream &os, const Sales_data &sd);
public:
  Sales_data(const std::string &s, unsigned n, double p):
    book_no(s), units_sold(n), revenue(p*n) { }
private:
  std::string book_no;
  unsigned units_sold = 0;
  double revenue = 0.0;
};

// << 重载函数在类外部的声明,必须对类可见
// 一些编译器强制要求此声明
std::ostream &operator<<(std::ostream &os, const Sales_data &sd);

// 重载 << 运算符,两个参数分别为 << 运算符的 lhs 和 rhs 运算数
// ostream 和 istream 不能拷贝,只能引用
std::ostream &operator<<(std::ostream &os, const Sales_data &sd) {
  std::cout << "Book number is " << sd.book_no << std::endl;
  std::cout << "Sold Amount is " << sd.units_sold << std::endl;
  std::cout << "Revenue is " << sd.revenue;
  return os;
}

int main() {
  Sales_data sd("214.5.1", 25, 13);
  std::cout << sd << std::endl;
}
Book number is 214.5.1
Sold Amount is 25
Revenue is 325

另一方面,前面定义的 readprintadd 函数也要声明为友元函数

class Sales_data {
  // 为 Sales_data 类的非成员函数所做的友元声明
  friend istream &read(istream &is, Sales_data &item);
  friend ostream &print(ostream &os, const Sales_data &item);
  friend Sales_data add(const Sales_data &lhs, const Sales_data &rhs);
};
// Sales_data 接口的非成员组成部分的声明
istream &read(istream &is, Sales_data &item);
ostream &print(ostream &os, const Sales_data &item);
Sales_data add(const Sales_data &lhs, const Sales_data &rhs);

1.3. 类的其它特性

1.3.1. 类成员再探

我们以一个粒子类进行展示

class Particle {
public:
  // 类型成员,定义类内别名,并且必须出现在使用处之前
  typedef float pos;
private:
  pos x = 0.0;
  pos y = 0.0;
  pos z = 0.0;
  std::string name;
};

接下来为类增加接口成员函数,注意何时成员函数会被内联

class Particle {
public:
  typedef float pos;
  Particle() = default;              // 因自定义了构造函数,默认构造函数的声明是必须的
  // x, y, z 被类内初始值初始化为 0
  Particle(std::string t, std::string n):
    type(t), name(n) { }
  const std::string &get_type() const { return type; } // 隐式内联
  inline const std::string &get_name() const;          // 显式内联
  Particle &move(pos x, pos y, pos z);                 // 能在之后被设为内联
private:
  pos x = 0.0;        // 粒子位置
  pos y = 0.0;
  pos z = 0.0;
  std::string type;   // 粒子类型
  std::string name;   // 粒子名字
};

为成员函数添加实现

const std::string &get_name() const {   // 已在类的内部声明为 inline
  return name;
}

inline                                  // 可在函数定义处指定 inline
Particle &move(pos x, pos y, pos z) const {
  this->x = x;
  this->y = y;
  this->z = z;
  return *this;
}

使用 mutable 关键字声明一个可变数据成员

class Particle {
public:
  void count() const;
  size_t get_count() { return access_ctr; }
private:
  mutable size_t access_ctr = 0;    // 即使在 const 对象内也能被修改
  /*
   * 其它成员
   */
};

inline void Particle::count() const {
  ++this->access_ctr;   // 保存一个计数器,用于记录调用次数
                        // 此处的指针是 const 修饰的但仍可以修改成员变量
}

int main() {
  Particle p;
  p.count();
  p.count();
  std::cout << "Count is " << p.get_count() << std::endl;
}
Count is 2

接下来声明一个 Box 类用来装一组 Particle ,这个类包含一个 Particle 类型的 vector 。默认情况下,我们希望 Box 类总是拥有一个默认初始化的 Particle

class Box {
private:
  // 默认情况下,一个 Box 包含一个原点处的 Particle
  // 类内初始化必须使用 = 的初始化形式或花括号
  std::vector<Particle> ps{Particle("A", "zero")};
};

1.3.2. 返回 *this 的成员函数

继续添加一些成员函数

#include <string>
#include <iostream>

class Particle {
public:
  Particle &set_type(std::string t);
  Particle &set_name(std::string n);
  // info 函数并不改变 Particle 的成员,声明为 const
  const Particle &info(std::ostream &os) const;
private:
  std::string type;
  std::string name;
};
inline Particle &Particle::set_type(std::string t) {
  type = t;
  return *this;    // 将 this 对象作为左值返回
}
inline Particle &Particle::set_name(std::string n) {
  name = n;
  return *this;
}
// 注意此处的两个 const,因为传入的 this 对象是 const 的
// 因此返回的对象也应该是 const
inline const Particle &Particle::info(std::ostream &os) const {
  os << "Particle " << name << " has type " << type << std::endl;
  return *this;
}

int main() {
  Particle p;
  p.set_type("A").set_name("zero").info(std::cout);     // 此时可以使用链式调用
  // 以下调用是错误的,info 返回了 p 的 const 引用,作为 this 指针常量对象传入了
  // set_name,而 set_name 期望一个非常量对象的指针
  // p.info(std::cout).set_name("one");
}
Particle zero has type A

一种解决办法是对 info 进行重载,并都调用名为 do_info 的私有方法

#include <string>
#include <iostream>

class Particle {
public:
  Particle() = default;
  Particle(std::string t, std::string n) {
    type = t;
    name = n;
  }
  Particle &set_type(std::string t);
  Particle &set_name(std::string n);
  // 重载的两个 info 方法
  Particle &info(std::ostream &os)
  { do_info(os); return *this; }
  const Particle &info(std::ostream &os) const
  { do_info(os); return *this; }
private:
  std::string type;
  std::string name;
  void do_info(std::ostream &os) const {
    os << "Particle " << name << " has type " << type << std::endl;
  }
};
inline Particle &Particle::set_type(std::string t) {
  type = t;
  return *this;    // 将 this 对象作为左值返回
}
inline Particle &Particle::set_name(std::string n) {
  name = n;
  return *this;
}

int main() {
  Particle p;
  const Particle cp("const", "one");
  p.set_type("normal").set_name("zero").info(std::cout); // 调用非常量版本,info 返回了非常量对象
  cp.info(std::cout);                                    // 调用常量版本,info 返回了常量对象
}
Particle zero has type normal
Particle one has type const

1.3.3. 类类型

每个类定义了一个唯一的类型,对于两个类,即使它们的成员完全一样,这两个类也是不同 的。两个类中的同名成员因处在不同的作用域,也不是一回事。

struct First {
  int memi;
  int getMem();
};

struct Second {
  int memi;
  int getMem();
};

First obj1;
Second obj2 = obj1;    // 错误:obj1 和 obj2 是不同的类型

// 对于类来说,以下三种定义是等价的
First obj;
class Fisrt obj;
struct First obj;

类的声明可以与定义分离

class Particle;     // Particle 类的声明,也称为前向声明

// 我们必须完成类的定义编译器才能知道类占用的空间,因此类的成员不能是自己
// 但有一种特殊情况,类中可包含自身类型的引用或指针,也就是常用的链表
class Link_particle {
  Particle p;
  Link_particle *next;
  Link_particle *prev;
};

// 当一个类包含另一个类时注意声明顺序
class A;
class B {
  class A *ptr;
};
class A {
  class B b;
};

1.3.4. 友元再探

除函数外,也可以把一个类定义成友元。但是要注意,友元关系不具有继承性,假设友元关 系可继承那么我们就可以通过继承友元类来访问设计者不希望我们访问的私有成员,破坏了 类的封装性。同样以前面的 ParticleBox 类为例

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

class Box;

class Particle {
public:
  // 将 Box 类定义为友元,Box 可访问 Particle 对象中的私有部分
  friend class Box;
  // 将友元函数定义在类中,此时该函数为隐式内联
  friend std::ostream &operator<<(std::ostream &os, const Particle &p) {
    return os << "Particle " << p.name << " has type " << p.type << std::endl;
  }
  Particle() = default;
  Particle(std::string t, std::string n)
    :type(t), name(n) { }
private:
  float x = 0.0;
  float y = 0.0;
  float z = 0.0;
  std::string type;
  std::string name;
};

// 友元函数可以在类内定义,但一定要在类外声明
// 因为友元的声明的作用仅仅是影响访问权限,并非真正意义上的声明
// 同时友元函数也并非该类的成员,一些编译器会对此进行检查
std::ostream &operator<<(std::ostream &os, const Particle &p);

class Box {
public:
  friend std::ostream &operator<<(std::ostream &os, const Box &box);
  Box() = default;
  Box(std::vector<Particle> vp) :particles(vp) { }
  // 将 Box 中的所有类型为 ori_type 的 Particle 对象都设置为 new_type
  size_t set_type(std::string ori_type, std::string new_type);
private:
  std::vector<Particle> particles;
};

std::ostream &operator<<(std::ostream &os, const Box &box) {
  for (const Particle &p : box.particles)
    os << p;
  return os;
}

inline size_t Box::set_type(std::string ori_type, std::string new_type) {
  size_t count = 0;
  for (Particle &p : particles) {
    if (p.type == ori_type) {
      p.type = new_type;
      ++count;
    }
  }
  return count;
}

int main() {
  Particle p1("A", "zero");
  Particle p2("A", "one");
  Particle p3("B", "two");
  Box box({p1, p2, p3});
  std::cout << "========== Before set type ==========" << std::endl;
  std::cout << box;

  auto count = box.set_type("A", "C");
  std::cout << "\nTotally " << count << " particles from type A to type C." << std::endl;
  std::cout << "\n========== After set type ===========" << std::endl;
  std::cout << box;
}
Before set type.
Particle zero has type A
Particle one has type A
Particle two has type B

Totally 2 particles from type A to type C.

After set type.
Particle zero has type C
Particle one has type C
Particle two has type B

也可以仅将成员函数作为友元,如为 set_type 提供 Particle 类的访问权限,但此时必须 仔细地组织声明和定义的顺序。

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

// 声明 Particle,在 Box 的 set_type 方法中需要使用
class Particle;

// 定义 Box 类
class Box {
public:
  friend std::ostream &operator<<(std::ostream &os, const Box &box);
  Box() = default;
  Box(std::vector<Particle> vp) :particles(vp) { }
  // 声明 set_type 方法
  size_t set_type(std::string ori_type, std::string new_type);
private:
  std::vector<Particle> particles;
};

class Particle {
public:
  // 将 Box 类的 set_type 方法定义为友元
  friend size_t Box::set_type(std::string, std::string);
  // 将友元函数定义在类中,此时该函数为隐式内联
  friend std::ostream &operator<<(std::ostream &os, const Particle &p) {
    return os << "Particle " << p.name << " has type " << p.type << std::endl;
  }
  Particle() = default;
  Particle(std::string t, std::string n)
    :type(t), name(n) { }
private:
  float x = 0.0;
  float y = 0.0;
  float z = 0.0;
  std::string type;
  std::string name;
};

// 友元函数可以在类内定义,但一定要在类外声明
// 因为友元的声明的作用仅仅是影响访问权限,并非真正意义上的声明
// 同时友元函数也并非该类的成员,一些编译器会对此进行检查
std::ostream &operator<<(std::ostream &os, const Particle &p);

// Box 类的 << 重载需要使用 Particle 类,因为定义在后面
std::ostream &operator<<(std::ostream &os, const Box &box) {
  for (const Particle &p : box.particles)
    os << p;
  return os;
}

// 在定义了 Screen 对象之后才可以定义 set_type 函数
inline size_t Box::set_type(std::string ori_type, std::string new_type) {
  size_t count = 0;
  for (Particle &p : particles) {
    if (p.type == ori_type) {
      p.type = new_type;
      ++count;
    }
  }
  return count;
}

int main() {
  Particle p1("Big", "zero");
  Particle p2("Small", "one");
  Particle p3("Big", "two");
  Box box({p1, p2, p3});
  std::cout << "========== Before set type ==========" << std::endl;
  std::cout << box;

  auto count = box.set_type("Big", "Huge");
  std::cout << "\nTotally " << count << " particles from type A to type C." << std::endl;
  std::cout << "\n========== After set type ===========" << std::endl;
  std::cout << box;
}
========== Before set type ==========
Particle zero has type Big
Particle one has type Small
Particle two has type Big

Totally 2 particles from type A to type C.

========== After set type ===========
Particle zero has type Huge
Particle one has type Small
Particle two has type Huge

类和非成员函数的声明不是必须在友元声明之前,当一个名字第一次出现在一个友元声明中 时,我们默认该名字在当前作用域是可见的,这与调用函数不同

struct X {
  friend void f() { }
  X() { f(); }               // 错误:f 还未声明
  void g();
  void h();
};

void X::g() { return f(); }  // 错误:f 还未声明
void f();                    // 声明 f()
void X::h() { return f(); }  // 正确:此时 f() 已声明

1.4. 类的作用域

在定义类时,花括号内部为类内作用域。在类的作用域外部,只能通过对象、引用或指针使 用成员访问运算符 .-> 来访问成员。当我们在类外部定义成员时,则需要同时提供类 名和函数名。

// 一旦遇到类名,定义的剩余部分就在类的作用域内了
const std::string& Particle::get_type() const {
  // 此处在类的作用域内,因此直接使用 type
  return type;
}

// 但是当返回类型是类的成员类型时,需要指定类名,因为返回类型不在类的作用域内
Particle::pos Particle::get_x() const {
  return x;
}

1.4.1. 名字查找与类的作用域

类内部的成员的名字查找与普通函数的名字查找有所区别,类的定义过程分为两步

  • 首先编译成员的声明
  • 直到类全部可见后才编译函数体
using pos = double;
pos x = 0.1;

class Particle {
public:
  // 在 get_x 的声明中,返回类型 pos 会先在此位置之前进行查找,然后在外层进行查找
  // 而 get_x 定义中返回的 x 是在声明全部编译完成之后在整个类中进行查找
  pos get_x() { return x; }
private:
  // 错误:如果类已经使用过了外层作用域中的别名,就无法重新对其进行定义,即便使
  // 用相同的定义,因此类型名的定义通常在类的开始处
  // using pos = int;
  pos x = 1.1;
};

int main() {
  Particle p;
  std::cout << p.get_x() << std::endl;
}
1.1

接下来探讨一下类的成员与成员函数内的局部变量的查找优先级,以下代码只为了说明问题, 形参和成员变量使用同样的名字不是一个好习惯

using pos = double;
pos x = 0.1;
pos y = 0.1;

class Particle {
public:
  // 与全局作用域的别名相同,因此需要定义在最开始
  using pos = double;
  pos get_x(pos x) { return x; }           // 形参是在函数作用域内的,因此局部形
                                           // 参变量会 Mask 掉成员变量
  pos get_y(pos y) { return this->y; }     // 要想返回成员变量,显示地指明 this
  pos get_z(pos z) { return Particle::z; } // 或者指明类内作用域
  pos get_x_global(pos x) { return ::x; }  // 使用 :: 指明全局作用域
private:
  pos x = 1.1;
  pos y = 1.1;
  pos z = 1.1;
};

int main() {
  Particle p;
  std::cout << p.get_x(2.1) << std::endl;
  std::cout << p.get_y(2.1) << std::endl;
  std::cout << p.get_z(2.1) << std::endl;
  std::cout << p.get_x_global(2.1) << std::endl;
}
2.1
1.1
1.1
0.1

当成员函数的定义与类的定义分离时情况会更复杂一点,成员函数的中的名字查找也包括了 成员函数定义之前的作用域

class Particle {
public:
  using pos = double;
  void set_x(pos);
  pos x = 0;
};

Particle::pos multi(Particle::pos var) {
  return var * 2;
}

void Particle::set_x(pos var) {
  // var: 形参局部变量
  // x: 类的成员
  // multi: 全局函数
  x = multi(var);
}

int main() {
  Particle p;
  p.set_x(1);
  std:: cout << p.x << std::endl;
}
2
Author: Cycoe (cycoejoo@163.com)
Date: <2020-06-08 Mon 14:12>
Generator: Emacs 29.1 (Org mode 9.6.6)
Built: <2024-01-27 Sat 21:20>