cpp

从C到C++

命名空间

命名空间用于解决代码名称冲突的问题,降低命名冲突的风险。

1
2
3
namespace mySpace{
void foo();
}

使用域解析运算符::在函数名称前给出命名空间。

1
2
3
4
int main()
{
mySpace::foo();
}

也可以使用using指定命名空间或要调用的函数,其后再调用时就不需要使用命名空间了。

不要滥用using namespace std,更不要在头文件中使用using namespace std。

1
2
3
4
5
int main()
{
using namespace std;
cout << "hello world!" << endl;
}

C++17后,命名空间可以嵌套,还可以给命名空间起别名。

1
2
3
4
5
6
7
8
9
10
11
namespace Outer {
namespace Inner {
/* ... */
}
}

namespace Outer::Inner {
/* ... */
}

namespace mySpace = Outer::Inner;

字面量

字面量是写在代码中的量,包括:

  • 十进制123
  • 八进制0173
  • 十六进制0x7B
  • 二进制0b1111011
  • 浮点数3.14f
  • 双精度浮点值3.14
  • 字符’a’
  • C风格字符串”hello world”
  • C++17新增的十六进制浮点数0x3.ABCp-10

数值字面量可以使用单引号作为分隔符,如23’456’789。

变量

变量的声明和初始化:

1
int n = 7;

C++提供的变量类型有:

  • 整型
    • (signed) int/signed
    • unsigned (int)
    • (signed) short (int)
    • unsigned short (int)
    • (signed) long (int)
    • unsigned long (int)
    • (signed) long long (int)
    • unsigned long long (int)
  • 浮点型
    • float
    • double
    • long double
  • 字符型
    • char
    • char16_t
    • char32_t
    • wchar_t
  • bool
    • std::byte(C++17)

隐式转换不必说,显式类型转换有三种方式:

1
2
3
int i = (int)f;
int i = int(f);
int i = static_cast<int>(f);

从C而来的类型

枚举的本质是整型。

1
2
3
4
enum Season {
Spring = 1,
Summer
};

枚举类则是类型安全的,枚举值名不会自动超出封闭的作用域,因此枚举在使用时总需要作用域解析操作符

1
2
3
4
5
6
enum class Season {
Spring,
Summer
};

Season s = Season::Spring

结构体和类很相近。

1
2
3
4
struct Student {
int age;
char* name;
};

语句

C++17允许if或switch语句使用一个初始化器,变量只能在初始化器和大括号中使用。

1
2
3
if (int i = rand(); i % 2 == 1) {
std::cout << i << "是一个奇数" << std::endl;
}

switch语句的表达式必须为整型或枚举,并与常量比较。C++17允许使用[[fallthrough]]有意忽略break。

1
2
3
4
5
6
7
8
9
Season s = Season::Spring;
switch (s) {
case Season::Spring:
/* ... */
[[fallthrough]];
case Season::Summer:
/* ... */
break;
}

函数

C++14允许函数返回类型推断。

1
2
3
auto addNumbers(int num1, int num2) {
return num1 + num2;
}

函数都有一个预定义的局部变量func表示当前执行的函数名。

数组

虽然仍然可以使用C风格的数组,但是最好使用定长数组std::array和动态数组std::vector。

1
2
3
int myArray[3] = { 1, 2, 3 };
array<int, 3> arr = { 1, 2, 3 };
vector<int> vec = { 1, 2, 3 };

结构化绑定

C++17允许用中括号同时声明多个变量,并使用数组、结构体、对组或元组来初始化。

1
2
3
4
struct Point { double mX, mY; };
Point p;
p.mX = 1.0; p.mY = 2.0;
auto [x, y] = p;

初始化表列

使用初始化表列可以编写接收可变数量参数的函数。

1
2
3
4
5
6
7
8
9
#include <initializer_list>

int makeSum(initializer_list<int> lst) {
int total = 0;
for (int v : lst) {
total += v;
}
return total;
}

调用函数时,可以使用:

1
int a = makeSum({ 1,2,3 });

指针

nullptr是空指针常量,类型是指针类型。

不要使用C的指针和malloc(),free(),使用智能指针和new,delete,new[],delete[]。

最重要的智能指针是std::unique_ptr和std::shared_ptr。

std::unique_ptr类似普通指针,但当unique_ptr超出作用域或被删除是会自动释放内存或资源,因而不需要调用delete。shared_ptr使用引用计数,超出作用域时递减引用计数,计数为0时释放对象。

1
2
auto pc1 = make_unique<Complex>();
auto pc2 = make_shared<Complex>();

智能指针的reset()方法可以释放当前指针的资源并进行重设,如果不传入参数,则设为nullptr。

1
2
pc1.reset(new Complex());
pc1.reset();

release()方法解除智能指针的所有权。

1
2
3
4
Complex* p = pc1.release();
/* ... */
delete p;
p = nullptr;

常量

C++的const常量是真实的常量,可以作为数组长度。

const的实现原理是符号表,编译过程中对常量直接进行替换。如果编译过程发现对常量使用&或extern,则给常量分配内存。

引用

C++的引用可以看作一个已定义变量的别名,引用的内部实现是常指针,但请把引用视作变量本身。创建引用时必须初始化,且不能修改。

1
2
void func1(int& a) { a = 5; }
void func2(int* const a) { *a = 5; }

函数如果返回引用,则应当将返回值视作变量本身。

1
2
3
4
5
6
7
8
9
10
11
int a = 0;
int& getA() { return a; }

int main() {
int x = getA(); //int x = a;
int& y = getA(); //int& y = a;
getA() = 100; //a = 100;
int* p = &getA(); //int* p = &a;
getA()++; //a++
return 0;
}

常量引用作为函数参数可以增加效率:函数不会创建副本,只传递指针,且不修改原始变量。

1
2
3
4
5
6
7
8
9
10
void printString(const std::string& s) {
std::cout << s << std::endl;
}

int main() {
std::string s = "hello world";
printString(s);
printString("hello world");
return 0;
}

如果要修改对象,则传递非常量引用。非常量引用的初始值必须为左值,函数此时不可以传入字面量。

三目运算符给出的是引用,因而可以做左值。

1
2
a > b ? a : b = 10;
*(a > b ? &a : &b) = 10;

右值引用

可以获取地址(有名称)的值称为左值,不可获取地址的称为右值。字面量、临时对象和临时值都是右值。

右值引用时对右值的引用,这用在临时对象上,在临时对象销毁前,某些值的复制可以用复制指针来代替。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void fun(Complex& s) {
std::cout << "左值引用:" << s << std::endl;
}

void fun(Complex&& s) {
std::cout << "右值引用:" << s << std::endl;
}

int main() {
Complex s1 = { 1.0,2.0 };
Complex s2 = { 3.0,4.0 };
fun(Complex()); //无参构造函数+右值引用+析构函数
fun(s1); //左值引用
fun(s1 + s2); //有参构造函数+右值引用+析构函数
cout << "pause" << endl;
return 0;
}

要注意,右值引用作为形参是左值,因为其有地址。

move()可以将左值移动为右值。

1
2
3
4
5
6
7
void fun(Complex&& s) {
std::cout << "右值引用:" << s << std::endl;
}

void newfun(Complex&& s) {
fun(move(s));
}

类型推断

类型推断有两个关键字,auto和decltype。auto可以对类型作推断,并去除const限定符和引用,有时这会产生副本,可以使用auto& 或const auto&。decltype可以把表达式作为实参,而且也不会去除const限定符和引用。

1
decltype(foo())f = foo();

字符串

代码中直接出现的字符串是字符串字面量,保存在字面量池中。生字符串使用R()R"分隔符序列(生字符串)分隔符序列"引导,其中不会出现转义字符。

std::string是basic_string模板类的一个实例。尽管string是一个类,但不妨把它当成一种内建类型。

