Cpp Tutorials-01-基础

类型限定符

  • const:表示该变量的值不能被修改
  • volatile:修饰符 volatile 告诉该变量的值可能会被程序以外的因素改变,如硬件或其他线程
  • restrict:由 restrict 修饰的指针是唯一一种访问它所指向的对象的方式。 C99 增加了新的类型限定符 restrict
  • mutable:表示类中的成员变量可以在 const 成员函数中被修改
  • register:提示编译器尽可能把变量存入到CPU内部寄存器中
  • static:
    • 用于声明静态变量或类的静态函数。静态变量作用范围在一个文件内,程序开始时分配空间,结束时释放空间,默认初始化为 0,使用时可改变其值;
    • C++ 类的成员变量被声明为 static(称为静态成员变量),意味着它被该类的所有实例所共享,也就是说当某个类的实例修改了该静态成员变量,其修改值为该类的其它所有实例所见;而类的静态成员函数也只能访问静态成员(变量或函数);
  • extern:
    • 当出现 extern “C”时,表示 extern “C”之后的代码按照 C 语言的规则去编译;
    • 当 extern 修饰变量或函数时,表示其具有外部链接属性,即其既可以在本模块中使用也可以在其他模块中使用;

访问控制

  • private(默认访问权限):只能由1.该类中的函数、2.其友元函数访问。不能被任何其他访问
  • protected:可以被1.该类中的函数、2.子类的函数、以及3.其友元函数访问
  • public:可以被1.该类中的函数、2.子类的函数、3.其友元函数访问

注:友元函数包括3种:设为友元的普通的非成员函数;设为友元的其他类的成员函数;设为友元类中的所有成员函数。

static 成员

区别与C,在C++中 static 的类成员表示:属于一个类而不是属于此类的任何特定对象的变量和函数. 对于类的static成员, 该类的所有实例都共用一个static成员.比如在对某一个类的对象进行计数时, 计数生成多少个类的实例, 就可以用到静态数据成员. 注意, static成员函数必须只能调用static成员.

类实例内存占用

@todo

构造函数

析构函数

  • 析构函数是一个成员函数,在对象超出范围或通过调用 delete 显式销毁对象时,会自动调用析构函数。
  • 只有当类存储了需要释放的系统资源的句柄,或拥有其指向的内存的指针时,你才需要定义自定义析构函数。
  • 当下列事件之一发生时,将调用析构函数:
    • 具有块范围的本地(自动)对象超出范围。
    • 使用 delete 显式解除分配了使用 new 运算符分配的对象。
    • 临时对象的生存期结束。
    • 程序结束,并且存在全局或静态对象。
    • 使用析构函数的完全限定名显式调用了析构函数。
  • 析构函数可以随意调用类成员函数和访问类成员数据。
  • 析构的顺序:

    • 按照非静态成员对象的析构函数在类声明中的显示顺序的相反顺序调用这些函数。 这些成员的构造中使用的可选成员初始化列表不会影响构造或销毁顺序。
    • 非虚拟基类的析构函数以声明的相反顺序被调用
    • 虚拟基类的析构函数以声明的相反顺序被调用
  • 很少需要显式(手动的)调用析构函数。但是,对置于绝对地址的对象进行清理会很有用(这些对象通常用 placement new 创建),可能会用到手动调用析构,但注意显式析构并不能释放”类自身”占用的内存,:

char* p_space = (char*)malloc( sizeof(Thing) ); // 创建内存块
Thing* p_thing = new (p_space) Thing(); // 在此内存块上new实例
p_thing->~Thing(); // 调用析构, 但Thing实例的内存不释放
delete p_space; // 销毁了存储区, 不会调用析构函数

//delete p_thing; // 这样会连同存储区p_space一起销毁, 并调用析构~Thing()

@ref: https://learn.microsoft.com/zh-cn/cpp/cpp/destructors-cpp?view=msvc-170

重载

C++ 允许在同一作用域中的某个函数和运算符指定多个定义,分别称为函数重载和运算符重载。

重载函数

在同一个作用域内(namespace or class)内可以声明几个同名的函数, 函数的参数列表(参数个数/类型/顺序)必须不同, 不能近通过返回值区分重载函数;

重载运算符

