入职培训总结之C++基础

一、语言入门

1)从C转向C++

C是一门简单的语言,真正提供的元素只有宏、指针、结构、数组和函数,不管遇到什么问题,C都尝试靠这些来解决。

而C++不一样:除了宏、指针、结构、数组和函数,此外还提供公有、保护、私有型成员、函数重载,缺省函数、构造和解析函数、自定义操作符、内联函数、引用、友元函数、模板、异常、命名空间等等……

因此,C++比C具有更加宽广的空间,设计时也有更多的选择可以考虑。

2)C++看作一个语言联邦

C++“联邦”包含的“部落”有:

  • C
  • Object-Oriented C++
  • Template C++
  • STL

C++工作学习书籍推荐:

  • C++ Primer
  • Effective C++
  • C++ 标准程序库

C++相关知识,会随着书本提供的建议实践中的经验慢慢积累。

3)初始化与赋值

  • 初始化指的是给出构造条件,创建出不存在的对象;

  • 赋值则代表为已经存在的对象赋予新的值。

  • C++中,一个赋值运算符=不再是简单的内存复制,而是一次更加耗时的operator=函数调用

C++中提供了一种初始化对象的方式:

1
2
int i(0);
int i{0};

在初始化列表里,只能使用()来初始化成员。

使用关键字auto声明并初始化变量,自动判断变量类型:

1
2
3
auto i = 0;
auto add =[](int a,int b)->int { return a+b;};
cout << add(10,20);

4)C++中的头文件

  • C++中的头文件,规范格式没有.h后缀,直接用文件名即可,比如#include<iostream>
  • 如需在C++中使用C标准库中的头文件,建议格式:#include<name.h>->砍头去尾#include<cname>

5)<iostream><stdio>相比

  • scanf与printf轻巧高效,但不是类型安全的:代码编译时不会根据格式信息检查参数,在scanf过程中只要有一个变量输入失败,该语句后续的变量都会跳过。

    1
    2
    3
    4
    5
    6
    7
    #include<stdio.h>

    int main(){
    float f=4.5f;
    printf("%d\n",f);//0
    printf("%d,%d.\n",f,sizeof(double));//某次输出0, 1074921472?
    }
  • cin与cout具有可扩展性:类只要重载了>><<运算符,就可以跟基本类型一样输入输出

  • cin与cout的变量控制格式的信息是一体的,而scanf与printf必须分开指明

io流对象的基本原理:在iostream头文件中,定义了两个对象extern istream cinextern ostream cout

6)命名空间

1
2
3
4
5
6
7
#include <iostream>
using namespace std;

int main(){
cout<<"Hello World";//其实是调用了一次`operator<<`
return 0;
}

在这里,using namespace std;是对使用命名空间的一个声明,cout其实是std::cout的简写,其中,cout是命名空间,::是作用域限定符。

不同命名空间,给了程序员在同一程序中使用不同命名空间的函数的方法,也避免了不同空间下同名函数的冲突

7)C++类型转换

不同于C中笼统的强制类型转换,C++有四种类型转换的形式:

  • const_cast,能够剥夺掉const属性进行赋值(原变量不受影响)

    1
    2
    3
    int i=20;
    const int *p=&i;
    int *q=const_cast<int*>(p);
  • static_cast,主要用于静态变量的转换

    1
    2
    float f=4.7f;
    int i=static_cast<int>(f+0.5f);
  • reinterpret_cast,用于指针的类型转换

    1
    2
    3
    int *p=&i;
    //float *pf=(float*)p;
    float *pf=reinterpret_cast<float*>(p);
  • dynamic_cast,动态类型转化,后续在多态中讲解

