C++入门之类和对象

目录

1.C++相对于C语言的一些不同的小语法

1.1命名空间

1.2C++输入&输出

1.3缺省参数

1.4函数重载

1.5引用

1.6内联函数

1.7auto

1.8nullptr

2.类的引入

2.1类的内部

2.2this指针        

2.3类的默认成员函数

2.3.1构造函数

2.3.2析构函数

2.3.3拷贝构造

2.4运算符重载

2.4.1赋值运算符格式

2.4.2 赋值运算符重载格式

2.4.3前置++和后置++

2.4.4const成员

2.5初始化列表

2.6构造函数的隐式类型转换和explicit关键字

2.7static成员

2.8友元

2.8.1友元函数

2.8.2友元类


1.C++相对于C语言的一些不同的小语法

      在讲C++之前,我们应该要了解一下C++的一些小语法:

1.1命名空间

        C++中,我们需要使用的变量名称都是很多很多的,在这么多的变量中,难免会出现一些小情况,会导致名字相同含义和作用不同的两个变量,所以在C++中就引入了命名空间这个概念,namesapce关键字也因此而生

        假设我们在C语言中,我们写下面这段代码:

#include <stdio.h>
#include <stdlib.h>
int rand = 10;
// C语言没办法解决类似这样的命名冲突问题,所以C++提出了namespace来解决
int main()
{
 printf("%d\n", rand);
 return 0;
}

        我们会发现我们的语法会报错,因为头文件中stdlib中有rand的定义了,这样我们就不能再使用这个变量名,要另外取名字。

        但是在C++中,引入命名空间之后,我们就可以使用这个rand并作为不一样的作用使用,做法是吧rand作为一个变量定义在一个命名空间中,这样我们使用::这个域作用限定符就可以访问不同域中的同名变量了:

namespace s1
{
	int rand = 100;
}
namespace s2
{
	int rand = 0;
}
int main()
{
	s1::rand;
	s2::rand;
}

        另外,命名空间可以嵌套,例如我们可以在域s1中再定义一个域s2,但是我们在访问s2的时候要先访问s1:

namespace s1
{
	int rand = 100;
	namespace s2
	{
		int rand = 0;
	}
}

int main()
{
	s1::rand;
	s1::s2::rand;
}

        除此之外,我们可以在一段代码中同时定义多个相同名字的命名空间,但是最后它们都会被划分到同一个命名空间中。

        命名空间的使用方法主要有三种:

        第一种就是前面提到的使用加命名空间名称及作用域限定符即可访问对应的函数或者变量,我们在C++标准库std中就有我们经常使用的cin和cout,但是使用它们还需要包含iostream这个头文件:

#include <iostream>
int main()
{
	std::cout << "Hello World" ;
	int a;
	std::cin >> a;
	std::cout << a;
}

        第二种就是使用using把某些成员引入,比如:

#include <iostream>
using std::cout;
using std::cin;
int main()
{
	cout << "Hello World";
	int a;
	cin >> a;
	cout << a;
	return 0;
}

        第三种就是使用using namespace 命名空间名称 引入把命名空间整个展开直接使用内部的所有变量和函数,也是我们经常看到的“using namespace std”的由来:

#include <iostream>
using namespace std;
int main()
{
	cout << "Hello World";
	int a;
	cin >> a;
	cout << a;
	return 0;
}

        第三种过于粗暴,一般只在竞赛和平时的小练习中使用,在大型项目中很少会这么干,命名空间的展开会造成很严重的问题,而且我们c++在这方面相对于c语言的优势也就没有了。

1.2C++输入&输出

       上面我们提到过的cin和cout,就是我们c++中的输入输出,不过更专业的名词应该是流插入和流提取。

        std是C++标准库的命名空间名,C++将标准库的定义实现都放到这个命名空间中

  1.  使用cout标准输出对象(控制台)和cin标准输入对象(键盘)时,必须包含< iostream >头文件 以及按命名空间使用方法使用std。
  2.  cout和cin是全局的流对象,endl是特殊的C++符号,表示换行输出,他们都包含在包含< iostream >头文件中。
  3.  是流插入运算符,>>是流提取运算符。
  4.  使用C++输入输出更方便,不需要像printf/scanf输入输出时那样,需要手动控制格式。 C++的输入输出可以自动识别变量类型。
  5.  实际上cout和cin分别是ostream和istream类型的对象,>>和也涉及运算符重载等知识, 这些知识我们我们后续才会学习,所以我们这里只是简单学习他们的使用。后面我们还有有 一个章节更深入的学习IO流用法及原理。

        cin会自动识别格式,这也是c++比较方便的一个点。另外,和cout一起使用的还有一个就是endl,相当于换行符,在cout后面再加上<< endl就可以了

#include <iostream>
using namespace std;
 
int main()
{
   int a;
   double b;
   char c;
     
   // 可以自动识别变量的类型
   cin>>a;
   cin>>b>>c;
     
   cout<<a<<endl;
   cout<<b<<" "<<c<<endl;
   return 0;
}

