9. 类
类及类中的基本概念
-
面向过程编程和面向对象编程
- 面向过程编程,首先考虑要遵循的步骤(过程),然后考虑如何表示这些数据。
- 面向对象编程,将从对象开始考虑,这个对象如何表示--数据成员。这个对象可以做什么--方法(接口)。这就构成了类--描述对象的数据以及描述用户与数据交互所需的操作。这个过程就叫抽象。
-
指定基本类型发生了什么?
- 决定数据对象需要的内存数量。
- 决定如何解释内存中的位。
- 决定可以使用数据对象执行的操作或方法。
总之,基本类型更加底层,它描述了向下--与内存存储的关系。向上--他描述了如何进行操作。而我们自己定义类就是在基本类型的基础上(不用管向下对内存的操作)组合出我们需要的类。
-
C++将类的接口放在头文件中,并将实现方法放在源代码中。这很C++不是吗?通过包含头文件,将类的声明包裹进自己的文件,从而可以调用它(这是对函数的调用所必须的---包含其声明)。但又不包含其实现(函数要单定义原则)。如果要在
.h中实现函数定义,它将自动称为内联函数--它和其他函数的工作方式不同--他要求在每一个使用他们的文件中都对其进行定义,但也单定义。 -
封装和数据隐藏
使用类对象可以直接访问公有部分,但只能通过公有函数访问对象的私有成员,这就实现了数据的隐藏--将数据放在私有部分。
-
一个简单的类及其实现
// 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; } } -
每个类对象都有自己的存储空间,用于存储其内部变量和类成员,但同一个类的所有对象都共用同一组类方法。
-
const成员函数。如果不想方法修改数据成员,那么应将方法声明为const
void Stock::Show() const; // 后置const
构造与析构
构造函数
class Stock
{
private:
...;
public:
Stock(); // 构造函数
~Stock(); // 析构函数
}
-
什么是构造函数
- 构造函数是类的一个方法,专门用于构造新对象,将值赋给他的数据成员。即为类提供初始化的方法。当实例化类时,编译器将自动调用构造函数。
-
为什么需要构造函数
如果不对其进行初始化,那么这个对象便无法进行准确的表示--他的数据不能表示我,那又从何谈起他是我的抽象化呢。也无法使用类的方法。
-
怎样声明一个构造函数
与类同名,但没有返回值,原型位于类声明的共有部分。
-
默认构造函数
当定义了构造函数后,必须显式的为类提供默认构造函数--当且仅当没有定义任何构造函数时,编译器才会提供默认构造函数。
这有两种方式:
-
提供一个无参数的构造函数。
Stock(); Stock(const string & co, int n); // cpp Stock::Stock() { // 对所有类成员进行初始化。 } -
给已有的构造函数提提供默认值。
Stock(const string & co = "", int n = 0);
-
-
使用类构造函数的两种方式
-
显式调用构造函数
// 啥叫显式?就是通过函数名Stock()调用。 Stock food = Stock("World Cabbage", 250); -
隐式调用构造函数
// 啥叫隐式? 不体现出函数名。 Stock food("World Cabbage", 250);
-
析构函数
-
什么是析构函数
对过期的类对象完成清理的函数--释放内存,当类对象过期(自动对象、静态对象自动过期、动态对象new-delete手动过期)后被自动调用。
-
为什么需要析构函数
主要是为动态内存分配准备的--使用new分配数据成员的内存,不会被自动释放。如果没使用动态内存分配,他就可有可无(指显式定义,如果不定义,编译器会自动提供一个。)
-
怎样声明一个析构函数
比构造函数名前多一个
~。 -
一个示例
// 如果其数据对象没有使用动态内存分配(在构造函数中没有使用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)
}; // 列表初始化,使用了构造函数进行初始化
对象数组的初始化过程:
- 首先使用默认构造函数创建数组元素,
- 然后花括号中的构造函数将创建临时对象,
- 最后将临时对象的内容复制到相应的元素中。
在类中定义常量
-
共有两种方式:
-
使用static关键字。
// 因为在程序开始运行时,编译器就会为静态变量分配空间,放在数据区 class name { static const int Months = 12; } -
使用枚举
// 在类中声明的枚举作用域为整个类,因此可以用枚举为整型变量提供作用域为整个类的符号名称 // 枚举类型是常量的另一种表示形式,它被直接编译到代码区。 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;
-
-
为什么要如此?
因为类只是描述了对象的形式,并没有创建对象。因此再创建对象之前,没有用于存储的空间。
运算符重载
返回值 operator操作符(参数列表);
// 相当于函数的隐式调用
// 下列两式等价
dist = sid + sara;
dist = sid.opetator+(sara);
// 为了累加/乘,一般返回值是类对象
class Time
{
public:
Time operator+(const Time & t) const;
}
-
运算符的重载限制
-
重载后的运算符必须至少有一个操作数必须是用户定义的对象。--为了防止用户对基本类型重载
-
重载运算符不能违反运算符原来的语法规则。
int x; Time shiva; % x; // not allowed % shiva; // not allowed -
只能通过成员函数对下面的运算符进行重载:
- =
- ()
- []
- ->
-
友元,函数与类
友元,friend,你是我的好朋友,我决定让你可以访问我的私有数据。但我无法决定我是你的是好朋友,这得你来决定。所以这不有悖于OOP数据隐藏。
友元函数
class Time
{
public:
Time operator*(double m);
friend Time operator*(double m, const Time &t);
}
-
为什么要有友元函数呢?
- 为了使非成员函数能够直接访问类的私有数据。这在二元运算符重载中经常用到。
-
怎么创建友元函数
// 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; }
友元类
友元类的所有方法都可以访问原类的私有成员和保护成员。
-
为什么需要友元类
将一个类声明为另一个类的友元,那么这个类将可以访问另一个类的成员。这使得有操纵关系的类对象提供了解决方式。
-
如何声明一个友元类?
在原类的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) {} }
友元成员函数
将另一个类的成员函数声明为本类的友元函数。
-
前向声明 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 {...;}; // 正式定义 //-
共同的友元。
如果一个函数要访问两个类的私有数据,那么就将这个函数声明为这两个类的友元。这比其作为一个类的成员,另一个类的友元更合理。
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是一个类。
-
类的自动转换
-
啥叫类的自动转换?
将一种类型的值赋给另一种类型时,自动转换为接收变量的类型
long count = 8; // 将int值自动转换为long int double time = 11; // 将int转为double -
如何实现自定义类的自动转化?
定义一个接收一个参数的构造函数。手动实现将类型与该参数相同的值转换为类类型的值。
// 有类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 -
强制转换函数
将自定义的对象转换为其他类的对象,比如基本类型,相对于转换构造函数而且,这是一种反向的转换。
修改基本类的声明是不明智的,则可以在自定义类中定义强制类型转换函数,这是一种C++运算符函数。以进行反向的类型转换。
-
如何定义?
class ClassName { public: // 无参,无返回值。 operator typename(); // 将本类转换为typename类型 }有以下几点需要注意: 无参,无返回值但在定义内返回转换完成值的operator typename()方法。
- 转换函数必须是类方法。---他要访问类数据成员
- 转换函数不能指定返回类型。
- 转换函数不能有参数。
- 转换函数返回转换完成的值。
operator int() const; // 转为int; const可加可不加,加上指明不改变调用者的值。 operator double() const; // -
如何使用?
// 隐式 Stonewt poppins(9, 2.8); double p_wt = poppins; // 隐式调用 double ww = double(poppins);
-
类和动态内存分配
在类中使用new
-
静态类成员变量
-
为什么不能在类声明中直接定义静态变量?
因为类声明只是表述了如何分配内存,但是不分配内存。实例化才会分配内存。
-
如何声明并初始化静态变量?
在类中static声明他,并在实现文件中初始化。
注意: 布恩那个在类中直接初始化。
// .h class String { static kCount; } // .cpp String::kCount = 0;
-
-
动态类成员变量
使用new声明的成员变量。在程序运行阶段才分配内存。
删除对象可以释放类对象成员所占用的内存,但是不能释放对象成员所指向的内存。
所以必须在析构函数中使用delete手动释放内存,确保对象过期时内存被释放。
复制构造函数和赋值运算符重载
-
总述:
- 当使用一个对象来初始化另一个对象时,将调用复制构造函数。
- 当对一个已存在的对象赋值(可能是使用已存在的对象,也可能是临时对象)时,将自动调用赋值运算符重载函数。
-
当没有手动定义时,C++自动提供以下成员函数:
- 默认构造函数
- 默认析构函数
- 复制构造函数
- 赋值运算符
- 地址运算符(返回调用对象的地址,即this指针的值)。
-
为什么需要复制构造函数
ClassName(const ClassName & cn); ClassName::ClassName(const ClassName & cn) { // 静态变量+1 kCount++; str = new char[strlen(cn.str) + 1]; strcpy(str, cn.str); }- 浅复制:逐个复制非静态成员(静态成员属于整个类,不受影响),复制成员的值。对于指针来说,复制的只是指针的值, 而不是数据。
- 深复制:复制指针指向的数据,而不是指针(指针是一个变量,其值为一个地址)。
-
为什么需要重载赋值运算符?
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指针就会出现问题。
-
空指针、悬空指针、野指针
-
空指针是指向空的指针
int *pt = nullptr; -
悬空指针是指向已被释内存空间的指针。原因:当程序释放了某个指针指向的内存,但是该指针还保留着原来的值---指向的原来的地址,导致该指针称为悬空指针。
-
野指针是指指向未知内存的指针。原因:未初始化。
-
静态类成员函数
属于类而不是类的实例的函数,可以通过类名直接调用,而不需要创建对象。由于它不依赖类的实例,所以它可以访问静态成员变量,但是不能访问非静态成员变量。所以一般用于操作类的静态成员变量。
使用关键字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); // 在编译时就能得到结果。
-
const 与constexpr定义常量的区别:
constexpr int size = 10; // 声明size是一个编译时常量 const int size = 10; // 声明size是一个运行时常量const是运行时常量,在程序运行时被初始化。constexpr在编译时就能得到结果,可以用于编译时计算。。在编译时需要常量表达式的场景中,应该优先选择使用
constexpr。
返回一个对象
-
返回指向const的对象引用
- 通过调用对象的方法或将对象作为参数,将函数返回传递给调用函数的对象。
const Vector & Max(const Vector & v1, const Vector & v2) { if(v1.magval > v2.magval) return v1; else return v2; }-
为什么要返回一个指向const的引用?
- 提高效率(返回的是引用,不需要调用复制构造函数。)
-
引用就能做到这件事,为什么还要const呢?
-
传入参数是const &,如果函数返回也要是引用的话,必须为const,因为const变量不能传给非const
-
限制返回对象不能被改变是什么意思呢?
“拒绝连等”,或说“拒绝函数返回作为左值”:
Vector vt1, vt2; Max(vt1, vt2) = Vector(1.25); // 不被允许,因为这是想要修改返回(的)对象。
-
-
返回指向非const的对象引用
用于连等,可以将函数返回作为左值,比如用于赋值运算符重载,<<运算符重载。
String s1("good"); String s2, s3; // 下面两式子等价 s3 = s2 = s1; // allowed s3.operator=(s2.operator=(s1)); -
返回对象
主要用于返回的对象是在调用函数中创建的局部变量,那么不应该返回其引用,因为当此函数执行结束后,其空间将被释放。
-
返回const对象
主要用于将函数返回禁止作为左值,且返回的是创建的局部对象。
成员列表初始化
-
为什么要有成员列表初始化
-
const和引用成员变量的必要性。这两种类型的变量只能在对象创建时进行初始化,成员列表初始化提供了这种方法。
-
高效率。可以在对象创建时就对成员变量进行初始化,避免了先构造再赋值的额外开销
-
控制初始化顺序。
-
在构造函数中初始化变量 == 对变量进行赋值 == 按照赋值顺序初始化。
-
在成员列表中初始化,初始化顺序与变量在列表中的顺序无关,而是按照变量声明的顺序。 这在一个成员的初始化会用到另一个成员的值时很有帮助。
-
实例化类时,初始化变量的顺序为:内部成员列表初始化 > 外部成员列表初始化 = 构造函数体内初始化。 顺序排在后面的会覆盖前面的值。虽然外部成员列表初始化 = 构造函数体内初始化,但是如果一个变量在外部成员列表初始化中被初始化,然后在构造函数体内再次初始化,最终它将以构造函数体内的初始化为准。
内部成员列表初始化:在声明时直接使用成员列表初始化进行初始化。
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) {} // 外部成员列表初始化
-
-