Seeker.Log

一个笨拙的探索者的思考

0%

看到项目代码里的构造函数,有时候用explicit,有时候不用,引发了我的思考,到底这个关键字什么时候用呢?

带着这个问题,我查看了一些资料。

比较官方的解释是:

explicit关键字用于禁止构造函数的隐式类型转换

当构造函数被标记为explicit时,编译器不会自动使用该构造函数进行隐式转换。


听得似懂非懂。

还是找些具体的例子看看来说吧。

没有explicit导致的重载决议歧义错误

C++ 有一个默认特性:如果一个构造函数只接受一个参数,编译器会默认认为它定义了一种“隐式转换路径”。

在这个例子里,由于单参数构造函数没有explicit,所以没有禁止隐式转换,导致重载决议歧义错误,出现编译错误。
代码如下:

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
// errDemo.cc
#include <iostream>

class Cents {
int cents_;
public:
Cents(int c) : cents_(c) { std::cout << "Cents: " << c << "\n"; }
};

class Dollar {
int dollar_;
public:
Dollar(int c) : dollar_(c) { std::cout << "Dollar: " << c << "\n"; }
};

void payBill(Cents amount) {
std::cout << "Paying with cents\n";
}

void payBill(Dollar amount) {
std::cout << "Paying with dollars\n";
}

int main() {
payBill(500); // ❌ 编译错误:ambiguous call
return 0;
}

由于两个函数的匹配程度完全相同,都需要一次用户定义的隐式转换, 编译器无法确定选择哪一个,导致编译错误
重载决议歧义错误

这时,我们通过使用 explicit,要求必须明确意图的进行调用,于是修改上面的代码

1
2
3
4
5
6
7
8
9
10
// 1. 构造函数前加explicit关键字
explicit Cents(int c) : cents_(c) { std::cout << "Cents: " << c << "\n"; } // 禁止隐式转换
explicit Dollar(int c) : dollar_(c) { std::cout << "Dollar: " << c << "\n"; } // 禁止隐式转换


// 2.明确调用意图
// payBill(500); // ❌ 此时会报编译错误:没有匹配的重载
// error: no matching function for call to ‘payBill(int)
payBill(Cents(500)); // ✅ 必须显式转换, 明确意图
payBill(Dollar(500)); // ✅ 必须显式转换, 明确意图

明确意图,显式转换


没有explicit导致的性能陷阱

在性能关键、资源敏感的软件开发中,没有使用explicit,超出预期的隐式转换,可能无意间消耗内存资源。
代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Matrix {
double* data;
size_t size_;
public:
Matrix(size_t size) : size_(size) {
data = new double[size * size]; // 昂贵的内存分配!
}
};

void compute(const Matrix& m);

// 性能陷阱
compute(1000); // 意外创建了1000x1000的矩阵!

这时,通过使用 explicit,要求必须明确意图的进行调用,修改上面的代码

1
2
3
4
5
6
7
8
// 1. 构造函数前加explicit关键字
explicit Matrix(size_t size) : size_(size) {
data = new double[size * size];
}

// 2.明确调用意图
// compute(1000); // ❌ 编译错误
compute(Matrix(4)); // ✅ 明确知道在创建矩阵

这种场景,主要防止的是,有可能开发人员根本没有意识到会触发内存分配,或者误在性能敏感的代码中意外创建大对象。
当然,如果的确需要创建,就显式构造,开发知道自己在干什么,可以对自己的行为负责。

加上explicit对列表初始化的影响

分析完单参数构造函数非必要最好加上explicit关键字之后,我们再看看,如果多参数构造函数加上explicit,又会有什么影响呢?

官方的说法是:当explicit构造函数接受std::initializer_list时,会失去所有数量的初值的隐式转换能力。
(说起来有点绕口,直接看例子就清晰了。)

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
class Container {
public:
// 非explicit版本
Container(std::initializer_list<int> values);
};

class SafeContainer {
public:
// explicit版本
explicit SafeContainer(std::initializer_list<int> values);
};

// 对比效果
Container c1 = {}; // ✅ 0个初值 隐式转换
Container c2 = {42}; // ✅ 1个初值 隐式转换
Container c3 = {1, 2, 3}; // ✅ 多个初值 隐式转换

