跳转到内容

第十章 自定义类型(类)

  1. 类的定义格式(class vs struct 默认访问权限差异)
  2. 访问权限(private/public)
  3. 构造函数(默认/重载/复制构造)
  4. 析构函数(调用时机与顺序)
  5. 初始化列表的使用场景(必须使用的典型情况与初始化顺序)
  6. this 指针机制
  7. 静态成员(数据成员/成员函数的定义与访问)
  8. const 与类(常量对象/常量成员函数/常量数据成员)
  9. 友元(友元函数/友元类及其特性)

10.1 面向对象程序设计概述

跳转到:10.1 面向对象程序设计概述
  • 面向过程 vs 面向对象:

    • 面向过程:数据与数据操作是分离的,通过函数调用解决问题
    • 面向对象:数据和数据操作封装在一起构成对象,通过对象间交互解决问题 [来源:第10章 p5,面向过程 vs 面向对象]
  • 类与对象的关系:类是具有相同属性的事物的抽象;对象是类的一个具体实例 [来源:第10章 p6,类与它的对象]

  • 面向对象三大特点:
    1. 封装:将数据和基于数据的操作封装在一起,数据被隐藏在类型内部,通过对外接口与外部联系
    2. 继承:用已存在的类(基类)建立新的类(派生类),派生类继承基类的可访问成员(受访问控制影响),并可扩展新成员/新行为
    3. 多态:同一接口在不同对象上表现出不同的行为(典型:虚函数实现的运行时多态;也可理解为重载/模板的编译期多态) [来源:第10章 p8,面向对象的程序设计的特点]

  • 类定义格式:

    class 类名 {
    private:
    私有数据成员和成员函数;
    public:
    公有数据成员和成员函数;
    }; // 注意分号

    [来源:第10章 p10,类的定义]

  • 对象定义方式:

    类名 对象名; // 普通定义
    类名 *p = new 类名; // 动态分配
    delete p; // 释放动态对象

    [来源:第10章 p13,对象的定义]

  • 成员默认访问权限:class 默认是 private;struct 默认是 public [来源:第10章 p10,Notes]

  • 私有成员:只能被本类的成员函数(含 static 成员函数)以及友元(友元函数/友元类)访问;同类成员函数可访问任意同类对象的私有成员 [来源:第10章 p10]

  • 公有成员:是类的对外接口,可以被外部访问 [来源:第10章 p10]

  • 对象访问数据成员:

    对象名.数据成员名 // 通过对象名访问
    对象指针->数据成员名 // 通过指针访问

    [来源:第10章 p14,对象的操作]

  • 对象调用成员函数:

    对象名.成员函数名(实参表)
    对象指针->成员函数名(实参表)

    [来源:第10章 p14,对象的操作]

  • 成员函数在类外定义:

    返回类型 类名::函数名(形参表) { 函数体 }

    [来源:第10章 p12,示例 - DoubleArray]

  • ⚠️ 私有数据成员不能通过 对象名.成员名 直接访问,必须通过成员函数 [来源:第10章 p14,Notes]

  • ⚠️ 同类对象之间默认可以相互赋值(若拷贝赋值运算符可用);默认赋值是成员逐个赋值。若成员包含裸指针/共享资源,赋值后可能仍指向同一资源(浅拷贝风险),需自定义拷贝控制(Rule of Three/Five)或使用 RAII 类型(如 std::string/std::vector/std::unique_ptr 等) [来源:第10章 p14,Notes]


  • this 指针:成员函数有一个隐藏的指向本类型的指针形参 this,指向当前调用该成员函数的对象 [来源:第10章 p17,this指针]
  • 所有对象共享同一个成员函数定义,但各有各自的数据成员 [来源:第10章 p16,this指针]

  • 定义对象时,系统只为其数据成员分配空间,不为成员函数分配空间 [来源:第10章 p16]

  • 成员函数里可以显式使用 this 指针(如 this->成员名),编译器会自动添加 [来源:第10章 p17]


10.4 对象的构造与析构