8)使用STL

  • 容器

    所谓容器,即“载物之物”,比如string,承载的是字符,其自身就可以看作是一个字符的容器

    STL中的容器包括:

    • 顺序性容器(vector、deque、list);
    • 关联容器(map、set);
    • 容器适配器(queue、stack)
  • 算法,后续补充

  • 尽量用new/delete而不是malloc/free

    这两套方法都是用来申请堆内存的,关于为何要使用堆内存:一是要处理动态分配,而是因为对有更多空间可用;

    new/delete是面向对象的方法,会分别调用对象的构造函数和析构函数

    1
    2
    3
    4
    5
    6
    7
    8
    9
    int *pi=new int(10);//不是new int[10]
    cout<<*pi<<endl;
    delete pi;

    string *ps=new string("Hello");
    string str=ps->substr(3);
    cout<<str<<endl;
    delete ps;
    ps=NULL;//更好的方式是指向nullptr,因为NULL还有一个隐藏含义:0值

    堆区申请的内存没有名字,只能通过指针来指向;**因此在delete后必须将指针置空否则会变为野指针**

    1
    2
    int *pi1 = new int();//默认初始化为0
    int *pi2 = new int;//不会初始化
  • new[]delete[]

    1
    2
    3
    int *pAss=new int[10];
    delete[] pArr;
    pArr=NULL;

STL的全称:Standard Template Library,

9)引用类型

所以引用,就是给对象起的别名,它不是指针,也不是对象的副本

引用必须初始化且只能用与该引用相同类型对象进行初始化,初始化后不能再绑定到别的变量;

1
2
3
4
int ival=1024;
int &refVal=ival;//refVal指向了ival
int &refVal2;//报错,引用必须初始化
int &refVal3=10//报错,初始化对象必须是对象类型

const引用

参照const指针,const引用的含义是:不能通过此引用去修改对象本身

const关键字的位置:只要写在引用定义符&的前面即可

1
2
3
4
5
6
7
8
9
int i=10;
const int &ri=i;
i=20;//ok
ri=10;//not ok

const T &rt=t; or T const &rt=t;
const char *p=NULL;
const const char *&rp=p;
const char* const &rp=p;

小练习

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int Val = 0;
int &refVal = Val;
refVal = i;

int val = 10;
int &refval = val;
int &rrefval = refval;//注意这不是“引用的引用”
//x现在val有三个名字:val、refval、rrefval
int *p = &val;
int &refp = *p;
cout<<val<<*p<<refp<<endl;//从左到右分别称为直接访问、间接访问、引用访问

int (*p)[10] = &arr;
int (&rarr)[10] = arr;//声明一个数组的引用

将数组作为指针和作为引用传递的区别:

  • int *p,p可以指向任意长度的数组
  • int (&arr)[10],那么&arr这个引用只能指向10整型数的数组
1
2
3
const int &i = 10;//一个特例,这里的10不是对象而是常量,但更推荐下面两种方式
#define N 10
const int i=10;

10)函数参数,“传值”与“传地址”

  • 如果传递指针是为了:
    • 提高效率,则使用const T *ptr为参数,防止修改变量
    • 扩展功能,则使用T *ptr为参数
  • 传指针与传引用的比较:
    • 两种方式都能改变主调函数栈内的变量
    • 引用更加安全,因为不会出现NULL reference,p[10]也不会警告
    • 引用更加方便,因为不需要使用*进行间址运算

小练习,引用传递交换整数值

1
2
3
4
5
6
7
8
9
10
11
vooid swap(int &a, int &b){
int tmp=a;//不需要像指针那样加*
a=b;
b=temp;
}
int main(){
int a(10),b(20);
swap(a,b);
cout<<a<<b<<endl;//20,10
return 0;
}

11)右值引用

右值引用可用于为临时对象(即将销毁)延长生存期(const 左值引用也能延长,但由于其const属性,无法修改左值)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include<iostream>
#include<string>
using namespace std;
int main(){
string s1="Test";
//string &&r1=s1;//错误,引用不能绑定到左值

const string &r2=s1+s1;//到const的左值引用延长生存期
//r2+="Test";//错误,不能通过到const的引用修改

string &&r3=s1+s1;//右值引用延长生存期
r3+="Test";//通过到非const的引用修改
cout<<r3<<'\n';
}