string类重载了operator+,operator+=,operator==,operator!=,operator[],operator<等运算符,以符合使用者预期的方式工作。

std命名空间包含了许多辅助函数来进行string的转化,如:

  • string to_string(int val);
  • int stoi(const string& str, size_t* idx=0, int base=10);//idx接收第一个未能转化的字符索引

C++17引入了std::string_view类解决对参数为const string&但传入const char会创建副本的问题。*string_view是const string&的简单替代品,只包含字符串的指针和长度,从不复制字符串。通常按值传递string_view。**

1
2
3
string_view extractExtension(string_view fileName) {
return fileName.substr(fileName.rfind('.'));
}

在这个函数下,传入const char*和const string&都没有问题,也不会制作字符串的副本。只会值传递string_view,也就是指针和长度。

无法拼接string和string_view,也无法隐式地从string_view创建string。可以的方案是使用sv.data()或string(sv)。

面向对象

类与对象

对象是对数据及数据的操作方法的封装,而同类型的对象抽象出其共性就是类。类通过一个简单的外部接口与外界发生关系。对象和对象之间通过消息进行通信。

类把属性和方法进行封装,对属性和方法进行访问控制。类的访问控制关键字包括public,private和protected。类的默认访问说明符是private,结构体的默认访问说明符是public。

友元使用关键词friend。友元函数可以访问类的私有成员,友元类中的函数全部都是友元函数。友元类一般作为传递消息的辅助类,若B是A的友元类,则一般A是B的子属性,用B来修改A。

C++面向对象模型

C++类对象中的成员变量和成员函数是分开存储的。C++类中的普通成员函数都隐式包含一个指向当前对象的this常指针。

const修饰成员函数,表示*this不能被修改。 此时this不仅是常指针,更是常量常指针。

1
2
double getReal() const;
double getReal();

如果一个类中有同名的常量和非常量函数,算是重载。常对象和非常对象可以分别调用。

对象的构造与析构

构造函数是与类名相同的特殊成员函数,没有任何返回类型的声明。

当栈中的对象超出作用域时,对象会被销毁,这时会发生两件事,调用对象的析构函数并释放对象的内存。先被创建的对象后释放。

无参构造函数(默认构造函数)

用类创建对象时,调用无参构造函数,无参构造函数的调用不能加空括号,否则编译器会将其视为函数声明。

1
2
Complex c1;		//调用默认构造函数
Complex c2(); //可以通过编译,但编译器认为这是一个函数声明

如果没有显式地声明构造函数,则编译器会提供默认的无参构造函数。

default和delete

如果希望C++保留默认构造函数,可以使用default。如果不希望使用构造函数,可以使用delete。

1
2
3
4
5
6
7
8
9
10
class MyClass1 {
public:
MyClass1() = default;
MyClass1(int);
};

class MyClass2 {
public:
MyClass2() = delete;
};

可以将拷贝构造函数和operator=设为delete来禁止赋值和按值传递。

有参构造函数

有参构造函数有以下调用形式。

1
2
3
4
Complex c1(1.0, 2.0);
Complex c2 = { 1.0, 2.0 };
Complex c3{ 1.0, 2.0 };
Complex c4 = Complex(1.0, 2.0); //使用了匿名对象,随后使之成为c4,而非拷贝

拷贝构造函数

编译器生成的拷贝构造函数的具有默认形式:

1
2
MyClass::MyClass(const MyClass& c)
:m1(c.m1), m2(c.m2) {}

如果没有显式地声明拷贝构造函数,编译器会提供默认的拷贝构造函数。

C++传递函数参数的默认方式是值传递,实参初始化形参时使用拷贝构造函数。

1
2
Complex c1 = old_c; //调用拷贝构造函数
Complex c2(old_c);

拷贝构造和赋值运算符

如果函数返回匿名对象,给对象赋值则会使匿名对象析构,如果使用匿名对象初始化一对象,则匿名对象会转化为新的对象。