SafeContainer sc1 = {}; // ❌ 失去0个初值的隐式转换
SafeContainer sc2 = {42}; // ❌ 失去1个初值的隐式转换
SafeContainer sc3 = {1, 2, 3}; // ❌ 失去多个初值的隐式转换

// 必须使用直接初始化
SafeContainer sc4{}; // ✅ 0个初值
SafeContainer sc5{42}; // ✅ 1个初值
SafeContainer sc6{1, 2, 3}; // ✅ 多个初值

加上explicit对多参数构造函数的影响

如果是普通的多参数构造函数加上explicit,又会有什么影响呢?
先说答案:直接初始化仍然不受影响,但拷贝初始化会受到影响。

代码如下:

1
2
3
4
5
6
7
8
9
10
11
class P {
public:
explicit P(int a, int b, int c);
};

P obj1{1, 2, 3}; // ✅ 直接初始化,总是可以
P obj2(1, 2, 3); // ✅ 直接初始化,总是可以


P obj3 = {1, 2, 3}; // ❌ 拷贝初始化,explicit会阻止
P obj4 = P{1, 2, 3}; // ✅ 显式构造后拷贝,可以

为什么加explicit会影响拷贝初始化呢?
分析下来是因为拷贝初始化需要两步:

  1. {1, 2, 3}创建临时对象(需要隐式调用构造函数)
  2. 将临时对象拷贝给目标变量
    explicit阻止了第一步的隐式调用。

有些场景需要加,但是有些场景我们又不应该使用explicit

拷贝和移动构造函数绝对不要加explicit

虽然 C++ 语法上允许你给拷贝/移动构造函数加上 explicit,但在工程实践中,这样做基本等于“自杀”,或者说是给使用者(包括你自己)制造巨大的麻烦。

如果说拷贝和移动构造函数加了explicit,这会发生什么?
答案是:会破坏基本语义,破坏函数传递,破坏容器使用,破坏返回值语义。
品一品,是不是这么回事。
上代码细看。

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
// 永远不要这样做
explicit MyClass(const MyClass& other); // ❌
explicit MyClass(MyClass&& other); // ❌

// 1.破坏基本语义
MyClass obj1;
MyClass obj2(obj1); // ✅ 允许:直接调用(像函数调用一样)
MyClass obj3 = obj1; // ❌ 如果拷贝构造是explicit,这会编译错误!因为等号 "=" 语义上要求隐式转换。
// 这意味着你无法再使用最自然的 = 进行赋值初始化了。

// 2.破坏函数传递
void process(MyClass obj); // 按值传递
MyClass original;
process(original); // ❌ 如果拷贝构造是explicit,无法传递参数!这是因为为了把 original 传进函数,需要构造一个临时对象。这是一次隐式动作,被 explicit 拦截了。
// 你被迫要写成这样这种恶心的代码:
process(MyClass(original)); // ✅ 显式强转
// 如果所有的类都这样写,C++ 的函数调用简直没法看了。

// 3.破坏容器使用
std::vector<MyClass> vec;
MyClass obj;
vec.push_back(obj); // ❌ 容器无法工作!
// 标准库容器依赖于拷贝/移动语义
// 很多 STL 操作内部逻辑是:在内存中放置新对象时,使用了 = 语义或者隐式构造语义。
// 如果拷贝/移动构造函数是 explicit 的,你的类基本上就告别 STL 标准库了,除非你用非常小心翼翼的 emplace 操作,但也很容易踩雷。

// 4.破坏返回值语义
MyClass createObject() {
MyClass obj;
return obj; // ❌ 无法返回对象!
}
// 在语义检查阶段,return 语句被视为一种“隐式构造”

关于拷贝/移动构造函数加explicit会破坏返回值语义,有些小伙伴可能像我一样,在这里会有一个疑问:“C++ 不是有 RVO/NRVO (返回值优化) 吗?编译器不是会把拷贝直接消除掉吗?既然消除了,为什么还要检查构造函数?”

这是因为这里有两步:

语义检查 (Semantic Check):编译器的第一步是检查“如果你要拷贝,代码写得对不对”。这一步要求拷贝/移动构造函数必须是可访问的且非 explicit 的。

