C++ 基础篇

 
 
C++
 6.1k
 

在观看了 Cherno 的 C++ 系列视频后的一些记录——基础篇

2024-07-19 19:58:53

目录:

头文件

循环语句(for、while)

类和结构体中的静态(static)

局部静态(static)

枚举

构造、析构函数

虚函数、纯虚函数(接口)

数组

字符串

const 关键字

mutable 关键字

排序

类型双关

联合体


头文件

C++ 采用了使用头文件来包含声明的约定。在一个头文件中进行声明,然后在每个.cpp文件或其他需要该声明的头文件中使用#include指令,#include指令在编译之前将头文件的副本插入.cpp文件。

引号可以引用所有文件,但是一般用于源文件所在目录的头文件,而尖括号仅用于标准库标头。是否有.h扩展是一种区分 C 标准库和 C++ 标准库的方法。

通常,头文件有一个 include 防范或 #pragma once 指令,用于确保它们不会多次插入到单个 .cpp 文件中。

循环语句(for、while)

语法:

1
2
for (init-statement condition; expression) statement
// init-statement 和 statement 被非正式描述为后随分号的表达式或声明

for 语句等价于:

1
2
3
4
5
6
7
8
{
init-statement
while ( condition )
{
statement
expression;
}
}

以下是两者的异同:

  • init-statement 和 condition 的作用域相同
  • statement 和 expression 的作用域不相交,但都内嵌在 init-statement 和 condition 的作用域中

类和结构体中的静态(static)

当数据成员被声明为static时,只会为类的所有对象保留一份数据副本。静态数据成员不是给定的类类型的对象的一部分。因此,静态数据成员的声明不被视为一个定义。在类范围内声明数据成员,但在文件范围内执行定义。这些静态类成员具有外部链接。

静态数据成员遵循类成员访问规则,因此只允许类成员函数和友元拥有对静态数据成员的私有访问权限。静态方法不能访问非静态变量,因为静态方法没有类实例。本质上在类中写的每一个方法总是获得当前类的一个实例作为参数,在类中你看不到这种东西,它们通过隐藏参数发挥作用,而静态方法不会得到这个隐藏参数。静态方法与在类外部编写方法相同

局部静态(static)

局部静态牵扯到了单例类,不了解单例类,暂时搁置

枚举

枚举只是一种命名值的方法,如果你有一个数值集合,而你想用数字来表示它们,枚举就是你想要的

构造、析构函数

默认构造函数通常没有参数,但它们可以具有带默认的参数。

1
2
3
4
5
class Entity{
public:
Entity(int w = 1, int l = 1, int h = 1) : m_width_(w), m_length_(h), m_height(h) {}
...
}

默认构造函数是特俗成员函数之一。如果类中未声明构造函数,则编译器提供隐式inline默认构造函数。如果你依赖于隐式默认构造函数,请确保在类定义中初始化成员。如果没有这些初始化表达式,成员会处于未初始化状态,Volume() 调用会生成垃圾值。一般而言,即使不依赖于默认构造函数,也最好以上述方式初始化成员。

当你不希望有人创建类实例时,有两种不同的解决方法:

  • 可以通过设置为private来隐藏构造函数

    1
    2
    private:
    Log() {}
  • C++ 为我们提供了一个默认构造函数,我们可以告诉编译器,我不想要那个默认构造函数

    1
    Log() = delete;

析构函数同时适用于堆和栈分配的对象,如果你使用new分配一个对象,通过调用deletedelete []显示销毁对象时,析构函数就会被调用。而如果只是一个栈对象,当作用域结束对象超出范围时,栈对象将被删除,析构函数也会被调用

虚函数、纯虚函数(接口)

虚函数引入了一种叫做 Dynamic Dispatch(动态联编)的东西,它通过虚函数表来实现编译,虚函数表就是一个表,它包含基类中所有虚函数的映射,这样我们可以在它运行时,将它们映射到正确的override函数

1
2
3
4
5
6
7
8
9
10
11
12
13
class Entity {
public:
virtual std::string GetName() { return "Entity"; }
}

