9. 类

67 阅读18分钟

9. 类

类及类中的基本概念

  1. 面向过程编程和面向对象编程

    1. 面向过程编程,首先考虑要遵循的步骤(过程),然后考虑如何表示这些数据。
    2. 面向对象编程,将从对象开始考虑,这个对象如何表示--数据成员。这个对象可以做什么--方法(接口)。这就构成了类--描述对象的数据以及描述用户与数据交互所需的操作。这个过程就叫抽象
  2. 指定基本类型发生了什么?

    1. 决定数据对象需要的内存数量。
    2. 决定如何解释内存中的位。
    3. 决定可以使用数据对象执行的操作或方法。

    总之,基本类型更加底层,它描述了向下--与内存存储的关系。向上--他描述了如何进行操作。而我们自己定义类就是在基本类型的基础上(不用管向下对内存的操作)组合出我们需要的类。

  3. C++将类的接口放在头文件中,并将实现方法放在源代码中。这很C++不是吗?通过包含头文件,将类的声明包裹进自己的文件,从而可以调用它(这是对函数的调用所必须的---包含其声明)。但又不包含其实现(函数要单定义原则)。如果要在.h中实现函数定义,它将自动称为内联函数--它和其他函数的工作方式不同--他要求在每一个使用他们的文件中都对其进行定义,但也单定义。

  4. 封装和数据隐藏

    使用类对象可以直接访问公有部分,但只能通过公有函数访问对象的私有成员,这就实现了数据的隐藏--将数据放在私有部分。

  5. 一个简单的类及其实现

    // stock.h
    #ifndef STOCK_H_
    #define STOCK_H_class Stock
    {
    private:
        std::string company_;
        long shares_;
        ...;
        
    public:
        void acquired(const std::string & co, long n, double pr);
        ...;
        
    }
    ​
    #endif// stock.cpp#include <iostream>
    #include "stock.h"void Stock::acquired(const std::string & co, long n, double pr)
    {
        company_ = co;
        if(n < 0)
        {
            std::cout << "Number of shares can't be negative; "
                << company << " shares set to 0.\n";
            shares_ = 0;
        }
        else
        {
            share = n;
        }
    }
    
  6. 每个类对象都有自己的存储空间,用于存储其内部变量和类成员,但同一个类的所有对象都共用同一组类方法。

  7. const成员函数。如果不想方法修改数据成员,那么应将方法声明为const

    void Stock::Show() const;  // 后置const
    

构造与析构

构造函数

class Stock
{
private:
    ...;
public:
    Stock();  // 构造函数
    ~Stock();  // 析构函数
}
  1. 什么是构造函数

    1. 构造函数是类的一个方法,专门用于构造新对象,将值赋给他的数据成员。即为类提供初始化的方法。当实例化类时,编译器将自动调用构造函数。
  2. 为什么需要构造函数

    如果不对其进行初始化,那么这个对象便无法进行准确的表示--他的数据不能表示我,那又从何谈起他是我的抽象化呢。也无法使用类的方法。

  3. 怎样声明一个构造函数

    与类同名,但没有返回值,原型位于类声明的共有部分。

  4. 默认构造函数

    当定义了构造函数后,必须显式的为类提供默认构造函数--当且仅当没有定义任何构造函数时,编译器才会提供默认构造函数。

    这有两种方式:

    • 提供一个无参数的构造函数。

      Stock();
      Stock(const string & co, int n);
      ​
      // cpp
      Stock::Stock()
      {
          // 对所有类成员进行初始化。
      }
      
    • 给已有的构造函数提提供默认值。

      Stock(const string & co = "", int n = 0);
      
  5. 使用类构造函数的两种方式

    • 显式调用构造函数

      // 啥叫显式?就是通过函数名Stock()调用。
      Stock food = Stock("World Cabbage", 250);
      
    • 隐式调用构造函数

      // 啥叫隐式? 不体现出函数名。
      Stock food("World Cabbage", 250);
      