代码生成与优化 (Code Generation & Optimization):只有通过了语义检查,编译器才会进行第二步优化(RVO),在运行时消除这次拷贝。

所以,结论就是:即使 RVO 会在运行时消除拷贝,explicit 依然会在编译时导致报错,因为它阻断了语义检查阶段的合法性。


正确的做法

1
2
3
4
5
6
7
8
9
10
class MyClass {
public:
// ✅ 正常的拷贝和移动构造函数
MyClass(const MyClass& other) = default;
MyClass(MyClass&& other) = default;

// 或者如果不需要拷贝,就删除它们
MyClass(const MyClass&) = delete;
MyClass(MyClass&&) = delete;
};

总结

explicit 的核心目的是防止意外的类型转换(比如把 int 变成 String)。

但是,拷贝和移动本身就是同一种类型的传递,它们在 C++ 语言层面被设计为一种“自然流动”的操作。阻断这种流动(加上 explicit),就会导致这个类在 C++ 的生态系统中寸步难行。

参考 Google C++ 编程规范的建议,我的使用原则目前是:

(1)原则上,所有的单参数构造函数都应该加上 explicit
除非你真的希望用户使用这种隐式转换带来便利。(例如模拟基础数据类型)

(2)多参数构造函数(C++11 之前通常不需要,但 C++11 引入了列表初始化 {})按具体场景决定加不加explicit
如果构造函数支持列表初始化,且你不想让 {1, 2} 隐式变成你的对象,也要加 explicit。

(3)拷贝/移动构造函数,绝对不要加 explicit

(大家有什么使用习惯吗?欢迎评论区交流)

工欲善其事,必先利其器。

关于visual studio的断点调试,一直没弄清楚那3个选项:逐过程,逐语句,跳出这3个都分别是什么含义!每次都是凭感觉来回试。。。现在终于研究明白了!

调试的核心三剑客

这三个功能是调试的核心三剑客,就像看电影时的“快进”、“慢放”和“跳过”。

一定要记住它们的快捷键(F10, F11, Shift+F11),使用的时候效率会提高十倍!
(除了这些,还有些常用的的快捷键,比如 F5 是跳过此次命中,继续执行,真正调试的时候用起来都非常方便)

举个例子来说

我们假设有这样一段代码,此时断点停在 Calculate() 这一行:

1
2
3
4
5
6
void MyFunction() {
int a = 10;
int b = 20;
int result = Calculate(a, b); // <--- 断点停在这里(黄色箭头)
printf("Result: %d", result);
}

1. 逐语句 (Step Into)

  • 快捷键F11
  • 含义遇到函数,这就进去。
  • 动作:如果当前行包含一个函数调用(比如上面的 Calculate),调试器会进入该函数的内部,停在函数的第一行。如果不包含函数,就只走下一行。
  • 使用场景
    • 一般当你怀疑 Calculate 函数内部有 Bug。
    • 你想看清楚这个函数具体是怎么一步步运算的。
    • 注意:如果你不小心对 printf 这种系统函数用了 F11,可能会跳进系统库的源码里(看不懂且没必要),这时候就需要用下面的“跳出”了。

2. 逐过程 (Step Over)

  • 快捷键F10
  • 含义遇到函数,直接跨过去(但会执行它)。
  • 动作:把整个函数调用当作一步。VS 会瞬间把 Calculate 函数跑完,然后黄色箭头停在下一行 printf 上。
  • 使用场景
    • 一般当你确信 Calculate 函数是没问题的(比如是系统自带的)。
    • 你只关心 Calculate 算出来的返回值对不对,不想看它里面怎么算的。
    • 用来快速浏览主流程逻辑。

3. 跳出 (Step Out)

  • 快捷键Shift + F11
  • 含义在这个函数里呆腻了,赶紧跑完回去。
  • 动作:瞬间执行完当前函数剩余的所有代码,然后返回到调用这个函数的地方(也就是父函数)。
  • 使用场景
    • 如果你不小心按 F11 进到了一个枯燥的函数(比如构造函数,或者标准库函数),想赶紧出来。
    • 或者你已经看出了当前函数的逻辑没问题,不想再一行行按了,想直接回到上一层继续调试。