跳转到:10.4 对象的构造与析构
  • 构造函数定义:

    类名::类名(形参表) { 函数体 }

    [来源:第10章 p21,构造函数的特点]

  • 对象定义时调用构造函数:

    类名 对象名(实参列表);
    类名 *p = new 类名(实参列表);

    [来源:第10章 p21]

  • 构造函数名字必须与类名相同 [来源:第10章 p21,构造函数的特点]

  • 构造函数不需要返回值,没有返回类型(不加 void) [来源:第10章 p21]

  • 构造函数可以重载,可以定义多个具有不同参数的构造函数 [来源:第10章 p22,构造函数的特点]

  • 默认构造函数:不带参数的构造函数 [来源:第10章 p22]

  • 如果没有定义任何构造函数,编译器会隐式声明/定义默认构造函数(可写作 类名() = default;)。其行为是对成员/基类执行默认初始化并调用其默认构造,是否“空函数体/平凡(trivial)”取决于成员/基类类型;且一旦用户声明了任意构造函数,编译器将不再自动生成默认构造函数(需手动提供或 = default) [来源:第10章 p22,Notes]

  • 构造函数重载示例:
    class Timer {
    private:
    int second;
    public:
    Timer(int t) { second = t; }
    Timer(int min, int sec) { second = 60 * min + sec; }
    Timer(int h, int min, int sec) { second = sec + 60*min + 3600*h; }
    };
    Timer a(20), b(1, 20), c(1, 1, 10);
    [来源:第10章 p23,重载构造函数示例]
  • ⚠️ 如果定义对象时没有初始化参数(如 Timer t;),必须有默认构造函数,否则编译错误 [来源:第10章 p23,Notes]

  • 初始化列表格式:位于函数头和函数体之间,以冒号开头,逗号分隔
    类名::类名(形参表): 成员1(值1), 成员2(值2), ... { 函数体 }
    [来源:第10章 p24,初始化列表方法]
  • 对象构造过程:
    1. 先初始化(构造)基类子对象与成员子对象,然后才进入构造函数体
    2. 成员初始化顺序由“基类声明顺序 + 成员在类中声明顺序”决定,与初始化列表的书写顺序无关
    3. 未出现在初始化列表中的成员,会使用类内初始值(若有)或执行默认初始化 [来源:第10章 p24]
  • 必须使用初始化列表(或等价的成员初始值)的常见情况:
    1. const 数据成员(必须在初始化阶段给值)
    2. 引用数据成员(reference 必须初始化,不能后赋值)
    3. 成员对象或基类没有可用的默认构造函数,或需要显式指定其构造方式(调用特定构造函数) [来源:第10章 p25-26,初始化列表方法]
  • 初始化列表示例:
    class A {
    private:
    const int a;
    public:
    A(int b): a(b) { } // 必须用初始化列表初始化const成员
    };
    [来源:第10章 p26]
  • ⚠️ const 数据成员不能在构造函数体中赋值,必须在初始化阶段完成初始化:用构造函数初始化列表(传统写法),或 C++11 起使用类内成员初始值(in-class member initializer) [来源:第10章 p26,Notes]

10.4.3 复制构造函数