1.3缺省参数

        在C++中,缺省参数就是在函数定义或者声明的时候给的一个缺省值,如果在调用函数的时候没有传入参数,就可以使用这个默认的参数进行参数初始化,如果有传入参数,那就优先考虑传入的实参。

void Func(int a = 0)
{
 cout<<a<<endl;
}
int main()
{
 Func();     // 没有传参时,使用参数的默认值
 Func(10);   // 传参时,使用指定的实参
 
 return 0;
}

        缺省参数中又有全缺省和半缺省,全缺省就是多个参数全部给缺省值的情况:

void Func(int a = 10, int b = 20, int c = 30)
 {
     cout<<"a = "<<a<<endl;
     cout<<"b = "<<b<<endl;
     cout<<"c = "<<c<<endl;
 }

        半缺省也好理解,就是不给全部缺省值,不过需要注意的是,我们的班缺省参数只能从优往左给缺省值,不能隔着:

void Func(int a, int b = 10, int c = 20)
 {
     cout<<"a = "<<a<<endl;
     cout<<"b = "<<b<<endl;
     cout<<"c = "<<c<<endl;
 }

        像下面这些情况都是错误的:

         半缺省参数都是从右往左给的,不过一般情况下,我们一律使用全缺省参数的情况似乎更多一点。

     另外,我们的缺省参数不能再函数的定义和函数的声明中同时给,就算是给的一样的值也不行

      除此之外,缺省值还必须是常量或者全局变量,就没有更多的注意事项了。

1.4函数重载

        函数重载有点类似中文中的“一词多义”,只不过在C++中这是针对函数的修饰说明,函数重载C++中体现在:允许同一作用域中声明几个功能类似的同名函数,这些同名函数的形参列表(参数个数或类型或类型顺序)不同,常用来处理实现功能类似数据类型不同的问题。

#include<iostream>
using namespace std;
// 1、参数类型不同
int Add(int left, int right)
{
 cout << "int Add(int left, int right)" << endl;
 return left + right;
}
double Add(double left, double right)
{
 cout << "double Add(double left, double right)" << endl;
 return left + right;
}
// 2、参数个数不同
void f()
{
 cout << "f()" << endl;
}
void f(int a)
{
 cout << "f(int a)" << endl;
}
// 3、参数类型顺序不同
void f(int a, char b)
{
 cout << "f(int a,char b)" << endl;
}
void f(char b, int a)
{
 cout << "f(char b, int a)" << endl;
}
int main()
{
 Add(10, 20);
 Add(10.1, 20.2);
 f();
 f(10);
 f(10, 'a');
 f('a', 10);
 return 0;
}

        上面的这些函数就构成函数重载,我们以后也会遇到很多。 

        但是我们C++中是不支持使用返回值不同但是参数相同的函数,比如下面这个函数:

        不过我们想要让它合理就只要改其中一个的参数类型或者参数数量,像这样:

        究其原因,其实是在C语言中,函数的调用只有函数名这样一个参考,但是在C++中,调用函数就变成了函数名+每个参数类型,这样就导致了我们的C++可以实现函数重载而C语言不能。另外,函数重载和给函数缺省值在实际写代码的过程中都是很好用的技巧,也算是C++的优胜处吧。

1.5引用

        引用,我们也叫做“取别名”,就是给一个人去另外一个名字,实际代表的是同一个人,而且一个人可以有多个别名。在C++中,我们可以给变量取别名,在编译器处理过程中,编译器不会再开辟新的空间,而是使用别名和原来变量使用同一块空间

        引用的操作就是:类型& 引用变量名(对象名) = 引用实体;另外我们需要注意的是引用类型必须和引用实体是同种类型的。

        前面我们说“给一个人去另外一个名字,实际代表的是同一个人,而且一个人可以有多个别名”,所以在引用中,我们也可以给同一个变量给多个引用。

        我们在使用引用的时候,也会相应的映射到原来变量,即我们改变引用的值,原始的变量的值也可以被改变。而且我们还可以给别名取别名,也是同样的效果。

        而引用的底层还是我们在C语言中接触过的指针,不过这里就不是指针这个概念,而是更方便的引用了,并且还更加节省空间,我们可以使用引用做很多事情。

//1. 做参数
void Swap(int& left, int& right)
{  
 int temp = left;  
 left = right;  
 right = temp;
}
//2. 做返回值
int& Count()
{  
 static int n = 0; 
  n++;   // ...  
 return n;
}

        使用引用去代替指针的作用,我们的代码会更加简洁好理解,并且还能一定程度上节省空间

        但是这里有一段代码:

int& Add(int a, int b)
{
	int c = a + b;
	return c;
}
int main()
{
	int& ret = Add(1, 2);
	Add(3, 4);
	cout << "Add(1, 2) is :" << ret << endl;
	return 0;
}

        这段代码就揭示了引用的局限性,看一下输出:

        我们发现,当引用作为返回值的时候,好像输出的并不是我们想要的结果。其实这里也是前面说过的,当使用引用返回局部变量c,而c又在出了函数作用域之后销毁了,所以我们得到的ret就变成了c销毁之后的值,也就是一个随机值,所以我们没有得到想要的结果,在我们使用引用的过程中,我们要尽量避免这样的情况发生。

        以值作为参数或者返回值类型,在传参和返回期间,函数不会直接传递实参或者将变量本身直 接返回,而是传递实参或者返回变量的一份临时的拷贝,因此用值作为参数或者返回值类型,效 率是非常低下的,尤其是当参数或者返回值类型非常大时,效率就更低。所以,可以使用传引用返回的时候,我们就尽量使用这样一个更加高效的方法实现,不过还是要注意变量出作用域销毁的问题。

