Kyle's Notebook

Java 程序员的 C++(译)

Word count: 3.5kReading time: 14 min
2021/03/17

原文链接:C++ for Java Programmers - Losing the Fear (scottlogic.com)

Java 程序员的 C++

本文试图介绍一些 C++ 基本的特征、区别,还有一些在首次使用这门语言时将会遇到的陷阱。

C++ 这门语言非常复杂,这里讲述的都是基础,也许是 Java 程序员在早期遇到的主要障碍。

创建对象

Java 程序员最初编写 C++ 代码时的经典例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>

class Thing {
public: // Defines everything until the next accessor
Thing(int val) : m_val(val) { // Uses the initialiser list, which runs before the constructor body
std::cout << "Create Thing " << m_val << "\n";
}

void doStuff() {
std::cout << "Doing Stuff " << m_val << "\n";
}

private:
int m_val;
};

int main()
{
Thing* thing = new Thing(5); // "Why do I have a pointer?"
(*thing).doStuff(); // Go away, pointer
delete thing; // I know I'm supposed to delete this, I hope I did it right
return 0; // 0 = EXIT_SUCCESS
}

上述是完全有效的代码,虽然可以正确执行,但却是非常糟糕的做法,而且也许还会变得更糟糕。

初学 C++ 的 Java 程序员往往会感到非常困惑,为什么他们在不需要指针的情况下还会有一个指针?并试图尽快摆脱它,结果导致代码发生了像筛子一样的泄漏内存;要么他们干脆忘记删除指针,要么过早地删除指针。

main 方法:

1
2
3
4
5
6
int main()
{
Thing thing(5); // Will be automatically deconstructed when out of scope
thing.doStuff();
return 0;
}

在 99% 的情况下这就是最必要的,没有指针、不需要删除,没有框架。

关于内存的分配有一些非常有趣的东西将在下文中深入探讨,但几乎在每一种情况下,想在这里发生的事情都会发生。

对象析构

每一种现代语言都有一个可以定义它自身的关键特征,以至于其他语言都试图将这个概念移植到自己身上,并取得不同程度的成功的效果。对于 Java 来说,这个特征可能是 JVM,对于 C# 来说,也许是 LINQ。而对于 C++ 来说,这个特征无疑就是析构器。

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
33
34
35
#include <iostream>

using namespace std;

class TestToDestruction {
int m_index;

public:
TestToDestruction(int value) : m_index(value) {
cout << "Created " << m_index << "\n";
}

~TestToDestruction() {
cout << "Destroyed " << m_index << "\n";
}
};

/*
Running this main method gives the following output:

Created 0
Created 1
Destroyed 1
Created 2
Destroyed 2
Created 3
Destroyed 3
Destroyed 0
*/
int main() {
TestToDestruction longlived(0); // Will live till the end of main
for(int i = 1; i <= 3; ++i) {
TestToDestruction shortlived(i); // Will live only until the } below
}
}

最初是为了简化内存管理。可以在构造函数中新建一个指针,在析构函数中删除。标准库的开发组花了很多时间将这种行为封装到一个特定的类中,甚至花了更长的时间来修复该类中的错误,但现在有了 std::unique_ptr 和 std::shared_ptr,使得一些 C++ 开发者在他们整个职业生涯中根本不用担心内存安全分配和删除的问题。

但是必须深思熟虑地分配和销毁套接字、文件句柄、数据库连接、互斥锁、会话等几乎所有有限的共享资源。假若在在应用程序中未能正确释放数据库连接,结果会很糟糕。

C# 有 Dispose 模式,而 Java 有 finalize 和 try-with-resources 都是有原因的。将资源的生命周期设置为特定对象的生命周期是解决很多问题的绝佳工具。直观感觉就是:如果我能掌握它,它就能工作;当它不能工作时,我就不能掌握它。但在垃圾收集语言中,不能真正控制一个对象的生命周期。垃圾收集器可以把它挂起,而当它把数据库锁死时就会出问题。

C++ 不是垃圾收集语言,它把“资源获取就是初始化”作为一个基本概念。事实上 RAII 是 C++ 开发人员在离开语言时最容易错过的东西。

但如果在这种情况下,想在不初始化和关闭不相干的副本时传递东西怎么办?只能使用(gulp)指针吗?

引用

如果说析构器是 C++ 带给所有其他语言的最重要概念,引用就是它带给 C 语言的最重要概念。

在许多方面引用是一个固定指针,C++ 的引用不能是 null**,必须在创建时为它分配一个可引用的东西,而且它将永远引用它,最常见的用途是防止对象不必要的复制。

虽然可以但也不应该创建空引用。

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
#include <iostream>