跳转到:10.4.3 复制构造函数
  • 复制构造函数格式:
    类名(const 类名 &other);
    //(不推荐)类名(类名 &other); // 不能绑定到 const 对象/临时对象
    [来源:第10章 p27,复制构造函数]
  • 如果没有定义,编译器自动生成默认复制构造函数,将实参对象的数据成员对应赋值给新对象 [来源:第10章 p27]

  • 若类使用裸指针等自行管理动态资源(new/delete),默认拷贝/赋值往往会产生浅拷贝、重复释放等问题。应遵循“三法则/五法则”:至少自定义(或禁用)拷贝构造、拷贝赋值、析构(必要时再加移动构造/移动赋值),或改用 RAII 成员(如 std::vector/std::string/std::unique_ptr)管理资源 [来源:第10章 p27,Notes]

  • 复制构造函数可能被调用的常见情况:
    1. 用一个对象初始化另一个对象:A a2(a1); 或 A a2 = a1;
    2. 对象作为函数参数(值传递):void f(A arr);
    3. 对象作为函数返回值:A f() { A a; return a; }
      • 注:现代 C++(尤其 C++17 起)很多返回值场景会发生/强制发生拷贝省略(copy elision),因此可能不调用拷贝/移动构造;若要观察调用需在特定编译器上关闭省略或降低优化等级 [来源:第10章 p28,调用复制构造函数的情景]
  • ⚠️ 默认复制构造/默认赋值是成员逐个拷贝/赋值。若成员是裸指针,就只会复制地址(典型“浅拷贝”),可能导致多个对象指向同一块内存并引发重复释放;但若成员是具有值语义的类型(如 std::string、std::vector),其自身拷贝通常是安全的 [来源:第10章 p27,Notes]

  • ⚠️ 引用传递不调用复制构造函数:void f(A &arr); [来源:第10章 p32,Notes]


  • 析构函数格式:
    类名::~类名() { 函数体 }
    [来源:第10章 p29,析构函数]
  • 析构函数名字是 ~类名 [来源:第10章 p29]

  • 析构函数没有参数,不能重载 [来源:第10章 p29]

  • 析构函数没有返回值 [来源:第10章 p29]

  • 若没有显式定义,编译器会生成默认析构函数:它会依次调用成员与基类的析构函数(并非简单“空函数体”)。但如果你用裸指针在构造中 new 了内存,默认析构不会自动 delete,需要你在析构中释放(或改用 RAII 成员) [来源:第10章 p29]

  • 对象撤销时,系统自动调用析构函数 [来源:第10章 p29]


10.4.5 对象生命周期与构造/析构顺序

跳转到:10.4.5 对象生命周期与构造/析构顺序
  • 不同存储类型对象的生命周期:

    • 全局对象(静态存储期对象):在进入 main 之前完成初始化,在程序结束时析构;注意不同源文件(不同翻译单元)间的初始化/析构顺序一般未指定
    • 静态局部对象:第一次执行到时构造(只构造一次),程序结束后析构
    • 自动局部对象:每次进入作用域时构造,离开作用域时析构
    • 动态对象:new 时构造,delete 时析构 [来源:第10章 p30,验证对象的生命周期]
  • 析构顺序:同一作用域内遵循“后构造先析构”(LIFO);对全局/静态对象在同一翻译单元内也是先构造后析构,但不同翻译单元之间的顺序一般未指定 [来源:第10章 p30,Notes]

  • 【执行结果分析】 p30 示例程序输出:

    constructor 0 // 全局对象
    constructor 3 // main局部对象
    f1:
    constructor 1 // f1静态对象(只构造一次)
    constructor 2 // f1局部对象
    destructor 2 // f1局部对象析构
    f1:
    constructor 2 // f1局部对象再次构造
    destructor 2 // f1局部对象析构
    destructor 3 // main局部对象析构
    destructor 1 // f1静态对象析构
    destructor 0 // 全局对象析构

    [来源:第10章 p30,Notes]

  • 【执行结果分析】 p31 习题输出:

    Construct 7
    Destruct 7
    7

    要点:p.check() 返回的是 int 值,不是对象,所以返回时不调用复制构造 [来源:第10章 p31,Notes]

  • 【执行结果分析】 p32 习题输出:

    Constructor 0 // 全局
    Constructor 2 // main obj2
    Constructor 3 // main obj3
    Copy constructor 2 // f1形参(值传递)
    Copy constructor 2 // f1内static对象
    Destructor 2 // f1形参析构
    Copy constructor 3 // f2内obj1(引用不复制,但内部用复制构造)
    Destructor 3 // f2内obj1析构
    Destructor 3 // main obj3析构
    Destructor 3 // main obj2析构(值已被改为3)
    Destructor 2 // f1 static对象析构
    Destructor 0 // 全局析构

    [来源:第10章 p32,Notes]

  • 【执行结果分析】 p44 习题输出:

    Object 1(x:1 y:2) begins: // a1
    Object 2(x:5 y:10) begins: // a2(使用默认参数)
    Object 3(x:5 y:10) begins: // p指向的动态对象(注意:a3(a1)用复制构造但没输出)
    Object 1 ends: // a1(注意:动态对象未delete所以不析构)
    Object 2 ends: // a2
    Object 1 ends: // a3(ID与a1相同因为复制了)

    要点:复制构造函数未定义时使用默认复制构造,不会调用用户定义的构造函数 [来源:第10章 p44,Notes]


  • 常量对象定义:
    const 类名 对象名(参数表);
    [来源:第10章 p34,常量对象]
  • 常量对象(const 类名 obj;)表示该对象被 const 限定:不能通过该对象修改其状态(非 mutable 成员不可修改)。对象成员仍在构造阶段完成初始化;并非“数据成员类型都变成 const”,而是“通过 const 对象只能以只读方式访问成员” [来源:第10章 p34]

  • 常量对象只能调用常量成员函数 [来源:第10章 p34]