1.6内联函数

        内联函数一般以inline修饰,编译时C++编译器会在调用内联函数的地方展开,没有函数调用建立栈帧的开销,内联函数提升程序运行的效率。

         inline是一种以空间换时间的做法,如果编译器将函数当成内联函数处理,在编译阶段,会 用函数体替换函数调用,缺陷:可能会使目标文件变大,优势:少了调用开销,提高程序运 行效率。     

        inline对于编译器而言只是一个建议,不同编译器关于inline实现机制可能不同,一般建 议:将函数规模较小(即函数不是很长,具体没有准确的说法,取决于编译器内部实现)、不 是递归、且频繁调用的函数采用inline修饰,否则编译器会忽略inline特性。

        inline不建议声明和定义分离,分离会导致链接错误。因为inline被展开,就没有函数地址 了,链接就会找不到。

1.7auto

        随着程序越来越复杂,程序中用到的类型也越来越复杂,经常体现在: 1. 类型难于拼写 2. 含义不明确导致容易出错

#include <string>
#include <map>
int main()
{
 std::map<std::string, std::string> m{ { "apple", "苹果" }, { "orange", 
"橙子" }, 
   {"pear","梨"} };
 std::map<std::string, std::string>::iterator it = m.begin();
 while (it != m.end())
 {
 //....
 }
 return 0;
}

        上面的代码中std::map::iterator 是一个类型,但是该类型太长了,特别容易写错。也许我们可以使用typedef重定义类型,但是这要求我们清楚的了解它的类型才不会用错,然而有时候要做到这点并非那么容易,因此C++11给auto赋予了新的含义:即自动识别变量类型。

        这样我们就不用想大半天然后最后还会写错了,这不仅大大简化了我们的代码,还提高了我们的正确率。但是我们使用auto定义一个变量之前必须要给它初始化。

注意事项:

1.在使用auto定义指针变量的时候,我们可以加上“ * ”也可以不加,但是声明引用类型的时候必须加:

2.当在同一行声明多个变量时,这些变量必须是相同的类型,否则编译器将会报错,因为编译 器实际只对第一个类型进行推导,然后用推导出来的类型定义其他变量。

void TestAuto()
{
    auto a = 1, b = 2; 
    auto c = 3, d = 4.0;  // 该行代码会编译失败,因为c和d的初始化表达式类型不同
}

3.auto不能用来作为函数的参数,也不能直接用来声明数组

// 此处代码编译失败,auto不能作为形参类型,因为编译器无法对a的实际类型进行推导
void TestAuto(auto a)
{}
void TestAuto()
{
    int a[] = {1,2,3};
    auto b[] = {4,5,6};
}

4.auto在实际中最常见的优势用法就是跟以后会讲到的C++11提供的新式for循环,还有 lambda表达式等进行配合使用。

        这里讲一下新式for循环:

        这是我们通常遍历数组的做法

void TestFor()
{
 int array[] = { 1, 2, 3, 4, 5 };
 for (int i = 0; i < sizeof(array) / sizeof(array[0]); ++i)
     array[i] *= 2;
 
 for (int* p = array; p < array + sizeof(array)/ sizeof(array[0]); ++p)
     cout << *p << endl;
}

         对于一个有范围的集合而言,由程序员来说明循环的范围是多余的,有时候还会容易犯错误。因 此C++11中引入了基于范围的for循环。for循环后的括号由冒号“ :”分为两部分:第一部分是范围内用于迭代的变量,第二部分则表示被迭代的范围。

        它的基本格式如下:

for (declaration : expression) {
    // loop body
}

        这里,declaration 是每次循环开始时声明和初始化的变量,expression 是一个可以产生范围(例如,数组、容器、字符串等)的表达式 。

        实际运用就是这样:

void TestFor()
{
 int array[] = { 1, 2, 3, 4, 5 };
 for(auto& e : array)
     e *= 2;
 
 for(auto e : array)
     cout << e << " ";
 
 return 0;
}

        C++11中把auto作为遍历数组的方式有点类似python,不过auto作为遍历数组还是有很多不足的,不如不能倒序,也不能从中间开始数。

        并且,像下面这段代码:

void TestFor(int array[])
{
    for(auto& e : array)
        cout<< e <<endl;
}

        由于范围不确定,传入的array只是首元素地址,范围不明确就会导致报错。

1.8nullptr

        在C语言和C++98中,NULL其实是一个宏,有这样的定义:

#ifndef NULL
#ifdef __cplusplus
#define NULL   0
#else
#define NULL   ((void *)0)
#endif
#endif

        所以上面我们的代码中原本是想要用f(0)调用f(int)用f(NULL)调用f(int*)的,但是其实它全部被用成了f(int),这就是因为NULL被宏定义成0导致的后果,指代不明。

        因此,在C++11中,多了一个专门用来定义空指针的nullptr横空出世,nullptr是一个专门用于空指针定义的void*类型变量,并且在C++11中nullptr的使用不需要包含头文件,它是作为一个关键字来使用的。

        为了提高代码的健壮性,在后续表示指针空值时建议最好使用nullptr。

2.类的引入

        C语言结构体中只能定义变量,在C++中,结构体内不仅可以定义变量,也可以定义函数。比如: 之前在数据结构初阶中,用C语言方式实现的栈,结构体中只能定义变量;现在以C++方式实现, 会发现struct中也可以定义函数。

        不过,在C++中,对于这种结构体的定义,C++一般都是使用class类来定义的。

class className
{
 // 类体:由成员函数和成员变量组成
 
};  // 一定要注意后面的分号

        class为定义类的关键字,ClassName为类的名字,{}中为类的主体,注意类定义结束时后面分 号不能省略。 类体中内容称为类的成员:类中的变量称为类的属性或成员变量; 类中的函数称为类的方法或者成员函数。

2.1类的内部

        类的内部函数一般叫做类的成员函数,如果一个成员函数在类中定义,为了效率,编译器一般会把它当做内联函数处理。        

        当然,也可以声明和定义分离,这种情况下也是会被当做内联函数处理的。在类里声明,在其他地方定义,不过在外定义的时候就要加上类名与类域限定符,否则无法访问这个函数。

        一般情况下,更期望采用第二种方式。注意:上课为了方便演示使用方式一定义类,尽量使用第二种。

        然后就是类的访问限定符和封装,用类将对象的属性与方法结合在一块,让对象更加完善,通过访问权限选 择性的将其接口提供给外部的用户使用。

        

【访问限定符说明】

1. public修饰的成员在类外可以直接被访问

2. protected和private修饰的成员在类外不能直接被访问(此处protected和private是类似的)

3. 访问权限作用域从该访问限定符出现的位置开始直到下一个访问限定符出现时为止

4. 如果后面没有访问限定符,作用域就到 } 即类结束。

5. class的默认访问权限为private,struct为public(因为struct要兼容C)

        一般来说,我们的成员变量都会定义在private中,并且成员变量一般都会加上_修饰,比如:

class Date
{
public :
private:
	int _year;
	int _month;
	int _day;
};

2.2this指针        

        下面有一段代码:

class Date
{
public:
	void Init(int year, int month, int day)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	void Print()
	{
		cout << _year << "-" << _month << "-" << _day << endl;
	}
private:
	int _year; 
	int _month; 
	int _day; 
};
int main()
{
	Date d1, d2;
	d1.Init(2022, 1, 11);
	d2.Init(2022, 1, 12);
	d1.Print();
	d2.Print();
	return 0;
}

        我们这里定义了d1和d2,Date类中有 Init 与 Print 两个成员函数,函数体中没有关于不同对象的区分,那当d1调用 Init 函 数时,该函数是如何知道应该设置d1对象,而不是设置d2对象呢?

        C++中通过引入this指针解决该问题,即:C++编译器给每个“非静态的成员函数“增加了一个隐藏 的指针参数,让该指针指向当前对象(函数运行时调用该函数的对象),在函数体中所有“成员变量” 的操作,都是通过该指针去访问。只不过所有的操作对用户是透明的,即用户不需要来传递,编 译器自动完成。

1. this指针的类型:类类型* const,即成员函数中,不能给this指针赋值。

2. 只能在“成员函数”的内部使用

3. this指针本质上是“成员函数”的形参,当对象调用成员函数时,将对象地址作为实参传递给 this形参。所以对象中不存储this指针。

4. this指针是“成员函数”第一个隐含的指针形参,一般情况由编译器通过ecx寄存器自动传 递,不需要用户传递

2.3类的默认成员函数

        一个类中什么成员都没有,简称为空类。在一个空类里面,自动什么都没有吗?

class Date {};

        比如上面的日期类Date,它的内部并不是什么都没有,而是有很多的默认成员函数

        下面,我们将逐步了解这些默认成员函数。

2.3.1构造函数

        这里的构造函数是一个函数的名称,是一种初始化成员变量的函数,并且它在我们构造一个类的时候自动调用