class player : public Entity {
private:
std::string m_name_;
public:
Player(const std::string &name) : m_name_(name) {}

std::string GetName() override { return m_name_; } // C++11 引入`override`关键字来标记`override`函数
}

虚函数并不是免费的(无额外开销的),有两种与虚函数相关的运行时成本:

  • 我们需要额外的内存来存储虚函数表,这样就可以分配到正确的函数,包括基类中要有一个成员指针,指向虚函数表
  • 每次我们调用虚函数表时,我们需要遍历这个表,来确定要映射到哪个函数

可能在一些嵌入式平台上 cpu 性能很差,避免使用虚函数,除此之外,它的影响小到你可能都不会注意到

纯虚函数本质上与其他语言(如Java或C#)中的抽象方法或接口相同,它允许我们在基类中定义一个没有实现的函数,然后强制子类去实现该函数,只有实现了纯虚函数之后,才能够实例化

1
2
3
4
5
6
7
8
9
10
11
12
13
class Entity {
public:
virtual std::string GetName() = 0;
}

class player : public Entity {
private:
std::string m_name_;
public:
Player(const std::string &name) : m_name_(name) {}

std::string GetName() override { return m_name_; }
}

数组

对于for循环,一般不采用 <= 或 >= ,这涉及到性能问题,因为你在做小于以及等于的比较,所以它必须做等于比较,而不仅是小于比较

在堆上创建数组,动态地用new来分配,最大的原因是生存期,用new分配的内存,它将一直存在,直到你删除它。在栈上创建数组可以避免间接寻址,因为在内存中跳跃肯定会影响性能。

实际上没有办法计算出原始数组的大小,需要自己维护自己的数组大小

1
2
3
static const int size = 5;
int example[size];
int *example = new int[size];

在 C++11 中引入了标准数组std:::array,它有边界检查,可以记录数组大小

1
std::array<int, 5> another;

字符串

std::string的本质是一个const char*数组,很多std::string可行而const char*不可行的操作都是基于对操作符的重载后实现的

举个例子,不引入头文件string<<会报错,这是因为<<允许我们发送字符串到流中的重载版本是在string头文件内部

const 关键字

const是一种承诺,它承诺了一些东西是不变的,但并不意味着不可以违背,在这里提出来只是想告诉你在计算机中没有什么是真正一成不变的,话说回来这是一个承诺,你应该遵守承诺

最基本的用法:

1
2
3
4
5
6
const int kMaxAge = 90;

int *ptr = new int;

ptr = &kMaxAge // 这是不被允许的,kMaxAge 是常量
ptr = (int *)&kMaxAge // 这是可行的,正如我们所说`const`只是一个可以被绕过的承诺

还有几种用法,首先是指针:

1
2
3
4
5
6
7
8
9
const int *ptr = new int; // int int const *ptr = new int;

*ptr = 2; // 这是不被允许的,不可修改指针指向的内容

int * const ptr = new int;

ptr = &val // 这是不被允许的,不可修改指针指向的地址

const int * const ptr = new int; // 猜测一下? ok,这行代码意味着指针指向的地址和内容同时不可修改

最后是在类中以及方法中使用const

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Entitty {
private:
int m_x_, m_y_; // 如果是指针`int *m_x_, *m_y_;`,那就得`const int * const get_m_x() const { return m_x_; }`
pulic:
int get_m_x() const { // `const`影响函数,对类成员变量只读不写
return m_x_;
}

int set_m_x() {
m_x_ = 2;
}

}

void PrintEntity(const Entity& e) { // 常量引用,引用就是内容,这里不会有指针指向的地址和内容的区别了
std::cout << e.get_m_x() << std::endl; // 如果`int get_m_x() { return m_x_; }`,那么就不可调用 get_m_x 函数了,get_m_x 函数不能保证它不会写入 Entity 了
}

有一种const的情况,你确实想要标记方法为const,但由于某些原因,又确实需要修改一些变量:

1
2
3
4
5
6
7
8
9
10
11
class Entitty {
private:
int m_x_, m_y_;
mutable int val; // `mutable`允许函数是常量方法,但可以修改变量
pulic:
int get_m_x() const {
val = 2;
return m_x_;
}

}

mutable 关键字

在类成员中使用mutable可能是唯一你会使用到它的情况了,例如为了调试,我们想计算一下函数在程序中被调用了多少次:

1
2
3
4
5
6
7
8
9
10
11
class Entitty {
private:
int m_x_, m_y_;
mutable int m_debug_count_;
pulic:
int get_m_x() const {
m_debug_count_++;
return m_x_;
}

}

但是,还有一个用mutable的地方,就是lambda

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int x = 8;
/*
auto f = [=]() {
int y = x;
y++;
std::cout << x << std::endl;
}
*/
auto f = [=]() mutable {
x++;
std::cout << x << std::endl;
}

f();

成员初始化列表

成员初始化列表不仅使代码非常干净,易于阅读,这实际上也有一个功能上的区别,在特定的类的情况下,会提升性能:

1
2
3
4
5
6
7
8
9
class Entity {
private:
int m_score_;
std::string m_name_;
public:
Entity(const std::string &name) : m_score_(0), m_name_(name) { // 初始化列表,确保与成员变量声明时的顺序一致
// 使用`m_name_ = name;`,去掉在成员初始化列表中的赋值,实际上会发生的是 m_name_ 对象会被构造两次,首先是默认构造函数,然后是初始化的构造函数
}
}

尽管对于整数这样的基本类型,它不会被初始化,除非你通过赋值来初始化它们,但是没有必要区分基本类型和类类型,你应该在所有地方都使用初始化列表

创建并初始化 C++ 对象

在栈上创建对象:

1
2
Entity e;
Entity e = Entity("cherno");

在堆上创建对象:

1
2
3
4
5
6
7
8
9
Entity e;
{
Entity *entity = new Entity("cherno");
e = entity;
std::cout << e->get_m_x_() << std::endl;
}

std::cin.get();
delete e;

这就是我们创建对象的两种方法,如何选择呢,如果对象太大或者你要显示的控制对象的生存期,那就在堆上创建。如果你忽略这两个选择的话,那就在栈上分配,栈上分配自动化而且更快,而在堆上分配,需要你手动delete,对此你可以使用智能指针,这点之后再谈

【39】C++ new 关键字

new返回指向你分配的内存的空指针(空指针基本上是一个没有类型的指针,指针只是一个内存地址,指针之所以需要类型,是因为你需要类型来操纵它)

new不仅分配内存,它还调用构造函数,这背后的真相是它是一个操作符,这意味着你可以重载这个操作符,并改变它的行为。通常调用new,会调用隐藏在里面的 C 函数

关于new想说的最后一点是当使用了new关键字时,得记住必须要用delete

1
2
Entity* e = new Entity();
Entity* e = (Entity*) malloc (sizeof(Entity)); // 这两行代码之间仅有的区别是`new`还调用了`Entity`构造函数

【40】C++ 隐式转换与explicit关键字

C++ 实际上允许编译器对代码执行一次隐式转换。当explicit放在构造函数前面,这意味着没有隐式转换,如果要构造对象则必须显示调用此构造函数

【42】C++ 的this关键字

在 C++ 中为了调用一个非静态方法,需要首先要实例化一个对象,然后调用这个方法。而this是一个指向当前对象实例的指针,这实际上对方法的一般工作方式很重要,在类中你可以通过this创建对象来调用某个方法

排序

1
2
3
4
5
6
7
sort(nums.begin(), nums.end(), [](int a, int b) {
if (a == 1)
return false;
if (b == 1)
return ture;
return a > b;
})

联合体

联合体一次只能占据一个成员的内存,假如 union 内定义了float x, y, z, w;,这里换成类或者结构体所占用的内存应该是 16 个字节,但是联合体所占内存为 4 个 字节,并且如果初始化 w 为 5.0f,那么其他三个成员数据的值均为 5.0f

现在是不是有点眼熟啊,没错,这里联合体就是起到了类似类型双关的作用,将一块内存用不同的变量(或者类型)来解释。有一点需要提及的是,联合体通常是匿名使用,匿名联合体不能包含成员函数

Comments