头文件:

class MyString
{
public:
MyString(const char *str=NULL);//构造函数
MyString(const Mystring& obj); //拷贝构造函数
MyString& operator=(const Mystring &obj);
MyString& operator+(const Mystring &obj);
MyString& operator+=(const Mystring &obj);
bool operator !=(const MyString &obj);
bool operator >(const MyString &obj);
bool operator <(const MyString &obj);

friend std::ostream & operator<<(std::ostream &out, const MyString &obj);
friend std::istream & operator>>(std::istream &in, MyString &obj);
};

源文件:

#include "MyString.h"
Mystring Mystring::operator=(const Mystring &obj)
{
//分配内存空间,记得+1,因为c风格的字符串以'\n'结尾,需要多加一个字符
this->pstr=new char[strlen(obj.pstr)+1];
strcpy(this->pstr,obj.pstr);
return *this;
}

C++如何实现重载

C++ 实现函数重载很大程度上依赖与编译器对函数名的 Mangling(损坏,破坏),即 C++ 的源代码被编译后同名的重载函数名字会被破坏,一般是在原函数名前后加上特定的字符串(g++编译器中通过在函数名后面添加参数的后缀),以区分不同重载函数,然后在调用的时候根据参数的不同选择合适的函数,如下代码说明了编译器是如何处理普通函数重载的

对于函数定义 void foo(int x, int y);
该函数被 C 编译器编译后在库中的名字为_foo,而 C++编译器则会产生像_foo_int_int之类的名字用来支持函数重载和类型安全连接。

这样一来就造成问题:C和C++中对函数的生成规则是不同的,C++程序不能直接调用 C 函数。
例如在C++中,调用foo的代码编译为汇编是call _foo_int_int,如果这个foo是在C库中的,那么foo的符号是_foo,在链接过程中就造成找不到这个符号报错

extern “C”

在C++项目中,调用C库函数,需要在头文件声明:

#ifdef __cplusplus
extern "C" {
#endif

void foo(int x, int y);

#ifdef __cplusplus
}
#endif