class Date
{
public:
 void Init(int year, int month, int day)
 {
 _year = year;
 _month = month;
 _day = day;
 }
 void Print()
 {
 cout << _year << "-" << _month << "-" << _day << endl;
 }
private:
 int _year;
 int _month;
 int _day;
};
int main()
{
 Date d1;
 d1.Init(2022, 7, 5);
 d1.Print();
 Date d2;
 d2.Init(2022, 7, 6);
 d2.Print();
 return 0;
}

        上面定义了一个类,包含一个成员函数Init,我们要使用这个Init作为初始化函数为成员变量赋初始值。这个是我们使用很原始的思想为它初始化,但其实,我们还有更简便的方法。

        在C++类中,默认定义了一个构造函数,无返回值(不用写返回类型),名字和类名一样,在定义一个类之后会自动调用。这个函数可以由编译器默认实现,当然,也可以由我们自己显式实现。

        在这里,我们显式实现构造函数,我们可以看到,确实是自动调用了构造函数

        在默认的构造函数中,并没有给我们的成员变量赋值,如果我们想要给它赋值,我们需要自己实现一个构造函数并赋初值。

        但是在我们自己写构造函数的时候,函数也是有需要注意的地方,首先,这个构造函数的参数可有可无,其次,我们的构造函数如果有参数的话要我们在定义一个类的实例的时候就要带上这个参数,否则就会报错。

       如果要解决这个报错,我们就要在实例化的时候就给一个参数,这样就可以解决了。

        不过,像这种情况,我们其实可以考虑到之前学过的缺省参数的用法,给所有参数一个缺省参数,这样我们就不用自己给参数了,如果需要参数的时候还可以写上去,默认会优先使用我们给的新参数:

    Date(int year = 2000,int month = 1,int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}

        构造函数其实是支持重载的,其实我们再写一个重载函数也可以,不过直接使用全缺省的构造函数是最简单的。

        但是重载需要注意,无参数构造和全缺省构造函数也构成重载,但是在无参数传入的时候,这种重载会被认定为指代不明,因为编译器会不知道使用哪一个函数,所以会报错。

 Date()
 {
 _year = 1900;
 _month = 1;
 _day = 1;
 }
 Date(int year = 1900, int month = 1, int day = 1)
 {
 _year = year;
 _month = month;
 _day = day;
 }
//Date d; -> 会报错
//Date d1(2020,1,1); ->不会报错

        当用户定义了构造函数之后,编译器就不会再生成默认构造函数了。

        对于初始化,在C++11后,对于默认生成的构造函数无法初始化的情况做了一个补丁,即内置类型成员变量在声明的时候可以给默认值:

class Date
{
public:
	//...
private:
	int _year = 2020;
	int _month = 1;
	int _day = 1;
};

        这里的给值相当于给缺省值,因为这里的所有成员变量都是声明而不是定义,所以这里所有的值都是缺省值。

2.3.2析构函数

        有了建造成员变量,自然就需要销毁成员变量和成员函数,所以这里又是一个默认的成员函数,叫做析构函数。析构函数的很多性质和构造函数差不多,比如自动调用,可以由用户显式实现等。不过,这里还是要讲一下它的特点:

  1. 析构函数名是在类名前加上字符 ~。
  2. 无参数无返回值类型。
  3. 一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。注意:析构 函数不能重载
  4. 对象生命周期结束时,C++编译系统系统自动调用析构函数。

        一般来说,在一些数据结构中,比如顺序表,链表等使用用户显式实现的析构函数就是很好用的,一般的普通类编译器默认析构函数其实也是够用的,不过在使用数据结构中才可以体会到它的魅力。

        另外,我们需要注意的是,编译器自动生成的析构函数不会释放自定义类型,会因此造成内存泄漏,所以在成员变量含有自定义类型的资源需要清理的时候,我们必须需要进行自己显式实现析构函数。除非,我们实现的类内部自定义成员也是类,而且类的内部有自行清理的办法。这种时候我们就可以不用自己实现析构函数

2.3.3拷贝构造

        拷贝构造函数就像它的名字一样,一般用来拷贝构造,它也属于构造函数的一种特殊重载形式,是用同类型的对象初始化

        拷贝构造函数只有单个形参,该形参是对本类类型对象的引用(一般常用const修饰),在用已存在的类类型对象创建新对象时由编译器自动调用。

	Date(Date& d)
	{
		_year = d._year;
		_month = d._month;
		_day = d._day;
	}

        这里是一般的构造函数的结构,注意这里的d必须是引用,否则会引发无穷递归:

        如果不是引用 ,就会在形参部分会连续构造很多个该类型变量,这样的话,就会造成无穷递归。

class Date
{
public:
	Date(int year = 2020, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	Date(Date& d)
	{
		_year = d._year;
		_month = d._month;
		_day = d._day;
	}
private:
	int _year = 2020;
	int _month = 1;
	int _day = 1;
};
int main()
{
	Date d1(2023, 1, 1);
	Date d2(d1);
	return 0;
}

        上面就是一个拷贝构造函数的实现和使用。

        构造函数和其他默认成员函数一样,若未显式定义,编译器会生成默认的拷贝构造函数。 默认的拷贝构造函数对象按内存存储按 字节序完成拷贝,这种拷贝叫做浅拷贝,或者值拷贝。

        所以,如果我们的自定义类型不是单纯只需要值的时候,这种时候我们需要自己实现拷贝构造。比如我们用顺序表实现一个栈,我们定义了st1,st1中有一个数组,当我们拷贝构造给st2,st2也会指向这块空间的数组,这是很可怕的,如果数据的插入和删除会影响数据还算小事,那我们的st1和st2其中一个释放空间就是一个很危险的事情了,这很有可能会导致程序崩溃,所以面对拷贝构造的时候,我们需要判断我们是不是只需要浅拷贝,再去考虑是不是要自己实现一个拷贝构造。