10.5.2 常量成员函数

跳转到:10.5.2 常量成员函数
  • 常量成员函数定义:声明和定义都必须在函数头后加 const
    返回类型 函数名(参数表) const { 函数体 }
    [来源:第10章 p34,常量成员函数]
  • 常量成员函数不能修改非 mutable 的数据成员(但可以修改 mutable 成员,且可修改静态数据成员) [来源:第10章 p34]

  • 常量成员函数中不能对当前对象(*this)调用非常量的非静态成员函数(可调用静态成员函数,也可对非 const 对象调用其非常量成员函数) [来源:第10章 p34]

  • 在常量成员函数中修改数据成员或调用非常量成员函数,编译器会报错 [来源:第10章 p34]


10.5.3 常量数据成员

跳转到:10.5.3 常量数据成员
  • 常量数据成员定义:
    class A {
    const int SIZE;
    };
    [来源:第10章 p36,常量数据成员]
  • 常量数据成员必须在初始化阶段完成初始化:可通过构造函数初始化列表初始化;C++11 起也可使用类内成员初始值(in-class member initializer) [来源:第10章 p36]

  • 不能在构造函数体中再赋值;是否能在类定义处给初值取决于语言标准:C++11 起允许类内成员初始值;C++98/03 下非静态 const 成员只能用初始化列表(静态 const 整型/枚举等另论) [来源:第10章 p36]

  • 不同对象的常量数据成员可以有不同的值(通过不同的构造函数调用初始化) [来源:第10章 p36,Notes]

class A {
private:
const int SIZE;
public:
A(int size): SIZE(size) { }
};
A a(100); // a的SIZE为100
A b(200); // b的SIZE为200

[来源:第10章 p36]


10.6.1 静态数据成员

跳转到:10.6.1 静态数据成员
  • 静态数据成员声明(类内):

    static 类型 成员名;

    [来源:第10章 p38,静态数据成员]

  • 静态数据成员定义(传统写法:类外提供一次定义;C++17 起可用 inline static 在类内定义,视编译环境而定):

    类型 类名::成员名 = 初值;

    [来源:第10章 p38]

  • 静态数据成员是所有对象共享的数据,属于类而不属于具体某个对象 [来源:第10章 p38]

  • 静态数据成员需要单独定义一次,并只分配一次空间 [来源:第10章 p38]

  • 为对象分配空间时,不包括静态数据成员的空间 [来源:第10章 p38]

  • 静态数据成员的定义不能在函数体内 [来源:第10章 p38,Notes]

  • 访问静态数据成员:
    类名::静态成员名
    对象名.静态成员名
    [来源:第10章 p38]

10.6.2 静态常量成员

跳转到:10.6.2 静态常量成员
  • 静态常量成员声明与初始化(传统规则,常见于整型/枚举型):
    // 例:整型静态常量成员可在类内给出常量表达式初值
    static const int N = 100;
    • 补充:若该成员被 ODR-used(如取地址),仍可能需要类外定义;C++11+ 推荐 static constexpr,C++17+ 可用 inline static(视课程/编译环境而定) [来源:第10章 p39,静态的常量成员]
  • 静态常量成员是整个类所有对象的共享常量 [来源:第10章 p39]

  • 非 const 的静态成员按传统规则不能在类定义内赋值(需类外定义);C++17 起可用 inline static 在类内定义并初始化(视编译环境支持) [来源:第10章 p39]

  • ⚠️ 常量数据成员 vs 静态常量数据成员:
    • 常量数据成员:属于每个对象,不同对象可以有不同的值
    • 静态常量数据成员:属于类,所有对象共享同一个值 [来源:第10章 p39]