12)有关函数的一些变化(扩展)

  • 一、带默认实参值的参数

    如果函数带有默认实参,那么调用时就可以省略掉这些参数,编译器会使用提供的默认值,这是一种方便函数使用的方式。

    1
    2
    3
    void point(int x=3, int y=4){……}
    point();//x=3,y=4
    point(0);//x=0,y=4

    两点注意事项:

    • 参数列表中的默认是残,右侧必须全为默认实参void foo(int i,int j=0,int k=0);
    • 虽然函数定义中也可出现默认实参,但编码规范要求实参一律出现在函数声明处(如果声明中出现了默认实参则函数定义不可再次说明,如果没有函数说明,则默认实参必须在函数定义时指出)

    三个使用示例

    默认实参示例
  • 二、函数重载overload

    问题的产生:以图片绘制为例,其绘制的需求包括但不限于下面类型:

    • 从左上角开始,保留原图大小和质量绘制
    • 从左上角开始,限制画布的宽和高,部分显示
    • 从左上角开始,限制画布的宽和高,选定图片源宽和高,按比例缩放
    • ……

    这些绘制方法虽然在实现功能的细节上有所差异,但是实现的功能都是相同的,区别就在于绘制方法,即输入参数的不同。我们可以将这些功能用同名但不同结构的函数进行实现。

    同一个作用域中(跨命名空间即无重载概念),将同一个名字用于不同形参表(形参个数或形参类型不同)的多个函数的情况就叫函数重载。

  • 关于main()

    • 任何程序都仅有一个main函数的实例,因此main函数不能重载
    • main不是C++中的关键字,main函数有参数
    • main函数前,可通过定义全局变量、实现其构造函数的方法执行其他函数
    • main函数后,可通过atexit()函数来注册main函数执行完之后执行的其他函数
  • 默认参数与重载的关系

    1
    2
    void f(int i=0);
    void f(int i=1);

    上面一对函数,其形参列表只有默认参数不同,默认参数并没有改变形参的个数或类型。无论实参是由用户还是由编译器提供的,这个函数都带有两个实参

    因此,默认参数的不同不构成区别函数重载。必须满足同一作用域、不同形参才能形成重载。

  • 三、inline函数

    先来回顾一下宏的概念:宏在程序开头进行定义,使用处进行展开,不会带来程序调用的开销

    inline,即内联函数,也会在使用处进行展开,但与宏不同的是,inline函数会进行参数的计算,因此可以使用表达式,是建议的方式。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    inline int MAX(int a,int b);
    int i=5;
    int j=8;
    MAX(i++,j);
    cout<<i;

    int i=10;
    int j=8;
    MAX(i++,j);
    cout<<i;

    两点注意事项:

    • inline只是向系统的建议,而不是强制执行,因此有可能不会生效
    • inline函数会在编译阶段进行展开
  • 尽量使用const和inline而不是#define

    • #define仅仅是字符串替换,不具备类型
    • #define不便于程序的调试,因为会在编译阶段进行替换,替换后程序中就看不到这个名字了
    • #define写出的带参宏容易产生问题(忘记括号会造成逻辑错误)

10)range-for:在一个区间中直接执行for循环

此语法类似于python的for循环range-for

二、类与对象

关于类,已经学习过的内容:

  1. 如何使用已定义类提供的方法:在实用类之前包含相关头文件,比如

    1
    2
    3
    #include<string>
    ……
    string LastNames[4]={"Zhao","Qian","Sun","Li"};
  2. C++中,为每个已定义的类都提供了初始化对象的方法:

    1
    2
    3
    4
    string durmmy ("dummy");
    vector<string> svec1(4);
    vector<string> svec2(4,durmmy) ;
    vector<string> svec3(LastNames,LastNames + 4 );

    为什么能声明定义这些同名的初始化方法,而在C中不允许呢?

  3. 每个类都会有一系列的操作函数(与之对比,C只能提供一些数据属性),比如vector的size等。这些类的函数是如何实现的?

带着这些问题,开始下面的学习

问题提出:用C的方式设计一个时钟程序

时钟程序基本方法

基于“程序=数据结构+算法”的宗旨,我们可能写出如下的结构体来表示时钟程序(C++的struct是可以写函数的):

1
2
3
4
5
6
7
8
9
typedef struct Clock 
{
int hour;
int minute;

void InitClock(Clock* pc, int h, int m);
void SetTime(Clock* pc, int h, int m);
void ShowTime(const Clock* pc);
}

抽象数据类型ADT的引入

不同于关注各个函数之间交互的C语言,

在C++中,用类来定义自己的抽象数据类型(abstract data types),通过定义类型来对应所要解决问题中的各种概念,可以使我们的程序编写、调试和修改更加容易。这就是面向对象的思想

