目录
- 一、类的构造函数的初始化列表
- 1. 初始化列表的使用
- 2. 初始化列表的初始化顺序
- 3. 使用初始化列表的注意事项
- 二、类的自动类型转换
- 1. 类的自动类型转换的使用
- 2. 关闭类的自动类型转换
- 三、静态类成员
- 1. 静态成员的特性
- 2. 使用静态成员计算类创建了多少个对象
- 3. 使用静态类成员的注意事项
- 四、友元函数
- 1. 友元函数的使用
- 2. 使用友元函数的注意事项
一、类的构造函数的初始化列表
使用函数时需要传递对应的参数,而在传参的过程中就已经完成了形参的创建并使用实参对其初始化。而构造函数不同于其他函数,因为构造函数是在创建类对象时编译器自动调用的,我们传递的参数只是用来给形参初始化,然后把形参赋值给类对象的成员变量。而在进入函数体时,类对象的成员变量就已经创建好了但并没有初始化,此时类对象的成员变量中存储的都是垃圾值(上一次使用遗留的值),只有在执行完构造函数之后,该类对象的成员变量中存储的才是我们想要的值。
说的直白一点,在构造函数的函数体中对类对象成员变量进行修改都是赋值,不是初始化。但是有的成员变量必须初始化,如:常量成员变量(const 成员)和引用成员等。而针对这些成员就需要使用构造函数的初始化列表,在初始化列表中可以对类的成员变量进行初始化。
1. 初始化列表的使用
初始化列表的使用方法是在构造函数定义的圆括号后面使用冒号(:),然后列出需要初始化的成员变量,中间用逗号(,)隔开,且圆括号中是用来初始化的值。
// A 类声明
class A
{
private:const int a; // const 成员变量const int& ra; // 引用成员变量public:A(int b, int c);
};// A 类的构造函数
A::A(int b, int c): a(b), ra(c)
{}
&esmp; 虽然上述的初始化列表使用正确了,但是成员变量 ra 引用了一个局部变量,出了该函数以后会导致 ra 成为野引用。
2. 初始化列表的初始化顺序
观察下面的 Stakc 类的构造函数:
// 类型声明
typedef int DataType;// Stack 类声明
class Stack
{
private:DataType* _pdata;size_t _top;size_t _capacity;public:Stack(size_t capacity = 4);void push(const DataType& data);
};Stack::Stack(size_t capacity): _capacity(capacity), _top(1), _pdata((DataType*)malloc(sizeof(DataType)*_capacity))
{}void Stack::push(const DataType& data)
{// 入栈_pdata[_top++] = data;
}
如果按照构造函数初始化列表中的初始化顺序来说,该构造函数没有问题。但是如果进行调试的话,可以发现初始化的顺序与之并不一致。
(1)刚进入构造函数
作者使用的是 VS2022,编译器自动把成员变量设置为 0 了。
(2)初始化第一个成员变量
可以看到下面的代码中第一个初始化的是成员变量 _pdata,并不是构造函数定义中的 _capacity。
(3)初始化剩下两个成员变量
通过对上面调试结果的分析,可以得出初始化列表的初始化顺序并不是按照构造函数中的顺序进行初始化的,而是按照类声明中成员变量的声明顺序进行初始化的。
这样就会产生一个问题,如果先初始化 _pdata,但是此时 _capacity 还没有初始化,它的值可能会是随机值(并不是所有的编译器都会在进入构造函数的瞬间把成员变的值设置为 0),这样就会开辟一个随机大小的空间,很容易造成严重的问题。
所以,初始化列表中的初始化顺序需要和类声明中成员变量的声明顺序一致。防止由于初始化的顺序和预期的顺序不一致,从而产生难以预估的影响。
3. 使用初始化列表的注意事项
(1)对于下面四种类型必须使用初始化列表:常量成员变量(const 成员)、引用成员变量、没有默认构造函数的自定义成员变量和基类构造(基类在后面的博客中会介绍);
当自定义类型作为其他类的成员变量时,该类的构造函数必须在初始化列表中使用该自定义类型的构造函数对该自定义类型初始化。如果不在初始化列表中显式使用,那么编译器会在初始化列表中隐式调用其默认构造函数。如果该自定义类型没有默认构造函数且又没有在初始化列表中显式调用其构造函数,编译器会报错。
为什么必须在初始化列表中调用自定义类型的构造函数?
1)首先只要创建类对象就必须调用其构造函数,这是 C++ 规定;
2)其次该类对象也可能包含 const 成员变量或者引用等必须初始化的成员变量,这些成员变量必须在它们自己类的构造函数中通过初始化列表进行初始化;
3)最后,该自定义类型可能涉及动态内存开辟,使用它自己的构造函数可以正确地开辟动态内存。
(2)初始化列表的初始化顺序应该和类声明中的成员变量的声明顺序保持一致,避免出现由于初始化顺序产生的错误;
(3)尽量使用初始化列表,哪怕不显式使用初始化列表,编译器也会把所有的成员变量在初始化列表中走一遍,因为初始化列表是成员变量的创建过程。并且一般情况下,使用初始化列表的效率更高。
二、类的自动类型转换
对于自动类型转换相信大家都不陌生,如:int a = 1.1;,该语句就是常见的 double 值自动类型转换为 int 值,只不过要默认舍弃后面的小数。
而在类中也存在类似的自动类型转换,在类中,把只有一个参数的构造函数用来自动类型转换。
1. 类的自动类型转换的使用
下面依旧是 Stack 类的声明:
// 类型声明
typedef int DataType;// Stack 类声明
class Stack
{
private:size_t _capacity;size_t _top;DataType* _pdata;public:Stack(size_t capacity = 2);
};Stack::Stack(size_t capacity): _capacity(capacity), _top(0), _pdata(nullptr)
{_pdata = (DataType*)malloc(sizeof(DataType) * capacity);// 申请判断if (nullptr == _pdata){perror("Stack::Stack::malloc: ");return;}
}
再来看下面的 Stack 类对象的创建语句:
为什么该语句可以通过编译?首先,编译器检测到该语句的右侧需要 Stack 类型的值,然后就会尝试把 int 类型的常量值 2 转换为 Stack 类型。而我们编写的 Stack 类的构造函数刚好包含一个 size_t 的参数,且 int 类型转换成 size_t 类型(满足隐式转换条件)。所以,编译器会先把 int 类型的 2 隐式转换成 size_t 类型,然后用这个 size_t 类型的 2 去构造一个 Stack 类型的临时对象,再用这个临时的 Stack 类型的对象去拷贝构造 sk1。
但是现在的编译器很智能,编译器会优化上述的构造过程。编译器会把 2 转换为 size_t 类型之后直接去构造 sk1,因为编译器觉得调用两次构造函数太浪费了。当在一条语句中使用两次构造函数时,编译器会尝试把这个过程优化成一次构造函数。
2. 关闭类的自动类型转换
自动类型转换有时候也会带来问题,比如当我们误写了 Stack sk1 = 10; 这种语句时,编译器并不会报错,而是进行隐式类型转换从而构造出一个容量为 10 的 Stack 类对象,因为我们编写了一个只包含一个 size_t 类型参数的构造函数——Stack(size_t capacity);。
可以在类的只有一个参数的构造函数的声明之前加上 explicit 关键字来关闭自动类型转换的特性。
可以看到当我们在只有一个参数的构造函数的声明前面加上了 explicit 关键字之后,该类的自动类型转换特性就消失了,无法从 int 类型转换为 Stack 类型。
通常情况下不建议使用类的自动类型转换,因为这样如果代码错了,把一个 int 值用来初始化或者赋值给 Stack 类对象,编译器不会报错,而是会进行自动类型转换。
三、静态类成员
如果我们想要计算一个类创建了多少个类对象,那么常规的方法肯定是创建一个全局变量或者静态变量,然后再构造函数中对该变量 +1,在析构函数中对该变量 -1。
但是这样会破坏类的封装特性,因为不管是全局变量还是静态变量都可以被直接访问,这样很不安全。这时就需要使用类的静态成员(static 成员)。
在类成员前面加上 static 就可以使该成员成为静态成员,成员变量前面加上 static 就变成了静态成员变量,成员函数前面加上 static 就成为了静态成员函数。
1. 静态成员的特性
(1)静态成员只有一个实例,被所有类对象共享,存放在静态区;
(2)静态成员必须在类外定义,定义时不添加关键字,类中只是声明;
(3)静态成员可以通过类名::静态成员或者对象.静态成员来访问;
(4)静态成员函数没有 this 指针,不能访问任何其他非静态成员;
(5)静态成员也是类成员,受 private、public 和 protected 访问限定符的限制。
2. 使用静态成员计算类创建了多少个对象
下面通过静态成员变量和静态成员函数计算 A 类创建了多少个对象。
// A 类声明
class A
{
private:static int _num_object;int _a;public:A(int a = 0);~A();// 静态成员函数static int GetObjectNum();
};// A 类静态成员变量定义
int A::_num_object = 0;// A 类成员函数定义
A::A(int a)
{_a = a;// 对象数量 +1++_num_object; // 实际上是 ++this->_num_object
}A::~A()
{// 对象数量 -1--_num_object;
}int A::GetObjectNum()
{cout << "当前对象个数: " << _num_object << endl;return _num_object;
}int main()
{{A a1;A::GetObjectNum();{A a2;A::GetObjectNum();A a3;A::GetObjectNum();}A a4;A::GetObjectNum();}A::GetObjectNum();return 0;
}
程序运行结果如下:
上述代码通过把静态成员变量 _num_object 放在私有(private)中实现了封装,只能通过公有的静态成员函数访问。
且静态函数中没有 this 指针,不能访问类的其他非静态成员变量。
3. 使用静态类成员的注意事项
(1)静态类成员变量在类中声明(前面加 static),在类外定义(不加 static,需要加类名::限定)。静态类成员函数也是如此;
(2)公有的静态成员可以直接通过类名和作用域解析运算符进行访问,或者通过类对象进行访问,但是建议使用类名和作用域解析运算符进行访问;
因为静态类成员属于整个类,被所有对象共享,使用类名::来进行访问更加能提现其是一个静态成员。
(3)由于静态类成员函数不包含 this 指针,所以不能访问其他非静态成员变量;
(4)通常把静态成员变量放在私有区域,然后通过公有区域的静态类成员函数进行访问。
四、友元函数
友元函数在类中声明,在类外定义。它不是类的成员函数,但是可以访问类的私有成员和保护成员。因为友元函数不是类的成员函数,所以它没有 this 指针,需要把类对象当作参数传递。友元函数不受访问限定符的限制,可以在类的任何地方定义,但是需要在前面加上关键字 friend。但是在类外定义时不需要加上 friend 关键字,也不需要使用类名::限定,因为友元函数不属于类的成员函数。
为什么需要友元函数?比如在 Date 类中,我们想要直接使用流插入运算符(<<)打印 Date 类对象,那么我们就必须重载流插入运算符(<<)。但是这里有个问题,如果把流插入运算符当作类的成员函数,那么就需要把类对象放在前面,如:
Date d1(2025, 1, 2);
d1 << cout;
我们平常都习惯把 cout 放在前面,如果像上面这样使用就导致可读性很差,所以我们需要使用友元函数来把 cout 放在前面。
1. 友元函数的使用
下面使用友元函数重载了加法运算符和流插入运算符。
// Date 类声明
class Date
{
private:size_t _year;size_t _month;size_t _day;public:Date(size_t year = 1949, size_t month = 10, size_t day = 1);size_t GetMonthDay() const;// 友元函数friend Date operator+(size_t days, const Date& date);friend std::ostream& operator<<(std::ostream& os, const Date& date);
};// Date 类成员函数定义
Date::Date(size_t year, size_t month, size_t day): _year(year), _month(month), _day(day)
{}size_t Date::GetMonthDay() const
{// 十二个月份的天数static const size_t MONTH_DAYS[13] = { 0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };// 闰年判断if ((2 == _month) && ((_year % 4 == 0 && _year % 100 != 0) || (_year % 400 == 0)))return 29;elsereturn MONTH_DAYS[_month];
}// 友元函数
Date operator+(size_t days, const Date& date)
{Date tmp(date);// 计算tmp._day += days;while (tmp._day > tmp.GetMonthDay()){// 减去当月的天数tmp._day -= tmp.GetMonthDay();// 12 月if (12 == tmp._month){++tmp._year;tmp._month = 1;}else // 其他月{++tmp._month;}}// 返回return tmp;
}std::ostream& operator<<(std::ostream& os, const Date& date)
{os << date._year << "-" << date._month << "-" << date._day;return os;
}int main()
{Date d1(2025, 1, 2);cout << d1 << endl;cout << "10000 天以后: ";cout << 10000 + d1 << endl;return 0;
}
程序的运行结果如下:
可以看到使用了友元函数之后,不仅可以直接使用 cout 和流插入运算符直接打印日期类对象,还可以实现数字在前和类对象相加。
2. 使用友元函数的注意事项
(1)友元函数在类内声明,声明时前面加上关键字 friend,在类外定义,定义时前面不加关键字 friend;
(2)友元函数可以直接访问类的私有成员和保护成员,但是友元函数不是类的成员函数;
(3)由于友元函数不是类的成员函数,所有定义时不需要使用类名::限定;
(4)友元函数不受访问限定符的限制,可以在类的任意位置声明;友元函数可以重载;
(5)尽量少使用友元函数,因为友元函数破坏了类的封装性。