析构函数

  1. 什么是析构函数

    对过期的类对象完成清理的函数--释放内存,当类对象过期(自动对象、静态对象自动过期、动态对象new-delete手动过期)后被自动调用。

  2. 为什么需要析构函数

    主要是为动态内存分配准备的--使用new分配数据成员的内存,不会被自动释放。如果没使用动态内存分配,他就可有可无(指显式定义,如果不定义,编译器会自动提供一个。)

  3. 怎样声明一个析构函数

    比构造函数名前多一个~

  4. 一个示例

    // 如果其数据对象没有使用动态内存分配(在构造函数中没有使用new)
    // 那么其析构函数中什么都不用做
    // .h
    class Stock
    {
    private:
        std::string company_;
        long shares_;
        ...;
        
    public:
        Stock();
        ~Stock();
        ...;
    }
    // .cpp
    Stock::Stock()
    {
        company_ = "\0";
        shares_ = 0;
        
    }
    Stock::~Stock()
    {
        // 啥也不用写
    }
    ​
    ​
    // 如果其数据对象使用了new
    // 在析构函数中使用delete将其释放
    class Stock
    {
    private:
        std::string company_;
        long shares_;
        ...;
        
    public:
        Stock();
        ~Stock();
        ...;
    }
    // .cpp
    Stock::Stock()
    {
        company_ = new std::string("\0");
        shares_ = new long(0);
        
    }
    Stock::~Stock()
    {
        delete company_;
        delete shares_;
    }
    ​
    

this指针与对象数组

this

// 括号中const,指出不会修改被显式访问(传入参数)的对象的值
// 后置的const,指出不会修改被隐式访问的对象的值
// 而前置的const是因为返回了两个const对象之一的引用,那么返回类型也应该为const
// 因为不能把const值赋给非const
const Stock & topval(const Stock & s) const;

// 实现
const Stock & Stock::topval(const Stock & s) const
{
    if(s.total_val > total_val)
    {
        return s;
    }
    else
    {
        // 返回类型为引用,意味着返回的是调用对象本身,
        // 如何表示调用者呢?使用this指针
        return *this;  
    }
}

this指针用来指向调用成员函数的对象。所有的类方法都将this指针设置为调用它的对象的地址

对象数组

Stock mystuff[4];  // 不初始化
Stock stocks[4] = 
{
    Stock("name1", 250),
    Stock("name2", 550),
    Stock("name3", 20)
    
};  // 列表初始化,使用了构造函数进行初始化

对象数组的初始化过程:

  1. 首先使用默认构造函数创建数组元素,
  2. 然后花括号中的构造函数将创建临时对象,
  3. 最后将临时对象的内容复制到相应的元素中。

在类中定义常量

  1. 共有两种方式:

    1. 使用static关键字。

      // 因为在程序开始运行时,编译器就会为静态变量分配空间,放在数据区
      class name
      {
          static const int Months = 12;
      }
      
    2. 使用枚举

      // 在类中声明的枚举作用域为整个类,因此可以用枚举为整型变量提供作用域为整个类的符号名称
      // 枚举类型是常量的另一种表示形式,它被直接编译到代码区。
      class name
      {
          enum {Max = 10};
      }
      // 这并不代表这个枚举类型是类的数据成员,
      // 用这个方式创建枚举不会创建类的数据成员,也就是说,所有对象中都不包含枚举。
      // 新枚举  或将class 替换为static
      enum class egg {kSmall, kMedium, kLarge, KJumbo};
      enum class t_shirt {kSmall, kMedium, kLarge, KJumbo};
      //通过作用域运算符使用他们
      egg::kSmall;
      t_shirt::kMedium;
      
  2. 为什么要如此?

    因为类只是描述了对象的形式,并没有创建对象。因此再创建对象之前,没有用于存储的空间。

运算符重载

返回值 operator操作符(参数列表);

// 相当于函数的隐式调用
// 下列两式等价
dist = sid + sara;
dist = sid.opetator+(sara);

// 为了累加/乘,一般返回值是类对象
class Time
{
public:
    Time operator+(const Time & t) const;
}
  1. 运算符的重载限制

    1. 重载后的运算符必须至少有一个操作数必须是用户定义的对象。--为了防止用户对基本类型重载

    2. 重载运算符不能违反运算符原来的语法规则。

      int x;
      Time shiva;
      % x;  // not allowed
      % shiva;  // not allowed
      
    3. 只能通过成员函数对下面的运算符进行重载:

      • =
      • ()
      • []
      • ->

友元,函数与类

友元,friend,你是我的好朋友,我决定让你可以访问我的私有数据。但我无法决定我是你的是好朋友,这得你来决定。所以这不有悖于OOP数据隐藏。

友元函数