        在实现一个函数传入一个自定义形参和返回一个自定义类型的时候,都会发生拷贝构造。

void Func1(Date d)
{
	//...
}

Date Func2()
{
	Date d;
	return d;
}

        比如这里两种都会发生拷贝构造,Func1会先拷贝传入的参数,在把这个值传入形参中,这样实现一次拷贝构造,然后这个构造还会再Func1结束之后销毁,Func2也差不多,Func2中定义了一个d,返回d其实并不是返回d本身,而是返回d的构造,先拷贝构造这个返回值,再返回,之后再把原来的d销毁。

        但是,其实我们实现的拷贝构造是有缺陷的,我们如果定义一个d1,再拷贝构造d2,这个d2其实是有更改d1的风险的,因为在拷贝构造里面,d1传入的是别名,这个是可以通过这个更改d1的,所以我们应该再修改一下我们的拷贝构造:

	Date(const Date& d)
	{
		_year = d._year;
		_month = d._month;
		_day = d._day;
	}

        加上const,让程序更加安全。

2.4运算符重载

2.4.1赋值运算符格式

        C++为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其 返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。

         函数名字为:关键字operator后面接需要重载的运算符符号。

         函数原型:返回值类型 operator操作符(参数列表)

注意:

  • 不能通过连接其他符号来创建新的操作符:比如operator@
  • 重载操作符必须有一个类类型参数
  • 用于内置类型的运算符,其含义不能改变,例如:内置的整型+,不 能改变其含义 作为类成员函数重载时,其形参看起来比操作数数目少1,因为成员函数的第一个参数为隐藏的this
  • .* :: sizeof ?: . 注意以上5个运算符不能重载。这个经常在笔试选择题中出现

        比如这里我们可以定义一个==重载:

        但是我们发现这里的代码有报错,报错内容如下:

               这是因为它现在在这个类的范围内部,我们编译器就把它当做成员函数了,在前面我们提过,运算符重载函数作为成员函数的时候它会有一个隐藏的参数,即这个函数中的this,所以我们再传入两个实例就会导致参数过多,所以就报错了。怎么解决?两个办法:

        方法一:把函数移到外部,作为全局函数使用,并且直接把类内部的成员开放:

// 全局的operator==
class Date
{
public:
	Date(int year = 1900, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}

//private:
	int _year;
	int _month;
	int _day;
};

// 这里会发现运算符重载成全局的就需要成员变量是公有的,那么问题来了,封装性如何保证?
// 这里其实可以用我们后面学习的友元解决,或者干脆重载成成员函数。
bool operator==(const Date& d1, const Date& d2)
{
	return d1._year == d2._year
		&& d1._month == d2._month
		&& d1._day == d2._day;
}

        方法二:作为成员函数使用,并且使用隐藏的参数:

class Date
{
public:
	Date(int year = 1900, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	bool operator==(const Date& d1)
	{
		return d1._year == _year
			&& d1._month ==_month
			&& d1._day == _day;
	}

private:
	int _year;
	int _month;
	int _day;
};

        一般来说,我们常用的还是方法二,直接定义成成员函数,还可以加快调用效率,然后我们就可以测试使用了:

        但是我们发现我们这样写的时候会报错,其实本质上<<其实也是一种操作符重载,所以<<和==就是同等级的操作符,那么就会从左到右依次执行,这样就会产生错误,所以我们就要在我们的等式外部包上():

int main()
{
	Date d1(2023, 1, 1);
	Date d2(2023, 1, 2);
	cout << (d1 == d2) << endl;
	return 0;
}

        其他的操作符函数重载也是可以这样使用

2.4.2 赋值运算符重载格式

参数类型:const T&,传递引用可以提高传参效率

返回值类型:T&,返回引用可以提高返回的效率,有返回值目的是为了支持连续赋值

检测是否自己给自己赋值

返回*this :要复合连续赋值的含义

	Date& operator=(const Date& d)
	{
		if (this != &d)//如果是同一个变量就不赋值
		{
			_year = d._year;
			_month = d._month;
			_day = d._day;
		}
		return *this;
	}

        像这种普通的日期类不涉及到资源管理,我们的赋值运算符重载其实和编译器给的默认拷贝构造没什么区别,但是我们的成员变量涉及到资源管理的时候我们就要开始考虑怎么才可以实区域现拷贝但是却又不把两个资源搞混,这种时候我们的赋值运算符的实现是很有必要的。

2.4.3前置++和后置++

        在我们使用普通类型的时候有前置++和后置++两种操作符,这种时候我们可以通过++的位置来确定它是哪一种,但是在我们定义重载函数的时候,操作符必须在operator的后面,这个时候我们就无法区分两种操作符了,这里我们规定:前置++不作处理,后置++要在参数列表中给一个int,这个int可以不定义任何东西,也可以不声明,只要有这样一个int在就可以区分前置和后置++

