Skip to content

Chapter 4 Object Interaction

1 Introduction

面向对象程序的基本看法
  • 对象之间通过发送/接收消息来协作
  • 消息由发送者构造、由接收者解释、最终由成员函数实现
  • 消息可能返回结果,也可能改变接收者状态(side effects)
  • 封装(Encapsulation):把数据和操作这些数据的方法打包到对象中,并隐藏实现细节
  • 抽象(Abstraction):忽略底层细节,只关注更高层的问题描述
  • 模块化(Modularization):把整体拆成边界清晰、可独立构造和检查的模块

Clock Display Example

一个 ClockDisplay 可以由 hoursminutes 两个 NumberDisplay 组成:

class ClockDisplay {
    NumberDisplay hours;
    NumberDisplay minutes;
    // constructor and methods...
};

class NumberDisplay {
    int limit;
    int value;
    // constructor and methods...
};

2 Constructors and Destructors

2. 1 构造函数(constructor)

  • 只要类定义了构造函数,编译器就在对象创建时自动调用
构造函数自动调用
class X {
    int i;
public:
    X();
};

void f() {
    X a;   // 在这里自动调用 a.X()
}
  • 构造函数名与类名相同
  • 构造函数可以带参数,用于指定对象的初始状态
带参数构造
class Tree {
public:
    Tree(int i) { /* ... */ }
};

Tree t(12);

2. 2 析构函数(destructor)

  • 负责清理(cleanup),和初始化同样重要
  • 名字形式为 ~ClassName()没有参数,当对象离开作用域时由编译器自动调用
析构函数声明
class Y {
public:
    ~Y();
};

存储分配与构造调用

  • 编译器会在进入作用域时为该作用域中的对象预留存储
  • 但构造函数要等到对象定义语句真正执行到时才调用

2. 3 聚合初始化(Aggregate Initialization)

  • 数组和简单聚合类型可以直接用花括号初始化

数组与结构体初始化

int a[5] = {1, 2, 3, 4, 5};
int b[6] = {5};             // 其余元素补 0
int c[]  = {1, 2, 3, 4};    // 自动推断长度

struct X { int i; float f; char c; };
X x1 = {1, 2.2, 'c'};
X x2[3] = { {1, 1.1, 'a'}, {2, 2.2, 'b'} };
  • 能不带参数调用的构造函数就是默认构造函数(default constructor)

带构造函数的聚合初始化

struct Y {
    float f;
    int i;
    Y(int a);
};

// 全初始化,不需要默认构造函数
Y y1[] = { Y(1), Y(2), Y(3) };

// 第二个元素没有显式初始化参数,需要默认构造函数
Y y2[2] = { Y(1) };

// 完全没有提供初始化列表,需要默认构造函数
Y y3[7];

// 没有提供任何初始化参数,需要默认构造函数
Y y4;

自动默认构造函数(auto default constructor)

  • 若类完全没有定义任何构造函数,编译器才会自动生成默认构造函数
  • 一旦你自己声明了构造函数,编译器通常不再自动补默认构造

3 Fields, Local Variables and this

局部变量 vs. 字段

  • Field(字段/成员变量):定义在成员函数外,跟随对象整个生命周期存在
  • Local variable(局部变量):定义在函数/方法内部,只在调用期间存在
  • 字段具有类作用域,可在该类任意成员函数中访问

示例

int TicketMachine::refundBalance() {
    int amountToRefund = balance;
    balance = 0;
    return amountToRefund;
}
  • amountToRefund 的生命周期只持续到函数结束
  • balance 属于对象状态,只要对象活着就一直存在
  • 如果局部变量与字段同名,局部变量会遮蔽字段

this 指针

  • 每个非静态成员函数都有一个隐藏参数 this
  • this 的类型是指向当前对象的指针
  • 成员函数调用本质上是把调用者对象地址传进去

this 的等价理解

void Point::print();
// 可理解为
void Point::print(Point* this);

Point a;
a.print();
// 可理解为
Point::print(&a);

4 Initializer List

  • 初始化列表规则可初始化任意类型的数据成员,比在构造函数体内赋值更直接、更高效
  • 真正的初始化顺序由成员声明顺序决定,与初始化列表中的顺序无关
  • 析构顺序与初始化顺序相反

成员初始化列表

class Point {
private:
    const float x, y;
public:
    Point(float xa, float ya)
        : y(ya), x(xa) {}
};

初始化 vs. 赋值

Student::Student(string s) : name(s) {}     // 初始化
Student::Student(string s) { name = s; }    // 赋值
  • 初始化发生在进入构造函数体之前
  • 赋值发生在构造函数体内部
  • 若采用赋值写法,成员对象必须先能默认构造

5 Function Overloading and Default Arguments

  • 函数重载(Function overloading):同名函数,只要参数列表不同即可共存

重载示例

void print(char* str, int width); // #1
void print(double d, int width);  // #2
void print(long l, int width);    // #3
void print(int i, int width);     // #4
void print(char* str);            // #5

print("Pancakes", 15); // #1
print("Syrup");        // #5
print(1999.0, 10);     // #2
print(1999, 12);       // #4
print(1999L, 15);      // #3