class Time
{
public:
    Time operator*(double m);
    friend Time operator*(double m, const Time &t);
}
  1. 为什么要有友元函数呢?

    1. 为了使非成员函数能够直接访问类的私有数据。这在二元运算符重载中经常用到。
  2. 怎么创建友元函数

    // 1.将函数原型放在类的声明中,前面加上friend
    // 2.编写函数定义,不需要使用作用域运算符来限制所属域,也不需要在定义中使用friend
    // 3.需要显式的指出被调用的类对象,即显示传递类对象作为参数。
    
    // .h
    class Time
    {
    public:
        Time operator*(double m);
        friend Time operator*(double m, const Time &t);
        friend ostream & operator<<(ostream & os, const Time &t);
    }
    
    // .cpp
    Time operator*(double m, const Time &t)
    {
        ...;  // 可以在此调用非友元的运算符重载。
    }
    
    ostream & operator<<(ostream & os, const Time &t)
    {
        os << t.hours << " hours" << t.minutes << " minutes";
        return os;
    }
    

友元类

友元类的所有方法都可以访问原类的私有成员和保护成员。

  1. 为什么需要友元类

    将一个类声明为另一个类的友元,那么这个类将可以访问另一个类的成员。这使得有操纵关系的类对象提供了解决方式。

  2. 如何声明一个友元类?

    在原类的public部分,使用关键字friend class 类名将类声明为友元类。

    注意: 要把被提到者类的定义放在提及者的前面。Remote(int m = TV::TV),提到了TV类,所以编译器必须先了解TV类,才能处理Remote类。

    class TV
    {
    public:
        friend class Remote;  // 将remote声明为TV的友元类。
    };
    
    class Remote
    {
    public:
        Remote(int m = TV::TV) : mode(m) {}
        
    }
    

友元成员函数

将另一个类的成员函数声明为本类的友元函数。

  1. 前向声明 forward declaration

    class TV
    {
        friend void Remote::set_chan(TV & t, int c);
        ...;
    }
    
    class Remote
    {
        void Remote::set_chan(TV & t, int c);
    }
    

    如果要使编译器能够处理这个语句,那么它必须知道Remote的定义,否则它无法知道Remote使一个类,而set_chan()是类的一个方法。这意味着要将Remote的定义放在TV之前。Remote类的方法提到了TV类,所以需要把TV放在Remote类前面。而打破次循环的方法是使用前向声明。在定义它之前,先声明它,使编译器知道它是一个类。

    class TV;  // 前向声明
    class Remote{...;};  // 正式定义
    class TV {...;};  // 正式定义
    

    下面的方法不行:

    class Remote;  // 前向声明
    class TV{...;};  // 正式定义
    class  Remote{...;};  // 正式定义
    

    为什么?因为TV中将Remote的方法set_chan()声明为了友元。如果不先定义Remote,那么编译器不知道这是一个方法。

    编译器必须已经看到了TV类的定义,才知道TV中有哪些方法,但TV类的声明在Remote后面,那如果Remote中使用了内联函数(在声明时直接定义,这其中用到了TV类的数据成员)怎么办?

    答案是将内敛函数的定义放在TV类之后。

    class TV;  // 前向声明
    class Remote{...;};  // 正式定义
    class TV {...;};  // 正式定义
    // 
    
    1. 共同的友元。

      如果一个函数要访问两个类的私有数据,那么就将这个函数声明为这两个类的友元。这比其作为一个类的成员,另一个类的友元更合理。

      class Analyzer;  // 前向声明
      
      class Probe
      {
          friend void sync(Analyzer & a, const Prob & p);
          friend void sync(Prob & p, const Analyzer & a);
      }
      
      class Analyzer
      {
          friend void sync(Analyzer & a, const Prob & p);
          friend void sync(Prob & p, const Analyzer & a);
      }
      
      // 定义友元函数
      inline void sync(Analyzer & a, const Prob & p)
      {
          ...;
      }
      inline void sync(Prob & p, const Analyzer & a)
      {
          ...;
      }
      

      前向声明使得编译器看到Probe类中的友元声明时,知道Analyzer是一个类。