 // 前置++:返回+1之后的结果
 // 注意:this指向的对象函数结束后不会销毁,故以引用方式返回提高效率
 Date& operator++()
 {
 _day += 1;
 return *this;
 }
 // 后置++:
 // 前置++和后置++都是一元运算符,为了让前置++与后置++形成能正确重载
 // C++规定:后置++重载时多增加一个int类型的参数,但调用函数时该参数不用传递,编译器自动传递
 // 注意:后置++是先使用后+1,因此需要返回+1之前的旧值,故需在实现时需要先将this保存一份,然后给this+1
 //       而temp是临时对象,因此只能以值的方式返回,不能返回引用
 Date operator++(int)
 {
 Date temp(*this);
 _day += 1;
 return temp;
 }

2.4.4const成员

        在我们的默认的this指向的对象是const修饰的时候,我们的传入参数是会报错的:

.

        比如这里,d1在左边,d1传入的时候就是当做那个this指向的成员,但是一个const修饰之后的是只读的变量,传入函数中却变成了可读可写的变量,这样造成了权限的放大,所以我们需要使用const修饰this才可以解决这个问题,这里我们想要修饰隐藏的this,只要在函数的后面加上const即可:

	bool operator==(const Date& d1)const
	{
		return d1._year == _year
			&& d1._month ==_month
			&& d1._day == _day;
	}

        这样this指针就被const修饰了,我们的操作符重载就不会导致权限的放大了,一般来说,我们的所有函数只要不需要更改this指向的变量,我们都可以用const修饰一下,这样是最安全而且最通用的一种办法。

        另外,取地址及const取地址操作符重载编译器会自动实现,我们不必自行实现。

2.5初始化列表

        这里我们再来回顾我们的构造函数,在创建对象时,编译器通过调用构造函数,给对象中各个成员变量一个合适的初始值。

        构造函数调用之后,对象中已经有了一个初始值,但是不能将其称为对对象中成员变量的初始化, 构造函数体中的语句只能将其称为赋初值,而不能称作初始化。因为初始化只能初始化一次,而构造函数体 内可以多次赋值。

        这里,我们引入初始化列表的概念:以一个冒号开始,接着是一个以逗号分隔的数据成员列表,每个"成员变量"后面跟一个放在括 号中的初始值或表达式。

class Date
{
public:
 Date(int year, int month, int day)
 : _year(year)
 , _month(month)
 , _day(day)
 {}
 
private:
 int _year;
 int _month;
 int _day;
};

        可以看到,我们使用了初始化列表之后,我们不需要在构造函数中再使用之前的赋值模式,其实使用初始化列表和之前的区别就是初始化列表类似于带了一个const。每个成员变量在初始化列表中只能出现一次(初始化只能初始化一次)

        类中包含以下成员,必须放在初始化列表位置进行初始化:

  •         引用成员变量
  •         const成员变量
  •         自定义类型成员(且该类没有默认构造函数时)

        尽量使用初始化列表初始化,因为不管你是否使用初始化列表,对于自定义类型成员变量,一定会先使 用初始化列表初始化。

        另外,成员变量在类中声明次序就是其在初始化列表中的初始化顺序,与其在初始化列表中的先后次序无关

2.6构造函数的隐式类型转换和explicit关键字

        如果一个构造函数接受单个参数,并且该参数的类型可以隐式转换为构造函数的参数类型,那么这个构造函数可以用于隐式类型转换。这意味着你可以使用一个参数来初始化一个对象,而不需要显式调用构造函数。

        这种隐式类型可以让我们节省很多的精力,比如我们使用栈有push等操作的时候:

        这里假设我们已经定义了一个stack类,类的内部也实现了push操作,根据我们以前的经历,push需要传入一个实例化的stack,我们可能需要写出下面的代码:

stack s1(1);
stack s2(2);
s1.push();
s2.push();

        这样其实是有一点冗杂的,我们可以写成这样:

stack.push(1);
stack.push(2);

        这样就简单多了,这里的1和2其实进行了隐式类型转换,即直接自动传入了s1(1)和s2(2),恶魔可以不需要显式构造了,这样其实是方便很多的。

        隐式类型转换在只有单参数和多参数有缺省的时候是可以使用的,比如:

Date(int year)
 :_year(year)
 {}
Date(int year, int month = 1, int day = 1)
 : _year(year)
 , _month(month)
 , _day(day)
{}

        这里一个就是单参数,一个就是全缺省可以只传单参数 。

这种时候我们如果写出下面的代码:

Date d1(2022);

         用一个整形变量给日期类型对象赋值, 实际编译器背后会用2022构造一个无名对象,最后用无名对象给d1对象进行赋值.这样效率其实是会一定程度上变低的。

        所以有的时候我们可能有禁止使用这种隐式类型转换的需求,这种时候我们就可以使用explicit关键字实现禁止隐式类型转换。

explicit Date(int year)
 :_year(year)
 {}
explicit Date(int year, int month = 1, int day = 1)
 : _year(year)
 , _month(month)
 , _day(day)
 {}

2.7static成员