class Thing {
public:
Thing(int val) : m_val(val) {
std::cout << "Create Thing " << m_val << "\n";
}

// Copy Constructor
Thing(const Thing& thing) : m_val(thing.m_val) {
std::cout << "Copy Thing " << m_val << "\n";
}

~Thing() {
cout << "Destroy Thing " << m_val << "\n";
}

void doStuff() {
std::cout << "Doing Stuff " << m_val << "\n";
}

private:
int m_val;
};

void doStuffToThing(Thing thing) {
thing.doStuff();
}

void doStuffToReferenceToThing(Thing& thing) {
thing.doStuff();
}

int main() {
Thing thing1(1);
Thing thing2(2);
doStuffToThing(thing1);
doStuffToReferenceToThing(thing2);
}

// Create Thing 1
// Create Thing 2
// Copy Thing 1
// Doing Stuff 1
// Destroy Thing 1
// Doing Stuff 2
// Destroy Thing 2
// Destroy Thing 1

注意 thing1 被复制,而 thing2 没有被复制。在 C++ 中一个关键的原则是不要为不想使用的东西花费开销,所以避免偶然的拷贝是一个有价值的特性。

值得注意的是参数和引用之间的关系在 Java 中是很有趣的。在 Java 中所有的东西都是按值传递,所以都会被复制,但因为任何非简单类型都是引用,所以对原始对象做任何事情(例如重新分配)都不会影响传递进来的对象。

在 C++ 中可以通过引用传递,这意味着对参数所做的任何事情都是对原始对象做的,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>

void incrementIt(int& val)
{
++val;
}

void setToSeven(int& val)
{
val = 7;
}

int main()
{
int value = 0;
incrementIt(value);
std::cout << value << "\n"; // Displays 1
setToSeven(value);
std::cout << value << "\n"; // Displays 7
}

当然,在很多时候也许会想把一个对象传到函数中,并确保它不会扰乱自己的对象。

对于这一点,可以使用 const。

常量

对于我来说,const 是 C++ 语言中最有趣和最强大的部分之一。

它有点像 Java 中的 final,但还要强大得多。从根本上它允许 C++ 将不变性作为一个编译时的概念,这在 Java 中则是 NotImplementedException

一个简单的对象:

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#include <iostream>

class Thing {
public:
// A: constructor taking reference to const integer
Thing(const int& val) : m_val(val) {
}

// B: const function - can't change values of internal variables
void printValue() const {
std::cout << "Value is " << getValue() << "\n";
}

// C: const function returning const reference to member variable
const int& getValue() const {
return m_val;
}

// D: non-const function taking reference to const object
void setValue(const Thing& value) {
m_val = value.getValue();
}

private:
int m_val;
};

int main()
{
Thing thing1(1);
const Thing thing2(2);
const Thing thing3(3);

// Print original
thing1.printValue();
thing2.printValue();
thing3.printValue();

// Set items
thing1.setValue(thing2);
// thing3.setValue(thing2); // Will not compile

// Print updated
thing1.printValue();
thing2.printValue();
thing3.printValue();

return 0; // 0 = EXIT_SUCCESS
}

请注意 setValue 不是常量函数,因为给 m_val 分配了一个新值。

如果把 getValue 变成一个非常量函数:

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#include <iostream>

class Thing {
public:
// A: unchanged
Thing(const int& val) : m_val(val) {
}

// B: calls getValue(), can't be const
void printValue() {
std::cout << "Value is " << getValue() << "\n";
}

// C: non-const function can still return const reference to members
const int& getValue() {
return m_val;
}

// D: calling getValue on parameter, thus parameter can't be const
void setValue(Thing& value) {
m_val = value.getValue();
}

private:
int m_val;
};

int main()
{
Thing thing1(1);
Thing thing2(2); // Passed to non-const function, can't be const
Thing thing3(3); // Calls non-const functions, can't be const

// Print original
thing1.printValue();
thing2.printValue();
thing3.printValue();

// Set items
thing1.setValue(thing2);
thing3.setValue(thing2);

// Print updated
thing1.printValue();
thing2.printValue();
thing3.printValue();

return 0; // 0 = EXIT_SUCCESS
}

请注意一直到变量的声明常量必须完全解开。不能在一个常量对象上调用非常量函数,也不能将该对象作为非常量引用传递。

这使得它在多线程场景非常有用,因为如果在并行化系统中传递一个常量对象,它就是一个不可变的对象,可确定它不会改变。

也可以使用一个 const 引用到一个非 const 对象,但是需要更小心一点。显然仍然可以通过原始对象和任何非 const 引用来改变状态,但是 const 可以确认编码标准,即在编译时不这样做。

实际上可以故意选择使用 const_cast 将一个常量引用转换为非常量引用,但如无必要请不要这么做。