类的自动转换

  1. 啥叫类的自动转换?

    将一种类型的值赋给另一种类型时,自动转换为接收变量的类型

    long count = 8;  // 将int值自动转换为long int
    double time = 11;  // 将int转为double
    
  2. 如何实现自定义类的自动转化?

    定义一个接收一个参数的构造函数。手动实现将类型与该参数相同的值转换为类类型的值。

    // 有类String,其将接收的c字符串转换为String类型
    class String
    {
    private:
        char *arr_;
    public:
        ...;
        String(const char * ch);
    }
    
    String(const char * ch)
    {
        arr_ = new char[strlen(ch) + 1];
        strcpy(ch, arr_);
    }
    

    定义了自动转换构造函数,就可以这样用

    String str = "aioj adijaio";  // 隐式调用
    String str = String("aioj adijaio")  // 显式调用
    

    这是隐式调用自动转换。如果不想自动调用,那么可以使用explicit关闭这种特性

    explicit String(const char * ch);
    
    // 调用
    String str;
    str = "nak aknd";  // 隐式调用将不被允许。
    str = String("nak aknd");  // allow
    
  3. 强制转换函数

    将自定义的对象转换为其他类的对象,比如基本类型,相对于转换构造函数而且,这是一种反向的转换。

    修改基本类的声明是不明智的,则可以在自定义类中定义强制类型转换函数,这是一种C++运算符函数。以进行反向的类型转换。

    1. 如何定义?

      class ClassName
      {
      public:
          // 无参,无返回值。
          operator typename();  // 将本类转换为typename类型
      }
      

      有以下几点需要注意无参,无返回值但在定义内返回转换完成值的operator typename()方法

      • 转换函数必须是类方法。---他要访问类数据成员
      • 转换函数不能指定返回类型。
      • 转换函数不能有参数。
      • 转换函数返回转换完成的值。
      operator int() const;  // 转为int;  const可加可不加,加上指明不改变调用者的值。
      operator double() const;  //
      
    2. 如何使用?

      // 隐式
      Stonewt poppins(9, 2.8);
      double p_wt = poppins;  // 隐式调用
      double ww = double(poppins);
      

类和动态内存分配

在类中使用new

  1. 静态类成员变量

    1. 为什么不能在类声明中直接定义静态变量?

      因为类声明只是表述了如何分配内存,但是不分配内存。实例化才会分配内存。

    2. 如何声明并初始化静态变量?

      在类中static声明他,并在实现文件中初始化。

      注意: 布恩那个在类中直接初始化。

      // .h
      class String
      {
          static kCount;
      }
      
      // .cpp
      String::kCount = 0;
      
  2. 动态类成员变量

    使用new声明的成员变量。在程序运行阶段才分配内存。

    删除对象可以释放类对象成员所占用的内存,但是不能释放对象成员所指向的内存。

    所以必须在析构函数中使用delete手动释放内存,确保对象过期时内存被释放。

复制构造函数和赋值运算符重载

  1. 总述:

    1. 当使用一个对象来初始化另一个对象时,将调用复制构造函数。
    2. 当对一个已存在的对象赋值(可能是使用已存在的对象,也可能是临时对象)时,将自动调用赋值运算符重载函数。
  2. 当没有手动定义时,C++自动提供以下成员函数:

    • 默认构造函数
    • 默认析构函数
    • 复制构造函数
    • 赋值运算符
    • 地址运算符(返回调用对象的地址,即this指针的值)。
  3. 为什么需要复制构造函数

    ClassName(const ClassName & cn);
    
    ClassName::ClassName(const ClassName & cn)
    {
        // 静态变量+1
        kCount++;
        str = new char[strlen(cn.str) + 1];
        strcpy(str, cn.str);
    }
    
    • 浅复制:逐个复制非静态成员(静态成员属于整个类,不受影响),复制成员的值。对于指针来说,复制的只是指针的值, 而不是数据。
    • 深复制复制指针指向的数据,而不是指针(指针是一个变量,其值为一个地址)。
  4. 为什么需要重载赋值运算符?

    ClassName & operator=(const ClassName & cn);
    
    ClassName &  ClassName::operator=(const ClassName & cn)
    {
        if(this == cn)
            return *this;
        else
        {
            delete [] str;
            str = new char[strlen(cn.str) + 1];
            strcpy(str, cn.str);
            return *this;
        }
    }
    

    因为自动提供的赋值运算符也是浅复制,对于new指针就会出现问题。

  5. 空指针、悬空指针、野指针

    1. 空指针是指向空的指针

      int *pt = nullptr;
      
    2. 悬空指针是指向已被释内存空间的指针。原因:当程序释放了某个指针指向的内存,但是该指针还保留着原来的值---指向的原来的地址,导致该指针称为悬空指针。

    3. 野指针是指指向未知内存的指针。原因:未初始化。