      声明为static的类成员称为类的静态成员,用static修饰的成员变量,称之为静态成员变量;用static修饰的成员函数,称之为静态成员函数。静态成员变量一定要在类外进行初始化 。

1. 静态成员为所有类对象所共享,不属于某个具体的对象,存放在静态区

2. 静态成员变量必须在类外定义,定义时不添加static关键字,类中只是声明

3. 类静态成员即可用 类名::静态成员 或者 对象.静态成员 来访问

4. 静态成员函数没有隐藏的this指针,不能访问任何非静态成员

5. 静态成员也是类的成员,受public、protected、private 访问限定符的限制

 

static int Count()
{
	static int ret = 0;
	ret++;
	return ret;
}
class Date
{
public:
	static int Count();
	Date(int year = 1900, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}
private:
	int _year;
	int _month;
	int _day;
};

int main()
{
	Date::Count();
	return 0;
}

        比如我们可以这样声明定义并使用一个静态成员函数,静态成员函数用的不太多,但是在很多时候有很大的用处。 

2.8友元

2.8.1友元函数

        友元函数(friend function)是被声明为友元的非成员函数,它可以访问类的私有(private)和保护(protected)成员。友元函数不是类的成员函数,但它可以像类的成员函数一样访问类的内部数据。

        一般来说,我们的友元函数的声明放在类的内部,它的定义放在外部,不过在C++11之后我们也可以定义在内部,这种叫内联友元函数,这里不作探讨,更多的还是分离的。

        我们声明友元函数的位置是没有限制的,只要在类的内部即可,不过更多的还是声明在最开始的位置,我们再在外面定义友元函数并将类的对象作为参数传递,这样我们就可以使用友元函数直接使用私有成员了,并且我们调用友元函数是全局的,我们可以把它当做普通函数调用:

void Print(const Date& d)
{
	cout << d._year << "-" << d._month << "-" << d._day << endl;
}
class Date
{
	friend void Print(const Date& d);
public:
	Date(int year = 1900, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	
private:
	int _year;
	int _month;
	int _day;
};

int main()
{
	Date d1(2023, 1, 1);
	Print(d1);
	return 0;
}

        这样就很容易访问私有成员了,这里的传参也可以是没有const的变量,不过我们的函数没有修改这个变量的需求,所以加上const修饰更安全一点。

2.8.2友元类

        

class Date
{
	friend class Time;
public:
	Date(int year = 1900, int month = 1, int day = 1)
		:_year(year),
		_month(month),
		_day(day)
	{}
private:
	int _year;
	int _month;
	int _day;
};

class Time
{
public:
	Time(int hour = 8, int minute = 30, int second = 30)
		:_hour(hour),
		_minute(minute),
		_second(second)
	{}
private:
	int _hour;
	int _minute;
	int _second;
};

        和友元函数类似,这里的友元类就是定义另外一个类B,然后在类A中声明友元类B,这样我们就可以在B中访问A中的成员了。放在上面的例子就是我们定义了一个Date类,又定义了一个Time类,我们在Date类中声明了Time是Date的友元,这里就可以在Time类中访问Date的成员,但是反过来却会有访问限制。

        可以打个比较形象的比方:我们在Date中声明Time是Date的友元,就好像Time在Date中安排了“间谍”,间谍可以访问Date中的成员,但是Date却不能通过这个间谍访问Time,除非Date也在Time中声明一个“间谍”即友元。

        特殊的,我们在一个类A里面声明另外一个类B,那么这个类B就自动成为了类A的友元,B可以访问A,但是A不能访问B,和之前的规则类似,只是把友元类的声明换成了类的定义。

相关推荐

  1. C++对象(1)

    2024-06-06 22:48:02       25 阅读

最近更新

  1. TCP协议是安全的吗?

    2024-06-06 22:48:02       18 阅读
  2. 阿里云服务器执行yum,一直下载docker-ce-stable失败

    2024-06-06 22:48:02       19 阅读
  3. 【Python教程】压缩PDF文件大小

    2024-06-06 22:48:02       18 阅读
  4. 通过文章id递归查询所有评论(xml)

    2024-06-06 22:48:02       20 阅读

热门阅读

  1. 双亲委派模型

    2024-06-06 22:48:02       7 阅读
  2. C++构造器设计模式

    2024-06-06 22:48:02       8 阅读
  3. 运维开发详解

    2024-06-06 22:48:02       7 阅读
  4. C++学习笔记

    2024-06-06 22:48:02       7 阅读
  5. 常微分方程 (ODE) 和 随机微分方程 (SDE)

    2024-06-06 22:48:02       12 阅读
  6. 【面试宝藏】Go并发编程面试题

    2024-06-06 22:48:02       6 阅读
  7. Linux学习—Linux环境下的网络设置

    2024-06-06 22:48:02       9 阅读
  8. 【力扣】不同的子序列

    2024-06-06 22:48:02       7 阅读
  9. c time(NULL) time(time_t *p) 区别

    2024-06-06 22:48:02       9 阅读
  10. 回溯算法全排列

    2024-06-06 22:48:02       9 阅读
  11. 数据仓库之核心模型与扩展模型分离

    2024-06-06 22:48:02       8 阅读