如何创建一个类

首先明确三个OOP概念:

  1. 抽象–数据抽象、行为抽象

    • 抽象能力:以抽象时钟为例
    • 数据抽象:int hour 、 int minute、int second
    • 行为抽象:
      showTime() 、 setTime ( ) 、...
  2. 封装:语义与语法

    浅层(基本)认识:封装就是将抽象得到的数据和行为相结合,形成“类”的过程,其中数据和函数都是类的成员

    1
    2
    3
    4
    5
    class ClassName{
    public:
    protected:
    private:
    };

    封装

    语法层面理解:封装是C++给我们提供的一种机制时钟“装箱”

  3. 类和对象

    针对项目不同,所抽象出来的属性可能不同。

类的成员

类可以没有成员,也可以定义多个类型成员;这些成员本身可能是数据、函数、自定义类型别名(iterator、typedef)、enum等等

类型别名(typedefine)的好处:

  • 简化代码,方便类型的使用和理解
  • 这只能加程序可移植性

类的定义

一般而言,数据部分作为私有部分保护起来,对外的接口作为公共部分

1
2
3
4
5
6
7
8
9
10
class Clock{
public:
void SetTime(int newHour, int newMinute);
void ShowTime();
private:
int m_hour;
int m_Minute;
};
Clock c;
c.SetTime(12,24);

通过定义类,就定义了一个新的类型

类的成员函数实现

1
2
3
4
void Clock::SetTime(int newHour, int newMinute){
m_Hour = newHour;
m_Minute = newMinute;
}

可见类的成员函数需要使用类名进行限制,

通过定义类,就定义了一个新的作用域

1
2
3
4
5
class Clock{
private:
int m_Hour;
int m_Minute;
}

类成员函数定义的两种方式:

  • 类内定义(隐式的内联)

    1
    2
    3
    4
    5
    6
    7
    8
    class Clock{
    ……
    public:
    void SetTime(int newHour,int newMinute){
    m_Hour = newHour;
    m_Minute = newMinute;
    }
    }
  • 类外定义(默认非内联,可以显示表达为inline)

    1
    2
    3
    4
    void Clock::ShowTime(){
    cout<<setfill('0')<<setw(2)<<m_Hour<<":"<<setw(2)<<m_Minute;
    cout<<setfill(' ');
    }

实现一个自定义类,一般分三步:

  • 创建.h头文件,放置类的声明
  • 创建.cpp源文件,实现类成员函数
  • 调用测试,一般在main.cpp中引用头文件创建对象调用对应的方法

再看Class,两点思考

  1. 能够将数据封装到private空间,维护方便,即使程序升级外界也感受不到,这就是私有成员的好处。
  2. class使程序更加模块化,逻辑清晰

思考:class与struct的区别:

**区别主要在于默认访问权限**,class默认私有,struct默认public

类的深入知识

  1. 类的作用域

    由三部分组成:

    • 类内
    • (类外定义)函数成员的形参表部分
    • (类外定义)函数成员函数体部分

    类外函数成员返回值不一定在类作用域中,以Clock的构造函数为例:

    1
    2
    3
    Clock::HOUR clock::GetHour(){
    return m_Hour;
    }
  2. 类内访问与类外访问(针对的是类的作用域,而不是类的声明)

    类外访问,只能访问到公有数据

  3. 隐含的this指针:使得成员函数能够得到调用它的对象的数据

    每一个成员函数都有一个隐含的参数,即classType* const this

    • 成员函数内对成员的访问都是:this->成员

      1
      2
      3
      4
      void Clock::SetTime(int newHour, int newMinute){
      this->m_Hour=newHour;
      this->m_Minute=newMinute;
      }
    • 类外访问函数成员,会把对象的地址传给this指针

    this指针妙用:连锁调用

    1
    2
    3
    4
    5
    6
    Clock& Clock::SetTime(int newHour, int newMinute){
    this->m_Hour=newHour;
    this->m_Minute=newMinute;
    return *this;
    }
    c1.setTime(12,0).showTime();

本节小结

  1. 创建类,就是创建类型,就是创建作用域
  2. 设计类,就像是设计一个软件