这就告诉 C++编译译器,函数 foo 是个 C 库的函数,那么 C++编译器应该按照 C 编译器的编译和链接规则来进行链接,也就是说到库中找名字_foo 而不是找_foo_int_int(原因是 C++支持函数的重载

继承

类的继承后方法属性变化

  • private 属性不能够被继承。
  • 使用private继承,父类的protected和public属性在子类中变为private,并且不能被这个派生类的子类所访问。
  • 使用protected继承,父类的protected和public属性在子类中变为protected,并且只能被它的派生类成员函数或友元访问,基类的私有成员仍然是私有的。
  • 使用public继承,父类中的protected和public属性不发生改变,父类中的private不变。

多重继承

  • class Divide: public Base1, protected Base2 { ... };
    • Base类的private成员,在Divide类中是否占用空间 // yes
    • 继承了多个基类的派生类, 有多个虚函数表,
    • 如果派生类没有重写任何基类的virtual函数, 派生类也有虚函数表(vtable), 里面是指向基类的函数
    • 如果派生类没有重写任何基类的virtual函数, 且派生类新建了一个virtual函数, 派生类的虚函数表(vtable)里面依次基类虚函数指针, 派生类自己的虚函数指针

虚函数 & 纯虚函数

虚函数的定义格式: virtual void func(param);

  • 虚函数作用是在程序的运行阶段动态地选择合适的成员函数,在定义了虚函数后,可以在基类的派生类中对虚函数重新定义,在派生类中重新定义的函数应与虚函数具有相同的形参个数和形参类型。
  • 如果在派生类中没有对虚函数重新定义,则它继承其基类的虚函数。
  • 设派生类有虚函数func,派生类中也声明了func 但是没有带virtual关键字,此时 Derive::func()仍然是虚函数。

如果类声明中有virtual函数, 但没有定义函数体, 则构造过程中出错, why?
因为创建类的实例时, 会初始化vtable, 如果这个类有未定义的虚函数, 则初始化vtable失败.

析构函数virtual的必要性? 如果一个类是作为基类的,那么该类的析构应该写为virtual: 这样在 delete 基类指针 时, 会自动选择相应的析构函数.

纯虚函数的定义格式: virtual void func(param)=0; 含有纯虚函数的类被称为”抽象类”(abstract class), 无法实例化. 如果派生类没有实现纯虚函数, 也仍是抽象类.
如果一个类的全部函数都是纯虚函数, 那么这个类可以声明为abstract的.

一些C++的代码策略:

  • private的纯虚函数: 可以作为一种策略, 比如我们要实现一个抽象类, 但是不想让其他人的代码继承这个抽象类, 就可以使用这种策略.
  • private的构造函数 & 析构函数: …

友元

首先,友元关系不能被继承, 即Func()函数是Base类的友元, 但对于Base类的派生Derive, 不存在友元关系;

class A
{
friend void func();
friend class B; // B类 是 A类的友元
}

虚函数表(vtable)

类实例的前4个字节, 是”数组的首地址”, 数组的元素是”虚函数地址”, 这个数组就是虚函数表.

Derive1 d;

cout << "vtable数组首地址" << *(int*)(&d)<< endl;
cout << "第一个虚函数地址vtable[0]" << *((int*)(*(int*)(&d))+ 0) << endl;
cout << "第二个虚函数地址vtable[1]" << *((int*)(*(int*)(&d))+ 1) << endl;

typedef void (*pFunc)(void);
pFunc pf;
pf = (pFunc)*((int*)(*(int*)(&d))+ 0);
pf(); // 执行第一个vFunc
pf = (pFunc)*((int*)(*(int*)(&d))+ 1);
pf(); // 执行第一个vFunc

pFunc* vtable = (pFunc*)*(int*)(&d);
(vtable[0])();
(vtable[1])();

虚继承

@todo

类型转换

C++中几种类型转换方式:

  • 旧风格的类型转换:

    • C 风格(C-style)强制转型: (T) exdivssion // cast exdivssion to be of type T
    • 函数风格(Function-style)强制转型: T(exdivssion) // cast exdivssion to be of type T
  • static_cast: 用法static_cast < type-id > ( expression ), 该运算符把exdivssion转换为type-id类型,但没有运行时类型检查来保证转换的安全性。它主要有如下几种用法:

    • 上行转换(把子类的指针或引用转换成基类表示)是安全的;
    • 进行下行转换(把基类指针或引用转换成子类指针或引用),由于没有动态类型检查,所以是不安全的,需要开发者自己保证 static_cast 转换的结果是否安全,static_cast转换失败会导致运行时错误;
    • 用于基本数据类型之间的转换,如把int转换成char,把int转换成enum等。得到的 char 可能没有足够的位来保存整个 int 值,这种转换的安全性也要开发人员来保证;
    • static_cast不能转换掉expression的const、volitale、或者__unaligned属性
    • 通常使用 static_cast 转换数值数据类型,例如将枚举型转换为整型或将整型转换为浮点型,而且你能确定参与转换的数据类型。
      // static_cast 示例
      Base *a = new Base;
      Derived *b = static_cast<Derived *>(a);

      double d = 3.14159265;
      int i = static_cast<int>(d);
  • dynamic_cast: 主要用来在继承体系中的安全向下转型。它能安全地将指向基类的指针转型为指向子类的指针或引用

    • 为什么需要 dynamic_cast 强制转换? 当无法使用 virtual 函数的时候
    • 如果转型失败会返回null(转型对象为指针时)或抛出异常(转型对象为引用时)
    • dynamic_cast 会动用运行时信息(RTTI)来进行类型安全检查,因此dynamic_cast存在一定的效率损失。
    • 基类要有虚函数,否则会编译出错;static_cast则没有这个限制。

充满风险的隐式类型转换

什么是“隐式类型转换”:

// 1.赋值
int a = 0;
long b = a + 1;

// 2.比较(==,>,<)和switch
if (a == b) {
// 默认的operator==需要a的类型和b相同,因此也发生转换
}

std::shared_ptr<int> ptr = func();
if (ptr) { // 这里会从shared_ptr转换成bool
// 处理数据
}

// 3.函数传参

引用

与指针相似的是,引用将存储位于内存中其他位置的对象的地址。有两种引用:

  • 引用命名变量的 lvalue 引用:& 运算符表示 lvalue 引用
  • 引用临时对象的 rvalue 引用:&& 运算符表示 rvalue 引用,或通用引用(rvalue 或 lvalue),具体取决于上下文

引用很容易与指针混淆,它们之间有三个主要的不同:

  • 不存在空引用。引用必须连接到一块合法的内存。
  • 一旦引用被初始化为一个对象,就不能被指向到另一个对象。指针可以在任何时候指向到另一个对象。
  • 引用必须在创建时被初始化。指针可以在任何时间被初始化。
inline String& String::operator=(const String& other)
{
if (this!=&other)
{
delete[] m_data;
if(!other.m_data) m_data=0;
else
{
m_data = new char[strlen(other.m_data)+1];
strcpy(m_data,other.m_data);
}
}
return *this; //返回this的解
}

C++ 设计技巧

RAII

资源获取即初始化( Resource Acquisition Is Initialization ),或称 RAII。
它将必须在使用前请求的资源(被分配的堆内存、执行的线程、打开的接头、打开的文件、被锁的互斥、磁盘空间、数据库连接等——任何存在于受限供给中的事物)的生命周期绑定到一个对象的生存期。

RAII 可总结如下:

  • 将资源的操作封装入一个RAII类里:
    • 构造函数请求资源,并建立所有类不变量或在它无法完成时抛出异常,
    • 析构函数释放资源并决不抛出异常;
  • 始终经由RAII类的实例使用资源,在栈上创建RAII类型的函数内变量,当函数退出时依靠”Stack winding”来保证一定调用RAII类的析构函数完成资源释放

例子1

template <TYPENAME T>
class RAII {
T* p_;
public:
explicit RAII(T* p) : p_(p) {}
~RAII() {
delete p_;
}

T& operator*() const {
return *p_;
}

private:
RAII(const RAII& other);
RAII& operator=(const RAII& other);
};

///
class Example {
RAII<SOMERESOURCE> p_;
RAII<SOMERESOURCE> p2_;
public:
Example() :
p_(new SomeResource()),
p2_(new SomeResource()) {}

~Example() {
std::cout << "Deleting Example, freeing SomeResource!/n";
}
};

C++中的explicit关键字只能用于修饰只有一个参数的类构造函数,它的作用是表明该构造函数是显示的,而非隐式的, 跟它相对应的另一个关键字是implicit,意思是隐藏的,类构造函数默认情况下即声明为implicit(隐式)。

问题: new Example()生成的Example实例, 如果没有调用delete(), RAII类的析构函数会被调用到吗?
会,C++ 编译器会在生成代码的合适位置,插入对构造和析构函数的调用,编译器会自动调用析构函数,包括在函数执行发生异常的情况。在发生异常时对析构函数的调用,还有一个专门的术语,叫栈展开(stack unwinding)。

CPP内存管理 | Little Web

例子2

错误的加锁:

void bad()
{
mutex.lock(); // 请求互斥
f(); // 若 f() 抛异常,则互斥不被释放
mutex.unlock(); // 抵达此语句,互斥才被释放
}

正确的方法, 使用std::lock_guard:

std::mutex mutex; // 定义全局的mutex
void good()
{
std::lock_guard<std::mutex> lock(mutex);
f(); // f()抛出异常, 仍然会调用到~lock_guard() 释放锁
// 运行到这里自动执行~lock_guard()
}

注:lock_guard是互斥封装器, 构造/析构函数定义如下:

explicit lock_guard(Mutex& m_):
m(m_)
{
m.lock();
}

~lock_guard()
{
m.unlock();
}

@ref:

Pimpl

这个机制是”Private Implementation”的缩写: 也即 实现私有化,力图使得头文件对改变不透明。

“实现私有化”必要性

在C++中, 头文件(类的声明)和源文件(类的实现)是分开的,
举个例子, 头文件base.h里声明了一个基类Base, 如果改动Base的公有接口, 会导致所有包含base.h的类(调用Base类的代码, 以及Base的派生类)都有重新编译, 在一个大工程中,这样的修改可能导致重新编译时间的激增。你可以使用Doxygen或者SciTools看看头文件依赖。
改动公有接口导致的编译时间激增是可以理解的, 但是如果我们改动了Base的私有接口或者成员, 也会导致上面编译时间激增的情况, 这就有些不可接受了.

如何Pimpl

MyClass.h 文件内容如下:

class MyClassImpl;   // forward declaration

class MyClass {
public:
MyClass();
~MyClass();
int foo();
private:
MyClassImpl *m_pImpl;
};

MyClass.cpp 文件内容如下:

// 定义MyClass的函数:
MyClass::MyClass() : m_pImpl(new MyClassImpl) {
}

MyClass::~MyClass() {
try {
delete m_pImpl;
}
catch (...) {}
}

int MyClass::foo() {
return m_pImpl->foo();
}

// 声明并定义MyClassImpl
class MyClassImpl {
public:
int foo() {
return bar();
}
int bar() { return var++; }
int var;
};

总结

  • Pimpl要实现的是, 在对类的私有函数/成员做改动时, 不希望(所有包含该头文件的)源文件被重新编译.
  • 如果一个类被设计为基类,应避免在头文件中出现private函数或成员, 如果该类有private的函数或成员,最好把它们放进“前置声明(forward declaration)”的类里面,以避免private的声明出现在头文件;
  • Java需要这种机制吗 ? 不需要, java里有interface, interface里不包含私有数据的, 所以不会有“改动上层类的私有数据导致编译量增加”这种问题.

前置声明(forward declaration)

如果类A中, 有C类型的成员, 则可以在A.h中声明该成员之前, 用class C;的方式来前置声明类型C, 而不再需要在A.h中包含C.h文件:

A.h头文件:

// #include "C.h"  // 不再需要这一行了
class A{
class C; // 前置声明C
C* ptr; // 成员声明
};

但是使用类型的前置声明是有条件的。假设有一个类C,那么如果你的类中如果有定义类型为C的非静态成员,抑或你的类继承了C的话,就不能使用类Test的前置声明,只能用include C.h的方式
大概有三种情况可以使用前置声明:

  • 参数或返回类型为C的函数声明;
  • 类型为C的类静态成员;
  • 类成员变量的类型是 C类型的指针或引用: C*C&

@ref: 【C++程序设计技巧】Pimpl机制

NVI

NVI(Non-Virtual Interface )机制:将虚函数声明为非公有,而将公有函数都声明为非虚 —— 虚拟和公有选其一。

  • 如果在基类中作为”对外接口”(public)的函数, 一定设计成非virtual的
  • 当且仅当子类需要调用基类的虚函数时才将虚函数设置为protected
  • NVI机制不适用于析构函数,对于析构函数,如果是public的也应该是virtual的

如果一个类是作为基类的,那么该类的析构应该写为virtual; 这样在”delete 基类指针” 时, 会自动选择相应版本的析构函数.

为什么需要NVI

在标准C++库中我们可以看到这样的一个现象:6个公有虚函数,并且都是std::exception::what()和其重载。142个非公有虚函数。
这样设计的目的何在呢,为什么“多此一举”的把虚函数设置为非公有呢?

先看示例代码:

class Base {
public:
void Foo(){
DoFoo1();
DoFoo2();
}

protected:
virtual void DoFoo1(){
cout << "Base's DoFoo1" <<endl;
}
private:
virtual void DoFoo2(){
cout << "Base's DoFoo2" <<endl;
}
};

class Derived: public Base{
private:
virtual void DoFoo2(){
cout << "Derived's DoFoo2" << endl;
};
};

因为C++没有Interface的概念, 我们把基类里定义的 public且非虚的函数 视作”接口”, 与java中的接口不同的是, 基类的”接口”函数有自己的函数体.

  • 一般在基类的”接口”里定义更上层的代码(参件Foo()函数), 而把具体的实现放进private/protected的虚函数, 这样做的好处是实现了接口和实现的分离;
  • 派生类可以从基类继承的函数声明为protected virtual的;
  • 需要派生类自己实现的函数声明为private virtual的;

Pimpl和NVI都实现了:接口和实现的分离,将不经常变动的控制代码放入public非虚函数,经常变更或者需要派生类重写的放进非public的虚函数。
从设计模式上来看,Pimpl用的是委托,NVI用的继承.

@ref: 【C++程序设计技巧】NVI(Non-Virtual Interface )