重载与自动类型转换

void f(int i);
void f(double d);

f('a');   // 可能歧义
f(2);     // 匹配 int
f(2L);    // 可能歧义
f(3.2f);  // 匹配 double
  • 默认参数(Default arguments):写在函数声明中,调用时省略实参,编译器自动补上默认值

默认参数必须从右往左连续提供

int harpo(int n, int m = 4, int j = 5);
int chico(int n, int m = 6, int j); // illegal
int groucho(int k = 1, int m = 2, int n = 3);

beeps = harpo(2);          // 等价于 harpo(2, 4, 5)   
beeps = harpo(1, 8);       // 等价于 harpo(1, 8, 5)
beeps = harpo(8, 7, 6);

6 Const Objects and Const Member Functions

  • const 对象只能调用 const 成员函数,const 成员函数不能修改对象状态
示例
int Date::set_day(int d) {
    // ...error check d here...
    day = d;      // ok, non-const so can modify
}

int Date::get_day() const {
    day++;        // ERROR modifies data member
    set_day(12);  // ERROR calls non-const member
    return day;   // ok
}
  • const 成员函数声明和定义中都要重复写 const,非修改型成员函数应尽量声明为 const
示例
int get_day() const;
int get_day() const { return day };

const / non-const 对象对比

Date when(1, 1, 2001);
int day = when.get_day();   // OK
when.set_day(13); // OK

const Date birthday(12, 25, 1994);
int day = birthday.get_day();   // OK
birthday.set_day(14); // ERROR
  • 类中的常量成员必须在初始化列表中初始化

类中的编译期常量

class HasArray {
    static const int size = 100;
    int array[size];    // ERROR!
};

定义普通数组时,大小必须是编译期常量,const int size 是非静态的 const 成员变量,属于每一个对象,不是编译期常量,编译器无法确定数组大小,故会报错。

方案 1:将该常量设为 static(静态)
// 该成员整个类仅共享一份实例(而非每个对象各有一份)
static const int size = 100;
方案 2:使用匿名枚举的变通写法(经典 Hack)
class HasArray {
    // 枚举成员本身就是编译期常量
    enum { size = 100 };
    int array[size];
};

7 Inline Functions

  • 普通函数调用有开销:压参数、压返回地址、准备返回值、恢复现场
  • 内联函数(inline functions)试图把函数体直接展开到调用点,减少调用开销
示例
inline int f(int i) {
    return i * 2;
}

int main() {
    int a = 4;
    int b = f(a); // 被展开为 int b = a + a;
}
  • inline 函数定义通常写在头文件中,可能不会在目标文件(.obj/.o)中生成任何可执行代码
  • inline 关键字本身是声明性修饰,而非定义,不必担心因 #include 带来传统意义的重复定义
  • 内联函数是以空间为代价,换取运行速度的提升
  • inline 比宏安全,有类型检查,没有宏替换副作用
inline vs. macro
#define unsafe(i) ((i) >= 0 ? (i) : -(i))

inline int safe(int i) {
    return i >= 0 ? i : -i;
}
  • 在类定义内部直接写出的成员函数,默认就是 inline

访问函数(Access functions)

  • 小型函数,作用是读取或修改对象的部分状态,也就是对象的一个或多个内部成员变量。
class Cup {
    int color;
public:
    int getColor() { return color; }
    void setColor(int color) {
        this->color = color;
    }
};
  • 小函数、频繁调用函数适合考虑 inline,大函数、递归函数通常不适合
  • inline 关键字不保证函数内联,编译器可能判定函数体积过大或检测到函数存在递归调用(内联函数不支持递归,或递归函数几乎无法被内联),也可能是使用的特定编译器本身就未实现该内联特性。

8 Type of Function Parameters and Return Value

8. 1 参数传入方式(way in)

  1. 值传递
    • void f(Student i);
    • 函数内创建原对象的副本,修改不影响外部对象,有拷贝开销
  2. 指针传递
    • void f(Student *p);
    • 不修改对象时加 const,即 void f(const Student *p);
  3. 引用传递
    • void f(Student& i);
    • 不修改对象时加 const,即 void f(const Student& i);

8. 2 返回值传出方式(way out)

  1. 返回对象
    • Student f();
    • 返回时创建新对象,安全无悬空风险
  2. 返回指针
    • Student* f();
    • 指针必须指向有效内存,禁止指向局部变量
  3. 返回引用
    • Student& f();
    • 引用必须绑定有效对象,禁止引用局部变量

8. 3 内存管理难点

示例

char *foo() {
    char *p = new char[10];
    strcpy(p, "something");
    return p;
}
void bar() {
    char *p = foo();
    printf("%s", p);
    delete p;
}
  • 内存分配(new)与释放(delete分离,易漏释放导致内存泄漏
  • 要求调用者必须手动释放,责任不明确

注意

  1. 需要存储对象 → 用值传递传入
  2. 读取对象值 → 传入 const 指针/引用
  3. 需要修改对象 → 传入指针/引用
  4. 函数内新建对象 → 直接返回对象(而非指针)
  5. 返回指针/引用 → 只能指向传入的对象
  6. 绝对禁止:在函数内 new 内存并返回指针