设计类与设计软件

三、构造与析构

这节主要学习类的三个重要函数,构造函数,拷贝构造函数,析构函数

构造函数

就是用于创建时对类的实体对象,做初始化操作

构造函数是特殊的成员函数,只要创建类类型的新对象,都要执行构造函数,保证每个对象的数据成员具有合适的初始值。

赋值与初始化的区别:

1
2
3
int i=0;
int j;
j=0;

构造函数调用时机:创建对象(声明新的对象)时

  • 无论对象有无名字
  • 无论对象被创建的区域
  • 无论对象是否是临时对象

语法规则,特殊点:

  1. 构造函数与类同名
  2. 不可有任何返回值
  3. 可以有初始化列表(只有构造函数有初始化列表)

以Clock为例。两种初始化:

Clock构造函数

关于初始化列表的用途和意义:一些特殊的成员,只允许初始化而不允许赋值,(如引用,字符串常量)的情况,可以使用初始化列表而不在函数体内进行赋值

常量字符串不允许赋值

如上方的序列号,其类型为const string m_serial_number,正确的初始化方式应当是在初始化列表中操作。

具体有哪些成员不能被赋值?

  1. const变量
  2. 引用
  3. IO流对象

如果类内包含这三类成员,一定要使用初始化列表对他们进行初始化工作,这是第一个注意事项
初始化列表一定要按照成员变量的声明次序书写以免引发隐晦的错误,这是第二个注意事项

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class A{
public:
A()∶j(10),i(j/2){}//i(5),j(2*i)
void show(){
cout<<i<<""<<j<<endl;
}
protected:
private:
int i;
int j;
};
int main ()
{
a;
a.show();
return 0;
}

上面的例子就不会按照我们所预想的那样赋予i,j期望的值,而是j=10,i未知(一个未初始化的值)

构造函数的使用,分为一般和默认构造函数两种。

默认构造函数的使用上,类初始化不带任何初始值,则编译器调用的就是默认构造函数

1
2
3
4
Clock clock(0,0);//注意不是Clock(0,0)

Clock::Clock();
Clock c;

如果一个类中出现多个默认构造函数,编译器会提出警告,运行时发生错误
在默认构造函数中使用默认参数,也能减少代码重复

构造函数不可由对象调用,是由编译器隐含调用的!

拷贝构造函数

是特殊的构造函数,调用时机:使用一个已经存在的对象去初始化同类的一个新对象时。其形参为本类的对象引用。

作为编码规范,建议使用const引用

语法规则上,与构造函数一样,和类同名,建议常引用

1
2
3
4
ClassName(const ClassName &name);
ClassName::ClassName(const ClassName &name){
……
}

使用上,介绍了三种拷贝构造调用示例,

  • 第一种使用已存在的对象去初始化同类的一个新对象,如:

    Point B(A);Point B=A;等价

  • 第二种 如果某函数的形参为类对象,调用函数时实参赋值给形参,系统会自动调用拷贝构造函数,

    1
    2
    3
    4
    5
    6
    7
    void foo(Point p){
    cout<<p.GetX()<<endl;
    }
    void main(){
    Point A(1,2);
    foo(A);
    }
  • 第三种 如果某函数的返回值是类对象,则系统也会自动调用拷贝构造函数

    1
    2
    3
    4
    5
    6
    7
    8
    Point foo2(){
    Point A(1,2);
    return A;
    }
    void main(){
    Point B;
    B=foo2();
    }
拷贝构造

析构函数

当对象生命期即将结束时自动调用,通常用来完成对象被删除前的一些清理工作,比如释放申请的内存空间
注意事项:

  1. 命名:波浪线加类名
  2. 没有任何参数 没有返回值
  3. 函数体内具体工作是资源清理

理解何时需要析构函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Array
{
public:
Array(int size):nsize(size) { pArray = new int[nsize]; }
//int& operator[ ] (int n){ return pArray [n] ; }
int Getsize() { return nsize; }
//……
int& GetItemByIndex(int idx){ return pArray[idx];}
~Array(){ delete[] pArray; pArray =NULL; }
protected:
private:
int nsize;
int *pArray;
};

上面的代码是示例1,下面的代码是示例2:

1
2
3
4
5
6
int main(){
Array a(10);
a[0] = 10;
cout<<a[0]<<endl;
return 0;
}

在示例2中,程序结束后申请的十个整形数的内存空间并没有释放掉,这造成了严重的内存泄漏,而相比之下,示例1则由于析构函数的存在,不会出现这个问题。

析构函数本身的实现并不难,真正困难的是决定何时应该写出析构函数。

析构函数通常用于释放在构造函数或在对象生命期内获取到的资源

四、深浅拷贝

String类(不是c++ string,但可以使用cstring中的库函数)的实战

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
//String.h
class string{
public:
String(const char *pstr="");
String(const string& str) ;
//string& operator=(const string& str) = delete;
~string();
int size() const;
void show() const { cout<<pbuf<<endl; }
//other member-methods
private:
char *pbuf;//用于内容操作时的缓冲区
int length;//不必须,可以实时求
};


//String.cpp
#include<String.h>
String::String(const char *pstr=""){
assert (pstr != NULL);
length = strlen(pstr);
pbuf = new char[length + 1];//给字符数组末尾的`\0`留位置
strcpy(pbuf,pstr);//为什么不能直接用pbuf=pstr?
//因为pstr有可能是一个临时对象,构造函数结束后不久就会被销毁。
//因此需要在内部创造一块内存,将其保护下来
}

String::String(const string &str){
length = str.length;
pbuf = str.pbuf;
}

String::~String(){
if(pbuf){
delete[] pbuf;//释放资源,指针置空
pbuf = NULL;
}
length = -1;
}
int String::size() const{
return length;
}

这里特别注意构造函数String里不能直接将pstr赋值给pbuf,因为pstr这块空间在构造函数完成之后会销毁

下面看一个使用String的小demo:

1
2
3
4
5
6
7
8
int main(){
String s("Hello");
{
String temp(s);
}
s.show();
return 0;
}

按照我们的预期,程序应该会打印出“Hello”,但实际运行过程中,程序会运行失败,打印乱码,这是为什么?

原因就在于拷贝构造函数。

在实现拷贝构造函数的时候是浅拷贝,直接把旧指针赋给新指针,两个指针指向同一内存。而代码段退出,调用析构函数会释放掉temp的内存,因此导致原来的对象s访问到乱码。

至于程序崩溃则是由于main函数结束时会自动调用s的析构函数去释放掉已经被释放的内存,所以会崩溃。

深浅拷贝的不同:

浅拷贝仅仅拷贝指针,即新指针和旧指针指向同一地址。

通过上面的例子,不难发现这种方式的弊端:只要有一个调用了析构函数,其他对象都不能再获取到对应内存空间的内容了,但对应的优点是资源利用率高,内存开销少;

深拷贝与之对应,拷贝的是指针对应内存空间(的内容),好处是各个对象的内存空间独立互不干扰
改造为深拷贝(pbuf=new char[length+1];strcpy(pbuf,str.pbuf);)后,析构函数的调用就不会影响到其他对象

类成员的补充

  • const数据成员,表示值不变

    比如const int size=128

  • const函数成员

    表示不会去修改类内的数据属性。比如String类的show函数,不涉及对pbuf的修改。格式一般为type func() const { funcbody }

  • static数据成员,也叫类属性的数据成员,或静态数据成员

    特点是所有对象共享,属于类本身而不属于任何一个对象。需要在源文件中,类外进行初始化,格式举例:int Clock::cnt = 0;
    访问方式:引入头文件,类名双冒号数据成员名

  • static函数成员(没有this指针),只能访问类属性数据成员(static数据成员)而无法操作对象属性的数据(非static数据成员)。static数据成员为所有对象所共享。

    该部分数据不会存储在对象内部,外界的方法也只能获取到对象属性的数据

    以一个简单的sizeof为例子进行验证:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    #include<iostream>
    class A{
    private:
    static int s;
    int n;
    };

    int A::s = 0;

    int main(){
    cout<<sizeof(A)<<endl;
    return 0;
    }

    该片段的运行结果是“4”,即A这个类只有4个字节。

  • 以及mutable数据成员

五、运算符重载

引言