总结对比

名称 快捷键 动作逻辑
逐语句 (Into) F11 最细颗粒度。遇函数就进,查个底朝天。
逐过程 (Over) F10 主流程优先。遇函数不进,直接拿结果。
跳出 (Out) Shift+F11 立即结束当前层。跑完当前函数,回到上一层。

还从网上学了一个超好用的神技:运行到光标处 (Run to Cursor)

  • 快捷键Ctrl + F10 (或者鼠标右键 -> 运行到光标处)
  • 场景
    你现在在第 10 行,你想看第 50 行的状态。
    • 如果一直按 F10,手都按酸了。
    • 你可以直接把鼠标点在第 50 行,按 Ctrl + F10
    • 程序会自动全速运行,直到撞上第 50 行才停下(相当于临时加了个断点并运行)。
    • 而且它完全支持跨方法、跨类、跨文件。只要程序的执行流在逻辑上能走到那一行,它就能在那停下来。
    • 另外:如果是鼠标流,Visual Studio 新版本引入了一个更直观的功能:当你把鼠标悬停在代码行号左侧时,会出现一个小小的绿色三角形图标(有时候叫 “Run to here” 图标)。点击这个绿色小箭头,效果和 Ctrl + F10 是一模一样的。这对于不喜欢记快捷键的人来说也很方便。

参考资料:
https://www.cnblogs.com/weizhixiang/p/6123211.html

cjson库版本不一致,导致解析失败

现象

在编译一个程序demo的时候,需要继承一个第三方库libexample.so,第三方库用到了cjson,本身这个程序也用到了cjson,由于两者用的cjson的版本不一致,导致json解析失败……

旧版本cjson

第三方库libexample.so使用的旧版本的cjson,cjson-types截图如下:
cjson_old_version.jpg

新版本cjson

程序demo使用的是新版本的cjson,cjson-types截图如下:
cjson_new_version.jpg

具体现象

用旧版本的cJSON源码编译到自己的代码里,编译出libexample.so库;
程序demo已经使用过新版本的cJSON源码,但是又连接了上面编译出来的libexample.so的库,再次进行json解析,会发现libexample.so里面解析cJSON_Number类型的节点的值会失败;
然后重新用新版本的cJSON源码编译出libexample.so库,再集成到上面的demo里面,即可解析成功。

分析

可以从上面两个不同版本的cjson源码截图的cjosn-types看出来:
这两个版本的cJSON Types的值不一样,比如cJSON_Number类型节点的值,旧版本的值是3, 新版本的值是8,
所以用旧版本编译的libexample.so库,集成到demo里的时候,解析到cJSON_Number节点的时候,错误的使用值8而不是3,所以导致解析失败

结论

代码里一定要保持一个版本的cjson;
版本混乱很容易造成奇怪的问题,而且这种问题往往还不容易排查!

背景

我的一个工程里的源文件就叫他a.cpp,在linux下可以正常的编译,由于工程需要移植到windos下,所以我把a.cpp源文件移动到windows下的工程里,然后编译,编译的时候,发现有两个特别诡异的报错,报错内容如下:

直接将cpp从Linux下挪到windows下报错

更为奇怪的是,如果我在windows操作系统下,将a.cpp文件,用sublime.txt打开,然后再全选内容,拷贝到vs到工程里报错的a.cpp文件里,然后发现可以成功编译!

原因

经过一系列脑壳疼的排查,最终发现,这是因为,linux下和windows下的换行符不一样!!!
如果用sublime.txt把报错的源文件a.cpp的所有\n替换成windows下的\r\n,然后再保存文件,把保存修改的文件,挪到vs里直接编译,发现阔以了!!!

vs是不支持把linux下的换行符主动转成windows下的换行符的!!!巨坑

总结

后来,查看了网友将linux和windows换行符的区别,讲的特别好
链接:https://blog.csdn.net/stpeace/article/details/45767245

讲解的特别精彩的截图片段
linux与windows换行符的区别

吐槽

不得不说,像换行符这种巨坑的问题,排查起来简直特别特别坑……