1
2
3
4
5
6
7
8
9
10
Complex fun() {
return Complex();
}

int main() {
Complex c = fun(); //在fun()内调用构造函数
Complex c2, c3; //调用无参构造函数
c2 = c; //调用赋值运算符
c3 = fun(); //调用赋值运算符和匿名对象的析构函数
}

总之,声明会使用拷贝构造函数,而赋值语句会使用赋值运算符。

移动语义

应当实现移动构造函数和移动赋值运算符,它们可以将右值的所有权交给现在的变量。只有知道源对象即将销毁时移动语义才有用。移动结束之后需要将源对象设为nullptr以防源对象的析构函数释放这块内存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Sheet {
public:
Sheet(Sheet&& src)noexcept
:Sheet() {
swap(*this, src);
}
Sheet& operator = (Sheet&& rhs) noexcept {
Sheet temp(move(rhs));
swap(*this, temp);
return *this;
}
private:
Sheet() = default;
};

标准库的swap()也是依赖移动语义实现的,避免了所有复制。

1
2
3
4
5
6
template <class _Ty, class>
void swap(_Ty& _Left, _Ty& _Right) noexcept(is_nothrow_move_constructible_v<_Ty>&& is_nothrow_move_assignable_v<_Ty>) {
_Ty _Tmp = _STD move(_Left); //T temp(std::move(left));
_Left = _STD move(_Right); //left = std::move(b);
_Right = _STD move(_Tmp); //right = std::move(temp);
}

五规则和零规则

如果类中动态分配了内存,通常应当事先析构函数、拷贝构造函数、移动构造函数、赋值运算符与移动赋值运算符。

但在现代C++中,应当避免旧式的、动态分配的内存,而改用现代结构。

构造函数初始化器

可以使用构造函数初始化器初始化成员。有参构造成员、const和引用必须在初始化器中赋值。 如果初始化器有多个成员,按照成员的定义顺序构造它们。

1
2
MyClass::MyClass(Complex c)
:mC(c) {}

委托构造

委托构造允许构造函数调用该类的其他构造函数,这个调用必须使用构造函数初始化器,且必须是唯一的成员初始化器。

1
2
Complex::Complex()
:Complex(0.0, 0.0) {}

运算符重载

运算符函数是一种特殊的成员函数或友元函数。成员函数具有this指针,而友元函数没有this指针。

二元算数运算符一般重载为全局函数,因为有时需要隐式的类型转换或自定义类型在运算符右边的情形。

1
2
3
friend Complex operator+(const Complex& c1, const Complex& c2){
return Complex(c1.mR + c2.mR, c1.mI + c2.mI);
}

前置++和后置++用一个int占位参数进行区分。前置++返回引用,后置++返回值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//前置++
Complex& Complex::operator++()
{
this->mR++;
return *this;
}

//后置++
Complex Complex::operator++(int)
{
auto tmp(*this);
++(*this);
return tmp;
}

不要重载&&和||,这会让它们的短路功能失效。

为了满足链式编程的需求,重载<<和>>时需要返回流的引用。

1
2
3
4
friend std::ostream& operator<<(std::ostream& out, const Complex& c) {
out << c.mR << " + " << c.mI << "i";
return out;
}

对于一些容器,往往需要重载下标运算符。为了提供只读访问,往往还提供const版本。下标运算符并不一定只能接受整数,也可以接受其他类型作为键。

1
2
3
4
5
6
7
8
9
10
11
template <typename T>
T& AssociativeArray<T>::operator[](std::string_view key)
{
for (auto& element : mData) {
if (element.mKey == key)
return element.mValue;
}

mData.push_back(Element(key, T()));
return mData.back().mValue;
}

下标运算符不能接收多个参数,可以重载函数调用运算符。此外,重载函数调用运算符可以将对象伪装成函数指针,然后将函数对象当成回调函数传给其他函数。

为实现类型转换,需要重载类型转换运算符。类型转换运算符函数不需要返回值类型,因为运算符名即确定返回类型。