C++定义了许多内置类型间的操作符。使用这些设施,程序员能够编写丰富的混合类型表达式。

C++允许我们重定义操作符用于类类型对象时的含义。

通过操作符重载,程序员能够针对类类型的操作数定义不同的操作符版本。

复数运算——*的重载

上面就是一个对复数进行乘法操作的例子,这里我们并没有对乘法运算符*进行特殊的定义,而它却能正常运算,得到正常的结果,其背后就是运算符重载的作用。

运算符重载不是一件简单的事,因为:

  • 首先,要了解运算符的性质。

    例如:+(双目,返回临时对象)、+=(双目,返回左操作数的引用)、=、++前与 ++后

  • 其次,运算符重载的语法也较复杂。

    例如:运算符可重载为全局函数(友元)、成员函数,非常灵活。

操作运算符重载,其实是具有特殊名称的函数:保留字operator后接需要定义的操作符,常见的可重载和不可重载的操作符如下所示,红色方框中的操作符不建议重载。

重载操作符一览

语法规则

1
2
返回类型 operator@(形参表);
其中@为需要重载的运算符,形参表代表运算符需要的操作数,不要忘记操作符默认this指针

注意:

  1. 重载操作符的形参数目(包括this),与该操作符的操作数数目相同(函数调用操作符除外)

  2. 重载操作符必须至少具有一个类类型操作数(内置类型的操作符,其含义不能改变)

    如:cannot redefine built-in operator fot ints

实例:Clock运算符<<的重载

首先为Clock增加second数据成员,提高实用性

其次为Clock重载<<运算符,使我们能够使用cout<<直接输出当前时间,分为如下几步:

  • 确定参数个数、返回值
  • 确定运算符是成员还是非成员
  • 书写函数体
1
2
3
4
5
6
7
8
9
//Clock.h
class Clock{
friend ostream& operator<<(ostream& out, const Clock& c);
……
};
//Clock.cpp
ostream& operator<<(ostream & out, const Clock& c){
out << c.m_Hour << ":" << c.m_Minute << ":" << c.m_Second << endl;
}

在实现的过程中,需要注意成员函数默认第一个参数是this指针,因此如果写在类内部,则我们的重载只能加一个参数ostream& out,则使用的方式只能是:c << cout(当前对象,输出流)

而我们使用cout的习惯一般是cout << c,即输出流在前,当前对象在后,那么:

我们只能把运算符的重载写为全局函数,避免其第一个参数为当前对象,这样,才能满足”输出流<<当前对象”的顺序

另外,需要注意类外函数不能直接访问private数据成员,因此需要使用friend关键字将其声明为友元函数

测试效果:

1
2
3
4
5
……
int main(){
Clock c(12,0,35);
cout << c;//12:0:35
}

Operator=

  • 内置类型的赋值运算返回对左操作数的引用,因此,赋值操作符也返回对同一类型的引用
  • 赋值操作符必须定义为成员函数

回顾重要概念:深拷贝与浅拷贝

Big3:

  • 构造
  • 析构
  • Operator=
1
2
3
4
5
6
Clock& Clock::operator=(const Clock& clock){
hor = clock.hour;
min = clock.min;
second = clock.second;
return *this;
}

实践:String& String::operator=(const String& other);

1
2
3
4
5
6
7
8
9
10
String& operator=(const String& other){
if(this == &other){//应对String a; a=a;这种操作
return *this;
}
length = other.length;
delete[] pbuf;//注意String的构造/拷贝函数中已经有new操作,因此此处需要将其释放掉,才能再new
pbuf= new char[length+1];
strcpy(pbuf,other.pbuf);//注意深拷贝
return *this;
}

其实,与上节课的拷贝构造(深拷贝实现)是一样的思路,不过在实现的过程中有另外两个需要注意的点,见。注释

Operator++()与Operator++(int)

需要再次提醒的是,“前++”返回的是操作数的引用(&),“后++”返回的是操作数的副本,是一个临时对象

下面就来给Clock重载++运算符:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Clock& Clock::operator++(){
Second++;
if (60 == Second){
Second = 0;
Minute++;
if (60 == Minute){
Minute = 0;
Hour++;
if (24 == Hour){
Hour = 0;
}
}
}
return *this;
}

