本课程要有一点点C或C++的基础,学习效果会更好哦。
侯捷老师讲的特别通透,听完收获很大。
Lesson1 简介
课程基础:
- 曾经学过某种面向过程的编程语言(procedural language)
-
知道程序的编译链接过程
-
知道如何编译链接,如何建立一个可执行程序
课程目标:
-
培养正规大气的编程习惯
-
以良好的方式编写C++ Class(对于单一的类,我们叫做基于对象的编程)
- 有指针的Class
- 没有指针的Class
-
学习Class之间的关系(对于多个Class之间的关系,称为面向对象的编程)
- 继承(inheritance)
- 复合(composition)
- 委托(deligation)
学会了一种语言,编程思想掌握了,除了具体语法有一些差异,其他语言也就学会了。
C++包括:
![image-20211110064133887](https://i-blog.csdnimg.cn/blog_migrate/d93434b721d10bb8d442fa7037b23e64.png)
标准库很重要,一个C++程序员一定会用标准库。
C++推荐书籍:
![image-20211215005323692](https://i-blog.csdnimg.cn/blog_migrate/940209c2f8ddafc198352d8274ab6c16.png)
还有Effective C++ ,STL源码剖析。
本课程有两个例子,一个是写一个复数complex,另一个是写字符串string。后面都是用到这两个例子,就不再多说明了。
Lesson2 头文件与类声明文件
![image-20211110064729929](https://i-blog.csdnimg.cn/blog_migrate/1f549ef682858288a3ddf29f9c0ddeb9.png)
一个程序中,包括数据和函数,函数是用来处理数据的,但是在C中,数据是全局的,各个函数都可以去处理它们,C++的思想就是把数据和处理这种数据的函数包在一起,这就是类。类是C语言中的Structure的升级,带有一些新的特性。
带指针的class和不带指针的class
不带指针的class,如复数,class创建了多个对象,这些对象中的数据部分占用的大小是一样的,然后用class中的函数可以处理这些数据,注意这里的函数只有一份,不是每个对象里都有一个函数。
而带指针的class,如字符串,每个字符串的长度不同,所以大小也是不同的,class中的数据类型是固定的,大小不可以变化,所以class中定义了指针,用指针指向字符串,然后每个指针占用的大小是相同的。
代码的基本形式
包括:
- 头文件
- 主程序
其实是一样的,只不过有角色的区分,所以分为头文件和主程序。
引用标准库的头文件用<>
,自己写的用""
而且,头文件的延伸文件名不一定是.h
.cpp
,也可能是.hpp
或其他的扩展名 。
Eg. complex.h
#ifndef __COMPLEX__
#define __COMPLEX__
#endif
上述的代码是一个防卫式声明,防止头文件被反复的包含。
头文件布局
![image-20211215005426587](https://i-blog.csdnimg.cn/blog_migrate/f52afccbdf4115df8723934db50e4e0e.png)
头文件最主要的是上图中的1和2,1是类的声明,就是声明一下类中有哪些变量,哪些函数。2是类中函数的具体定义。但有时在做1和2时,一些东西应该先声明过,所以这就有了0前置声明。
class语法部分
class complex //class head
{ //class body
public:
complex (double r=0,double i=0):re(r),im(i)
{}
private:
double re;
double im;
};
如上,类的声明部分,要有class head,也就是类的名字,然后下面的大括号里面是class body。
模板
下面我们慢慢发展我们的代码。
看上面类中的数据部分,re和im是double类型的,但是如果我们想要写成别的类型时,也不能反复的写好多个类呀,所以这时候就可以用到模板,这样我们就可以在定义对象的时候再制定里面数据的类型了。
template <typename T> //使用模板,要在类前加上这句话,这里的T是一个符号,任何符号都行。这就是告诉编译器T是一个模板。
class complex
{
private:
T re,im;
};
在用的时候,我们就可以把T替换成我们制定的类型。
complex<double> c1(2.5,1.5);
complex<int> c2(1,2);
Lesson3 构造函数
我们现在不再介绍模板,回到我们的代码继续发展。
内联(inline)函数
在声明类的时候,我们也可以直接在类中定义函数,这种定义就叫做内联函数。
内联函数的好处就是调用的时候很快,但是是否是内联函数还要由编译器决定。编译器会根据函数的复杂程度自行决定。
class complex
{
public:
complex (double r=0,double i=0):re(r),im(i)
{}
double real() const{return re;}//这个函数很简单,很有可能被编译器编程内联函数。
double imag() const{return im;}
private:
double re;
double im;
};
上面的代码是直接在类的声明的时候定义了函数。我们也可以在后面具体定义函数的时候定义内联函数,此时就要加上关键字inline
inline double complex::imag() const{return im;}
访问级别
在class body中,定义了访问级别。主要有public,private和protect。
主要介绍public和private。
public就是公开的,外部可以看的到。
private就是只有该class里可以看的到,一般数据部分会放到private里。
函数也分为类自己的函数还有给外部用的函数。
在写代码时,各个段落是可以互相交错的。先public再private然后再public。
构造函数
创建一个对象,构造函数会被自动调用。
complex c1(2,1);//赋初始值
complex c2;//不赋值,默认创建
complex* p = new complex(3,1);//动态创建,创建了一个该类的指针
构造函数名一定要和类的名字相同。
函数的参数是可以有默认值的,在创建对象的时候没有指明参数,就使用默认值。
构造函数是没有返回类型的。
class complex
{
public:
complex (double r=0,double i=0):re(r),im(i)
{}
};
上面的写法是一种大气规范的写法。只有构造函数有这种写法。
re(r)
和 im(i)
就是把r和i的值赋值给class的数据。当然也可以写到大括号里,但是这样的写法不好。
不带指针的类一般不用写西沟函数。
重载
构造函数可以有多个重载。因为要被初始化,可能有很多种情况的设定,所以需要不同方法的构造函数。
当然一般的函数也可以重载,事实上,在我们写代码的时候,函数名是一样的,但是当进行编译的时候,由于传入的参数类型,个数还有返回值的类型不同,编译器编译后的代码显示的函数名是不一样的。只是我们人类在看的时候是一样的。
下面这种重载是不允许的:
class complex
{
public:
complex(double r=0 , double i=0):re(r),im(i){}
complex()re(0),im(0){}//这两种写法是不行的,编译后的代码是一样的,这时编译器就不知道调用哪个函数了。
private:
double re;
double im;
};
Lesson4 参数传递与返回值
构造函数一般不可以被放在private,如果放在private里,就说明外界不可以创建对象。
有一种设计模式叫做singleton就是把构造函数写到private里。
常数成员函数
class中的函数有两种,会改变数据内容的和不会改变数据内容的。
不改变数据内容的函数一定要加 const
,这是大气的编程习惯。
class complex
{
public:
complex(double r=0 , double i=0):re(r),im(i){}
double real () const {return re;}
double imag () const {return im;}
private:
double re;
double im;
};
函数名后加 const
说明该函数一定不会改变class的数据。如果不加,就说明可以改动。
那么不加会有什么后果呢?
看下面的例子:
complex c1(2,1);
cout<<c1.real();
cout<<c1.imag();
const complex c2(1,1);//这里说明该对象是一个常量,内部的数据一定不会改
cout<<c2.real();//那么在调用该函数的时候,如果函数没有加const,就说明内部的数据可以该,这就与定义时相矛盾
cout<<c2.imag();
函数的参数传递
1.pass by value
pass by value就是把要传入的变量的值整包传过去,这个变量有多少个字节,就传多少个。这样的效率很低。
2.pass by reference
在C中,可以用指针来传递,就是把这个变量的地址传进去。在C++中,可以用reference(引用)来进行传递。
其实引用的底层就是传指针。引用的形式很漂亮。
class complex
{
public:
complex(double &r=0,double &i=0):re(r),im(i){}//这就是传引用
private:
double re;
double im;
};
如上面的例子,传引用就相当于 double & r = varies
,这时,就相当于为varies变量起了一个别名r,当改变r的时候,varies也会改变。r的地址和varies的地址一样。
当不想让varies改变时,就可以在r前面加上 const
关键字。此时改变r的值时,编译就会出错。
参数传递尽量都传引用。
返回值的传递
1.return by value
double function()
2.return by reference
double& function()
什么时候不可以return by reference呢?
当返回的东西是函数内创建的,这是一个local变量,当函数运行完之后,就直接消失了,所以此时renturn by reference,指向到一个消失的东西。这就不可以了。
友元
class中的数据在private下,外界不可以获取。但是想让一些函数可以直接获取,就可以加上 friend
关键字,表明这个函数是该class的朋友,可以直接获取class中的数据。
定义友元函数,要在class中声明一下子。
class complex
{
public:
private:
friend complex& __doap1(complex*,const comp1ex)//在class中声明一下子,该函数是该class的友元,写在哪个标签下都无所谓
};
inline complex& __doap1(complex* ths,const comp1ex& r)//定义函数
{
}
要注意:相同class的各个object互为友元,这就是说对象1中的函数可以直接处理对象2中的数据。
Lesson5 操作符重载与临时对象
事实上,在C++中,操作符就是一种函数,是可以重新定义的。也就是操作符重载。
操作符重载
操作符重载的函数可以写成成员函数的形式,也可以写成非成员函数的形式
1.成员函数的形式
这就是把操作符重载函数写在class内,对该class的object进行操作。
首先我们来看一下编译器是如何看待操作符的。
![image-20211110090019793](https://i-blog.csdnimg.cn/blog_migrate/58943495d30715b89284917406ef9450.png)
+=
这是一个二元操作符,就是有两个操作数的。
编译器看待这个符号,就是把这个符号作用在左边身上,如果左边对这个符号进行了定义,那么编译器就找到了这个函数。
所有的操作符重载都有一个隐藏的参数,就是this
,谁调用这个函数,那个谁就是this,this是那个谁的指针。这里就是c2的指针。
在写重载的时候,这个this就不写在形参列表里(也不能写,写出来就错了),但是是有的,如下面的代码:
inline complex& complex::operator +=(const complex& r)
{
return __doap1(this,r);//这个函数的功能是修改this指向变量的值
}
所有的二元操作符都是这种规则。
在这里补充一下by reference传递的另一个好处,就是传递者不需要知道接收者的形式,如果要用指针进行传递,一定要搞清楚,接收者接收的是指针还是value,因为接收者如果是一个指针,那么它只能接收指针,所以传递的时候要取地址。
注意上面代码,操作符重载函数有一个返回值类型,complex&
。虽然该函数可以直接通过this为操作符前面的变量赋值,有没有这个都无关紧要。但是下面这种形式,就需要有返回值了。
c3+=c2+=c1
2.非成员函数的写法
之前的+=操作符重载成员函数写法,函数的形参里面自动包含了一个隐藏的this指针,可以直接操作this,从而改变操作符左边的变量。
但是有的时候,不是改变操作符左边的变量。
如:
c3=c1+c2;
c2=c1+5;
此时,就需要使用非成员函数的写法。
![image-20211215005524527](https://i-blog.csdnimg.cn/blog_migrate/c7a52188573af769c8015b925698c153.png)
这里是没有this的存在的。
这种情况必是return by reference ,因为返回的必定是local object。
临时对象 typename()
在上面的return中,我们可以看见,complex (,)
,这就是一个临时对象。
这种形如 typename ()
,是临时对象。它的声明到下一行就结束了。
例如熟悉的int类型,临时对象就是:
int(5);//这并不是一个函数,就是一个值为5的int型的临时变量,当运行到下一句话时,这个变量就消失了。
Lesson6 复习complex
本节课是复习课,回顾了complex类的创建过程。
Lesson7 三大函数
介绍完complex类之后,我们现在继续学习带指针的类的写法。以string字符串这个类为例子。
在创建一个新的字符串时,我们会遇到以下的几个情况:
int main()
{
String s1();//创建一个没有初值的对象
String s2("hello");//创建有初值的对象
String s3(s1);//创建一个新的对象,以另一个对象为初值,是一个拷贝的动作
s3=s2;//赋值,也是一个拷贝的动作,这里与上一句的区别就是这里的s3不是第一次出现
}
在之前的complex的例子,我们没有写,但是编译器会自己制定一个规则,就是一项一项的对应,去拷贝和赋值。但是复数是很规则的,有实部和虚部,默认的拷贝和赋值完全可以满足。
但是字符串创建的是指针,两个指针不能指向同一个地方,这样会弄乱,所以带指针的类一定要自己写拷贝和赋值。
下面我们来看String类
class String
{
public:
String(const char* cstr=0);
String(const String& str);
String& operator=(const String & str);
~String();
char* get_c_str() const{return m_data;}
private:
char* m_data;
};
字符串就是指针指着头,最后有一个结束符号。
我们先来看第一个构造函数,是没有初始值得构造函数。函数的代码如下:
inline String::String(const char* cstr=0)
{
if(cstr)
{
m_data = new char[strlen(cstr)+1];//如果指针不是空,那么运行这段话,分配一个空间,大小为字符串的长度再加上1,因为后面还有一个结束符。
strcpy(m_data,cstr);//然后调用这个函数,把指针地址拷贝到数据中。
}
else
{
m_data=new char[1];//如果是空指针,那么就在该空间放一个结束符。
*m_data='\0';
}
}
//该构造函数应对下面这两句话,会被调用
{
String s1();
String s2("Hello");
String*p = new String("Hello");//这里也会调用上面的构造函数。
}
析构函数
当这个对象被清理的时候,会自动调用析构函数,需要把这块的内存给释放,否则就内存泄漏了。
析构函数:
inline String::~String()
{
delete[] m_data;
}
拷贝构造
三大函数有拷贝构造,拷贝赋值,析构函数。析构函数我们上面说了。下面说前两个。
只要是带有指针的类,就要有这三大函数。
如果没有拷贝构造函数,用编译器自动生成的拷贝构造,就会发生浅拷贝。
浅拷贝就是,自动生成的拷贝构造函数,只会把指针本身拷贝过去,这样就会造成两个指针指向同一个东西。
inline String::String(const String& str)
{
m_data=new char[strlen(str.m_data)+1];
strcpy(m_data,str.m_data);
}
//拷贝构造适用的条件
{
String s1(s2);
String s3=s1;//这个虽然是赋值符,但是s3是新建立的,此时也是调用拷贝构造函数。
}
拷贝赋值
拷贝赋值的过程:
- 先将原来的东西清空
- 然后再拷贝字符串
inline String& String::operator=(const String& str)
{
if (this==&str)return *this;//自我检查,看是不是自己赋值自己。
delete[] m_data;//删除原来的空间内容
m_data=new[strlen(str.m_data)+1];//重新指向一个空间
sctrcpy(m_data,str.m_data);//拷贝内容
return *this;//返回自己,这样可以连=
}
**注意:**上面的自我检查很重要,如果是自己赋值自己,没有自我检查,会先把原空间的内容删掉,结果赋值后字符串的内容变成空了。
Lesson8 内存管理
栈(Stack)
是存在某作用域的一块内存空间,例如调用的函数,函数本身会形成一个栈来放置它所接收的参数,以及返回地址。
在函数本体(function body)内声明的变量所使用的内存块都取自上述的栈。
该空间中的变量会被自动清理掉。
堆(Heap)
是操作系统提供的一块全局(global)的内存空间,程序可动态分配(dynamic allocated)该空间。
所以我们需要手动开辟出一块空间,并且手动清理该空间。
生命期
- stack object:其生命在作用域结束之后结束。
- static object:其生命在作用域结束之后仍然存在,直到整个程序结束。
- global object:其生命也是在程序结束之后才结束,也可以把它当成是一个static object。
内存管理
1.new
![image-20211110140453980](https://i-blog.csdnimg.cn/blog_migrate/f7084f99da6281b201147f43afe1880d.png)
如果不delete,那么随着程序的运行,指针p会消亡,那剩下所指的空间就没人管了。所以称为内存泄漏。
分配内存的分解动作:
![image-20211215005626031](https://i-blog.csdnimg.cn/blog_migrate/b65006c59d364b84dc6724326ae82df3.png)
- 先调用一个叫operator new的函数,分配内存,得到一个指针。
- 然后将这个指针转型。
- 然后通过指针调用该类的构造函数。
2.delete
再来分解delete的动作:
![image-20211110141305647](https://i-blog.csdnimg.cn/blog_migrate/dcbc277a7ea81ac44e4029d56b071265.png)
- 首先调用析构函数。
- 释放内存。
良好的编程习惯:
new[]
要搭配 delete[]
使用。
Lesson9 复习String
本节课主要是复习之前的String类。
Lesson10 类模板 函数模板 及其他补充
static
每创建一个对象,对象中的非静态数据都会被创建一份。
而函数只有一份,不同的对象调用成员函数,其实就是给函数传入不同的地址,让成员函数来处理该地址对应对象的数据。这靠的就是this pointer。
可能有点绕,好好想一下。
函数的参数列表中一定不要写this,编译器会自动加,否则就会报错。黄色的地方呢就是可加可不加。
当成员变量加上static
关键字,就和对象脱离了,在内存中只有一份。
而静态成员函数和一般的成员函数几乎一样,都只有一份。但是唯一的区别是,静态成员函数没有this pointer。所以它不能直接去处理对象中的数据,而是去处理静态成员变量。
静态变量的使用:
静态变量在类中声明,但是在使用的时候,需要在类外进行定义。
这里辨析一下声明和定义。无论是变量还是函数。声明是告诉编译器有该东西存在,但是不分配内存。
而分配内存,就称为定义。
class Account
{
public:
static double m_rate;
static void set_rate(const double& x){m_rate=x;}
};
double Account::m_rate=1;//这里是定义,可以赋初值也可以不赋值。
int main()
{
Account::set_rate(5);//调用静态函数可以直接调用,不通过对象。
Account a;
a.set_rate(7);//也可以通过对象来调用。
}
小贴士:凡是类里面的一切东西,无论是变量还是函数,拿到外面的名字都要加上类名。就是在家里可以直接叫名字,但是到外面,就必须要加上姓氏。
就像上面的
static double m_date
,在外面的名字就是Account::m_date
。
静态函数的使用:
- 通过对象调用。
- 通过类名调用,也就是直接用函数的全名调用。
Singleton设计模式
只能创建一个对象。
class A{
public:
static A& getinstance(return a;);
setup(){}
private:
A();
A(const A& rhs);//构造函数都写在私有里,外界不能创建。
static A a;//这里创建了一个静态变量,没有任何人创建A的对象时,就已经存在一个了。
};
如果要使用这个对象,就通过静态函数来获取。
A::getinstance.setup();//通过静态函数便可以操作这个对象的所有成员函数。
这样写呢也有一个弊端,就是在没有使用这个对象的时候,它已经出现了。
所以有下面这种改进:
class A{
public:
static A& getinstance();
private:
A();
A(const A& rhs);
};
A& A::getinstance()
{
static A a;
return a;
}
改进后,在静态成员函数内声明了这个对象,这样的好处就是当使用这个静态成员函数一次,该对象就会被创建,并且会一直存在到程序结束。
函数模板(function template)
template<class T>
inline const T& min(const T& a,const T& b)
{
return b<a?b:a;
}
//在使用时,与类模板不同,不用再指出具体的类型
r3=min(r1,r2);
编译器会做实参推导,自动推导出类型。
例如上面的比大小,编译器会自动推导,a和b的类型,然后在下面有<,编译器会根据该类型寻找是否有重载。那么定义该类的人就需要写好重载<的函数。这样职责分开,是特别好的。在C++中标准库中,有很多这样的模板函数,称为算法。
namespace
namespace std
{
...
}
上面的例子是把所有的东西都包在std里面,这样可以避免同名的函数混淆。
在使用的时候,就要打开这种包裹。
using namespace std;//全开 using directive
using std::cout;//就开这一个 using declaration
int main()
{
std::cin<<...;
cout<<...;
}
Lesson11 组合与继承
前面我们学的是写一个单一的class,这种叫基于对象设计。
下面我们要学习,类和类之间的关系。也就是面向对象编程。
类与类的关系大致上可以分为三种:组合,委托,继承。
Composition组合(表示has-a)
一个class里面包含了另一个class的对象。
对于复杂的程序,通常会使用图来表示。组合关系的表示方法如下:
![image-20211110162419332](https://i-blog.csdnimg.cn/blog_migrate/1e239c48127f87fbd95f936a3350e6a9.png)
下面讨论这种组合关系时的构造函数和析构函数。
构造函数
![img](https://i-blog.csdnimg.cn/blog_migrate/a4421ecceebddddf48d3af5afcca0e16.png)
构造函数规则,由内而外,先调用内部包含class的默认构造函数。如果想调用别的构造函数,需要自己写明。
放一个慢动作:
Container::Container():Component(){}
内部的构造函数可以不用自己写,编译器会帮我们自动完成。
析构函数
由外而内,先调用Container的析构函数。再放一个慢动作:
Container::~Container(){~Component}
这也是编译器自动帮我们完成的。
Delegation委托(composition by reference)
类中包含另一个类的指针。
![image-20211110164453307](https://i-blog.csdnimg.cn/blog_migrate/5a05b2d0dec57fb39d5a6e594c7a2dbf.png)
在这里有一个很有名的设计模式,就是一个类中包含了另一个类的指针,而这个指针所指的类中包含了要实现的所有功能,这种模式称为 pimpl 。
Inheritance继承(表示is-a)
继承的关系,子类可以包含父类的数据。
语法:
class A:public B//public公有继承,还可以是private protect
{};
用图表示:
![image-20211110165524489](https://i-blog.csdnimg.cn/blog_migrate/0bf66f259bbf18abac36b63afbb9ad6c.png)
下面是子类,上面的是父类,由儿子指向父亲。
就如同生物学的那样,界门纲目科属种。一直往下分,每一个门都是一个界的一种。所以是is-a。
继承最有价值的地方是和虚函数搭配使用。
![image-20211215010208419](https://i-blog.csdnimg.cn/blog_migrate/3c52e9a690237290c3c43a1d0b2fdae3.png)
父类的析构函数要是virtual
在这里我们只探讨public继承。
Lesson12 虚函数与多态
使用继承的时候,要搭配虚函数使用,效果最佳。
继承的时候,数据会被继承下来,在创建子类对象的时候,会分配内存空间。
成员函数也会被继承下来,但是函数是不会被分配内存空间的。函数的继承是继承函数的调用权。
什么事虚函数呢?就是在成员函数的声明钱加上virtual
。
成员函数可以分为三类:
- non-virtual函数:不希望子类重新定义(override)
- virtual函数:希望子类重新定义
virtual void function()
- pure virtual函数:子类必须重新定义
virtual void function()=0
![image-20211110171243449](https://i-blog.csdnimg.cn/blog_migrate/b52e819553b571f91f931d7ea1c837df.png)
例如,定义一个形状的类。世界上并没有叫形状的形状,所以该类一定会有子类。
上面就会定义一个画出形状的函数,这个函数一定要被子类override。
我们还会给每个形状都附一个编号,这个和具体的形状无关,所以用non-virtual函数。
我们还要打出操作各种形状的一些信息,如果子类不特别说明,就用父类定义的内容。如果子类想单独说明,也可以重写,所以这里用virtual函数。
Lesson13 委托相关设计
本节课介绍了一些设计模式。
就用到composition, delegation,inheritance这三种模式,进行组合,来解决现实的问题。
其中delegation+inheritance的功能最强大。