参考:When Should You Use const_cast?

向量

Almost always use std::vector

在 C/C++ 代码中,我最讨厌的东西是 *,然后就是 new 和一组方括号 [n]。这让我想起了一些糟糕的 bug 以及各种不理想的解决方案,现在我的程序即使通过编译,却出现了 seg-fault,或者像个筛子一样内存泄漏。

这就是 std::vector 存在的原因。

1
2
std::vector<MyClass> vec;
// Use the vector for various things

std::vector 是一种简洁、高效、标准的方式,用于创建一个可扩展、可调整大小的无序集合。

它通常是一个数组,一旦空间用完了就会被删除并作为一个更大的数组重新创建。就 Java 开发者而言它就是一个干净的 ArrayList 的等价物。

重载运算符

The C Family of Languages

Java Gems: Jewels from Java Report

1
2
3
4
5
Price p1(1);
Price p2(2);
Price p3 = p1 + p2;

cout << p1 << "+" << p2 << "=" << p3 << "\n";

这完全是有效的 C++ 代码,在功能上与 Java(或 C++)的同类代码没有区别。

1
2
3
4
Price p1 = new Price(1);
Price p2 = new Price(2);
Price p3 = p1.add(p2);
System.out.println(p1 + "+" + p2 + "=" + p3);

把任何运算符作为函数来处理是很整洁的做法。上面的 C++ 代码干净、清晰,而且更直观。

主要有语义上的问题;对于类来说,+ 或 / 或 << 意味着什么?此时就存在可能的滥用问题。一旦看到像 p << x + n ^ 2 这种(非数字对象的)语句,都必须猜测每个运算符在上下文中的含义,对这种做法产生偏见完全可以理解。

在很多情况下它可以用来产生清晰直观的代码,因此在写 Java 时这种语法糖常常令人怀念。

异常

C++ 有支持的异常规范,但请不要在 C++ 中使用异常规范。

C++ 没有受检、不受检异常的概念。如果包含一个异常规范,即使在函数中抛出了错误的异常,程序的编译也会顺利通过。然后当这个异常被抛出时它就会崩溃。

其中有一些有趣的原因,但不必关心它们。

所以干脆把所有的异常当作非受检异常:

永远不要写异常规范。

避免空异常

继承

Java 程序员在谈到 C++ 时,最纠结的是继承的方式,这可能是 Java 中最常见的架构工具,但在 C++ 中使用起来要尴尬得多。然而在某些方面这与其说是一个特点不如说是一个错误。

首先,在每种语言中继承都是被过度使用的,往往让开发变得更不方便,于是驱使别人使用组合(通常是一种更好的模式)。

其次,多态性本质上是低效的。要想知道真正使用的是哪个类,需要在运行时进行查询。在 C++ 中更容易受到这种影响,因为必须明确说明函数是可重写的。Java 走的是另一条路,它要求开发者明确地声明方法为 final。

如果对 Java 的对象的装箱拆箱特性感到满意,在很多方面这都是类似的形式。要获得继承的强大功能必须跳过一些圈套。不同的是由于对它的控制力更强,不会在类中自动继承一大块垃圾,可以在使用点上非常清楚地知道是否想参与继承。

注意 C++ 中的继承只通过指针或引用查找来工作。如果试图通过对基类的非引用来访问一个继承的类,就会得到 切片 的效果,即派生特性被简单地抹去了。Java 通过使每个对象都是引用来躲避这个问题,但是按照 C++ 的标准,这将是非常低效的(AFAIK,对于按照 Java 的标准来说它有多低效,人们意见不一)。

基于继承的代码指针负担往往很重,正如上面所说的,在函数参数的情况下(例如,将一个派生类传递到一个接受基类的函数中)是没有必要的。引用可以很好地工作,而且也不麻烦。

但是?

以上是一些 C++ 的核心概念,我认为 Java 程序员会发现它们出乎意料地有用,或者区别太大以至于令人讨厌。其中有很多东西是我刻意回避的,最明显的是几乎所有与指针有关的东西。

C++ 是一种有 30 年历史的语言,正在经历一个重新焕发活力的时期。类似但现代的语言(如 Rust)的存在所带来的压力将有助于推动它向好的方向发展。

上述内容应该有助于 Java 开发者顺利摆脱一些学习这门荒诞而迷人的语言的障碍。

CATALOG
  1. 1. Java 程序员的 C++
    1. 1.1. 创建对象
    2. 1.2. 对象析构
    3. 1.3. 引用
    4. 1.4. 常量
    5. 1.5. 向量
    6. 1.6. 重载运算符
    7. 1.7. 异常
    8. 1.8. 继承
    9. 1.9. 但是?