1
2
3
4
Complex::operator double() const
{
return this->mR;
}

不过此时会出现多义性,可以用将构造函数或类型转换运算符函数标为explicit来禁用自动类型转换。

1
2
Complex c(1.0, 2.0);
double d2 = 1.0 + c;

将double()标为explicit后,下面可以使用:

1
2
Complex c(1.0, 2.0);
double d2 = static_cast<double>(1.0 + c);

代码重用

继承与派生

继承模型

子类继承父类的全部成员变量和除了构造及析构以外的成员函数。

类型兼容性原则:子类就是特殊的父类。子类是父类成员叠加子类新成员得到的。

  • 子类对象可以当做父类对象使用
  • 子类对象可以直接赋值给父类对象
  • 子类对象可以初始化父类对象
  • 父类指针可以直接指向子类对象
  • 父类引用可以直接引用子类对象

当子类和父类有同名成员时:

  • 子类的成员屏蔽父类的同名成员
  • 访问父类同名成员需要使用父类的类名限定符
  • 父类成员的作用域延伸到所有子类

构造和析构

子类对象在创建时:

  • 首先调用父类的构造函数
  • 父类构造函数执行结束后,执行子类的构造函数。
  • 当父类的构造函数有参数时,需要在子类的初始化列表中显式调用
  • 析构函数的调用顺序与构造函数相反

当继承和组合混搭时:

  • 先构造父类,再构造成员变量,最后构造自己
  • 先析构自己,再析构成员变量,最后析构父类

多态

重载、重写、重定义

重载:

  • 必须在同一个类中进行
  • 子类无法重载父类的函数,父类同名函数会被覆盖
  • 重载发生在编译期间,根据参数表列决定函数调用
    重写:
  • 必须发生在父类和子类之间
  • 父类和子类必须有相同的函数原型
  • 使用virtual声明
  • 多态在运行期间根据具体类型决定函数调用
    重定义:
  • 不使用virtual,子类覆盖父类函数

将所有方法都设为virtual,除了构造函数(因为实例化子类对象时必须逐个调用父类的构造函数)。尤其是析构函数,这可以防止意外地忽略析构函数的调用。

如果需要在子类中重写某一方法,始终使用override关键字,确保重写的正常进行,而不是意外地创建了新的虚方法。

1
2
3
4
5
6
7
8
9
10
class Base
{
public:
virtual void fun();
};

class Derived :public Base {
public:
virtual void fun() override;
};

不能重写静态方法,因为静态方法属于类而不属于对象。

应当重写重载方法的所有版本,可以显式地重写也可以使用using关键字。

1
2
3
4
5
class Derived :public Base {
public:
using Base::fun;
virtual void fun() override;
};

多态的实现

  • 子类继承父类
  • 子类重写父类虚函数
  • 父类指针/引用指向子类对象

不要到数组使用多态,因为指针步长不一定相等。

多态原理

当类中声明虚函数时,编译器会在类中生成虚函数表。虚函数表是一个存储类成员函数指针的数据结构,由编译器自动生成和维护,virtual成员函数会被编译器放入虚函数表中。

当存在虚函数表时,每个对象都有一个指向虚函数表的VPTR指针,VPTR一般作为类对象的第一个成员。

编译器不区分对象是子类对象还是父类对象,它只区分是否是虚函数,是在虚函数表中寻找函数的入口地址

初始化子类的vptr时,vptr会分步依次指向父类的虚函数表。

多继承

多继承有可能会带来二义性,尤其是多继承了同属于一个父类的两个子类。虚继承可以保证被继承的父类只会构造一次。

解决“菱形”问题的最好方式是将最顶层的类设为抽象类,将所有方法设为纯虚方法,只声明方法而不提供定义。如果顶层的类提供了方法的实现,则可以虚继承这个类,子类将视虚基类没有任何方法的实现。

多继承的合理应用是混入类,这种混入类通常以-able结尾。


article_txt
目录