上面的代码实现的是前++,返回的是this指针,即操作数的副本

后++和前++都只是一个单目运算符,函数名都叫operator++,那我们如何区别这两种重载?

C++规定,“后++”这种情况,在运算符重载函数中传入一个不用的参数,用于区分。

1
2
3
4
5
Clock& Clock::operator++(int){
Clock c=*this;
++(*this);
return c;
}

上面的代码,就是重载后++的示例,可以看到最后返回的是一个临时对象,其实质是调用了前++。这也是为什么for循环更喜欢用前++写的原因:前++效率更高。

小结:

  • 先写前++,前++返回自身的引用
  • 后++直接调用前++,后++返回临时对象
  • 前++效率高于后++

让时钟“跑起来”

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include<cstdlib>
#include<iostream>
#include<thread>
#include<chrono>

#include<Clock.h>
using namespace std;

int main(){
Clock c(22,51,50);
while(true){
c++;
cout<<c;//打印时间,持续一秒
this_thread::sleep_for(1s);
system("cls");//清屏
}
return 0;
}

operator+与operator+=的最佳实践

定义了operator+,也要定义operator+=。

一般而言,operator+通常定义为非成员函数,返回临时对象;而operator+=则定义为成员函数,返回的是左操作数的引用。

通常,先重载operator+=,继而对其进行调用,来实现operator+。

其他算术操作符(-,*,/,%)同+

1
2
3
4
5
6
7
T& operator+=(const T& t);//定义为成员函数

T& operator+=(const T& t){
T temp(item1);
temp op=item2;
return temp;
}

operator+:

1
2
3
4
5
const T operator+(const T& lht, const T& rht){
T ret(lht);
ret+=rht;
return ret;
}

operator+返回const对象可以防止如下问题的产生:

a + b = c;

Complex类及运算符重载实战

实现一个复数Complex类,并重载运算符+,-,*,/,+=,-=,*=,/=,<<,>>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
//Complex.h:
#pragma once
#include <iostream>
using namespace std;

class complex
{
friend ostream& operator<<(ostream& out,const complex& c);
public:
Cmplex(float a = 0.0f,float b = 0.0f):a(a),b(b);
Complex& operator+=(const complex& other);
this->a += other.a;
this->b += other.b;
return *this;
private:
float a;
float b;
};
const Complex operator+(const Complex &c1,const Complex &c2);//全局函数必须放在类后面,才能获取到类的信息

//Complex.cpp:
#include "Complex.h"
ostream& operator<<(ostream& out,const Complex& c)
{
out <<c.a <<"+"<<c.b <<"i";
return out;
}
const Complex operator+(const complex& c1,const Complex& c2)
{
Complex tmp (c1);
tmp += c2;
return tmp;
}

使用这段代码进行测试:

1
2
3
4
5
6
7
Complex c1(1,2);
Complex c2(3,4);

Complex ret,ret1;
ret = c1+c2;
ret1 = 1+c1;
cout << ret << endl << ret1 << endl;//4+6i,2+2i

为什么我们使用整型数直接和复数相加也能得到正确结果?仔细看默认构造函数,我们使用了默认参数,这意味着不给出虚数部分,也能够造出一个复数。

Operator[]

该运算符的作用,主要是实现类似数组方式的下标访问。

在重载[]时,一般要提供两个版本:一个是非const成员调用,返回引用。另一个是const成员调用,返回const引用。只有这样,const成员调用才能正常返回结果。

以我们自己实现的String为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
char& operator[](int index){
if (index < 0 || index >= length){
throw exception("Index out of range");
}
return pbuf[index];
}

const char& operator[](int index) const
{
if (index < 0 || index >= length){
throw exception("Index out of range");
}
return pbuf[index];
}

测试代码:

1
2
3
4
5
6
String s("hello");
const String s1("Hello");
s[0]='H';
s.show();
//s1[0]='h';
cout<<s1[0];

六、继承与多态

七、模板与智能指针

八、STL迭代器

九、STL容器与算法

十、STL组件之算法&C++11新概念


入职培训总结之C++基础
https://dockingyuan.top/2023/03/23/入职培训总结之C++基础/
作者
Yuan Yuan
发布于
2023年3月23日
许可协议