10.6.3 静态成员函数

跳转到:10.6.3 静态成员函数
  • 类内声明:

    static 返回类型 函数名(参数表);

    [来源:第10章 p40,静态成员函数]

  • 类外定义:不能加 static

    返回类型 类名::函数名(参数表) { 函数体 }

    [来源:第10章 p40]

  • 静态成员函数没有 this,因此不能直接访问非静态成员(需要显式传入对象/指针/引用才能访问);但可以访问静态成员以及其他静态成员函数 [来源:第10章 p40]

  • 静态成员函数没有 this 指针 【整理自 PPT】(由静态成员函数不能访问非静态成员推得)

  • 调用静态成员函数:
    类名::静态成员函数()
    对象名.静态成员函数()
    对象指针->静态成员函数()
    [来源:第10章 p40]
  • 【执行结果分析】 p43 示例程序输出:
    总对象数:0 存活对象数:0
    总对象数:2 存活对象数:2
    总对象数:4 存活对象数:4
    总对象数:4 存活对象数:3
    总对象数:4 存活对象数:2
    [来源:第10章 p43,Notes]

  • 友元函数声明(类内):

    friend 返回类型 函数名(参数表);

    [来源:第10章 p47,友元]

  • 友元函数可以定义在类内或类外;不是成员函数,调用时不需要 类名:: [来源:第10章 p48,Notes]

  • 友元函数可以访问类的私有成员 [来源:第10章 p47]

  • 友元类声明:
    friend class 类名;
    [来源:第10章 p47]
  • 如果 B 是 A 的友元类,则 B 的所有成员函数都可以访问 A 的私有成员 [来源:第10章 p47]

  • 友元关系是授予的而不是索取的:类 A 必须显式声明函数 f 是它的友元,f 不能自称是 A 的友元 [来源:第10章 p49,友元特点]

  • 友元关系不是对称关系:A 声明 B 是友元,不意味着 A 也是 B 的友元 [来源:第10章 p49]

  • 友元关系不是传递关系:A 是 B 的友元,B 是 C 的友元,不意味着 A 是 C 的友元 [来源:第10章 p49]

  • 将类 B 的成员函数定义为类 A 的友元(特殊语法):
    class A; // 1. 先前向声明 A
    class B { // 2. 定义 B
    void f(const A &x); // 此处先声明;若要访问 A 的成员,定义需在 A 完整定义之后
    };
    class A { // 3. 定义 A
    friend void B::f(const A &x);
    // ...
    };
    void B::f(const A &x) { } // 4. 最后在类外定义 B 的友元函数
    [来源:第10章 p49,Notes;p53,习题]

  • p45 习题答案:

    CConst(int d = 0): len(d) { size += d; }
    void print() const
    static void show()
    int CConst::size = 2;

    [来源:第10章 p45,Notes]

  • p50 习题答案:

    friend float Dis(Point &p, Point &q)
    Point(float a, float b): x(a), y(b)
    result = sqrt((q.x-p.x)*(q.x-p.x) + (q.y-p.y)*(q.y-p.y))

    [来源:第10章 p50,Notes]

  • p52 习题答案:

    A(int a, int b): y(b) { x = a; }
    friend void disp(const A &a);
    void print() const { cout << x << " " << y << endl; }

    [来源:第10章 p52,Notes]

  • p53 习题答案:

    class SeaAnemone;
    void disp(const SeaAnemone &x);
    friend class Clownfish;
    SeaAnemone sa1("Tony", 4);
    SeaAnemone sa2("Tim", 2);
    Clownfish cl("Nemo", 4);

    [来源:第10章 p53,Notes]


总结(来源:p54)

跳转到:总结(来源:p54)
  1. 面向对象程序设计的基本思想是如何根据应用的需求创造合适的类型,用该类型的对象来解决特定的问题
  2. 本章主要介绍了:
    • 如何定义一个类,了解封装的概念
    • 如何定义类的对象
    • 如何实现对象的初始化
    • 如何操作对象