静态类成员函数

属于类而不是类的实例的函数,可以通过类名直接调用,而不需要创建对象。由于它不依赖类的实例,所以它可以访问静态成员变量,但是不能访问非静态成员变量。所以一般用于操作类的静态成员变量。

使用关键字static将方法声明为静态的。

class MyClass {
public:
    static void fun();
};

// 静态成员函数的定义
void MyClass::fun() 
{
    // 函数体
}

constexpr

用于将变量或函数声明为常量表达式。其值在编译时就能得到结果,因此可以在编译时优化。

constexpr int size = 10;  // 声明const是一个编译时常量

// 定义一个常量表达式。
// 不需要原型, 
constexpr int add(int x, int y) 
{
    return x + y;
}

result = add(3,5);  // 在编译时就能得到结果。
  1. const 与constexpr定义常量的区别:

    constexpr int size = 10;  // 声明size是一个编译时常量
    const int size = 10;  // 声明size是一个运行时常量
    

    const是运行时常量,在程序运行时被初始化。constexpr在编译时就能得到结果,可以用于编译时计算。。在编译时需要常量表达式的场景中,应该优先选择使用 constexpr

返回一个对象

  1. 返回指向const的对象引用

    1. 通过调用对象的方法或将对象作为参数,将函数返回传递给调用函数的对象
    const Vector & Max(const Vector & v1, const Vector & v2)
    {
        if(v1.magval > v2.magval)
            return v1;
        else
            return v2;
    }
    
    1. 为什么要返回一个指向const的引用?

      • 提高效率(返回的是引用,不需要调用复制构造函数。)
    2. 引用就能做到这件事,为什么还要const呢?

      • 传入参数是const &,如果函数返回也要是引用的话,必须为const,因为const变量不能传给非const

      • 限制返回对象不能被改变是什么意思呢?

        “拒绝连等”,或说“拒绝函数返回作为左值”:

        Vector vt1, vt2;
        Max(vt1, vt2) = Vector(1.25);  // 不被允许,因为这是想要修改返回(的)对象。
        
  2. 返回指向非const的对象引用

    用于连等,可以将函数返回作为左值,比如用于赋值运算符重载,<<运算符重载。

    String s1("good");
    String s2, s3;
    
    // 下面两式子等价
    s3 = s2 = s1;  // allowed
    s3.operator=(s2.operator=(s1));  
    
  3. 返回对象

    主要用于返回的对象是在调用函数中创建的局部变量,那么不应该返回其引用,因为当此函数执行结束后,其空间将被释放。

  4. 返回const对象

    主要用于将函数返回禁止作为左值,且返回的是创建的局部对象。

成员列表初始化

  1. 为什么要有成员列表初始化

    1. const和引用成员变量的必要性。这两种类型的变量只能在对象创建时进行初始化,成员列表初始化提供了这种方法。

    2. 高效率。可以在对象创建时就对成员变量进行初始化,避免了先构造再赋值的额外开销

    3. 控制初始化顺序。

      1. 在构造函数中初始化变量 == 对变量进行赋值 == 按照赋值顺序初始化。

      2. 在成员列表中初始化,初始化顺序与变量在列表中的顺序无关,而是按照变量声明的顺序。 这在一个成员的初始化会用到另一个成员的值时很有帮助。

      3. 实例化类时,初始化变量的顺序为:内部成员列表初始化 > 外部成员列表初始化 = 构造函数体内初始化。 顺序排在后面的会覆盖前面的值。虽然外部成员列表初始化 = 构造函数体内初始化,但是如果一个变量在外部成员列表初始化中被初始化,然后在构造函数体内再次初始化,最终它将以构造函数体内的初始化为准。

        内部成员列表初始化:在声明时直接使用成员列表初始化进行初始化。

        class Cls
        {
        private:
            long cc_;
        public:
            Cls(long cc) : cc_(cc) {}  // 内部成员列表初始化
            
        }
        

        外部成员列表初始化:在定义函数时进行成员列表初始化

        class Cls
        {
        private:
            long cc_;
        public:
            Cls(long cc);
            
        }
        
        //.cpp
        Cls::Cls(long cc) : cc_(cc) {}  //  外部成员列表初始化