本幻灯片在CC BY 4.0下提供,转载请注明来自https://szp.io。本幻灯片包含大量的个人观点,对错误的内容造成的损失,概不负责。
This has led to innumerable errors, vulnerabilities, and system crashes, which have probably caused a billion dollars of pain and damage in the last forty years.
某些语言允许空值作为某些类型的一种特殊状态存在:
NULL
/nullptr
)null
)None
null
和undefined
空值带来了便利:没有值的时候可以先赋值为空;同时也是危险的:它不强制程序员检查非空,导致运行时才发现。
x = *p;
// do something
if (p == NULL) {
// and then do something
}
上面是一个简单的空值漏洞的例子:代码的顺序出现了错误,即先使用后检查。
如果能强制程序员检查空值,就能提醒它犯了这种错误。
(续)
如何解决空值的问题?
借助完善的类型系统:
例如:
std::optional
(C++17)替代空指针表示可空类型java.util.Optional
(由于允许对象类型可空,形同虚设)typing.Optional
(由于类型检查很弱,形同虚设)可空类型是如何解决这个问题的?
检查之后再断言非空(传统的过程式方式,C/C++、Java):
Optional<X> x = ...;
if (x.isPresent()) {
y = x.get();
}
使用模式匹配(Haskell/Rust),避免了断言:
Maybe a = Just a | Nothing
f x = case x of Just a => ...
Nothing => ...
函数式地进行映射、且、或之类的运算(Haskell/Rust/Java):
> fmap (+ 1) (Just 1) -- Maybe, it's a functor!
Just 2
未初始化与空值类似,是一个正常类型不该有的状态,解决方案:
一些语言采用一些特殊的值初始化了对象(如Java的null
),这叫饮鸩止渴。
下面是C语言的一个使用未初始化数据的例子。这是由于忘记了赋予初值,这种错误通常很容易犯。
int i, counter;
for(i = 0; i < 10; ++i)
counter += i;
printf("%d\n", counter)
整数溢出很可能是程序异常的行为。为了避免这种情况:
clang -fsanitize=undefined
),Rust debug模式$ cat test.c
int main(int argc, char *argv[]) { return 0x7fffffff + argc; }
$ clang -fsanitize=undefined test.c
$ ./a.out
test.c:1:54: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior test.c:1:54 in
不再需要的资源(如堆内存、文件、锁)未释放,就会造成泄露。为了避免这种情况:
对于使用垃圾回收的语言,诸如文件之类的资源是需要手动释放的。于是就有了:
AutoClosable
)、Python(__enter__
,__exit__
),需要手动调用close()
或使用try-with-resources语句RAII是资源获得即初始化的简写,指对象的生命周期与资源的获取释放完全一致。这是异常安全的。下面的代码不需要显示释放锁和文件。
void WriteToFile(const std::string& message) {
static std::mutex mutex;
std::lock_guard<std::mutex> lock(mutex);
std::ofstream file("example.txt");
if (!file.is_open())
throw std::runtime_error("unable to open file");
file << message << std::endl;
}
对于C++、Rust之类的语言,可以通过计数有多少引用,来自动释放不再被引用的内存。循环引用对此是致命的,示例如下,所以就引入了弱引用。
struct Person { std::shared_ptr<Person> partner; };
auto lucy = std::make_shared<Person>();
auto ricky = std::make_shared<Person>();
lucy->partner = ricky;
ricky->partner = lucy;
主流的垃圾回收方式是使用遍历判断可达性,可以有效解决循环引用问题。
异常安全,是指当异常发生时:
即使是在实践了RAII的语言中,这也是一件很困难的事情:
int a = new int;
foo();
delete a;
上面的代码就会在foo()
跑出异常时造成内存泄露。所以在C++中全面使用智能指针是很有必要的。
对于不能实践RAII的垃圾回收语言,通过try-finally来释放资源很容易有遗漏,所以就有了try-with-resources语句。如Python:
with open("lol") as f:
print(f.read())
数据竞争竞争是并发中很容易出现的问题,加锁可以解决这个问题,但谁能确保不忘呢。为了避免这种情况:
数据竞争的条件:
Rust只允许一个时刻持有:
以上内容是编译时检查的,这对编译器的静态分析提出了更高的要求,也限制了一些可能正确的程序。
编程语言有如下的分类:
总的来说,动态语言很容易写出运行时才会报错的代码,所以静态比强是更重要的。当然又强又静态是最好的。
下面是一个典型的动态语言Bug,这在静态语言是不会出现的。相信也有不少的人遇到过,Python写了个神经网络,跑了几小时后,崩在保存模型的代码上。
a = [1, 2, 3]
print('.'.join(a))
JavaScript是一门神一般的语言,举几个栗子:
[] == ![]; // -> true
[6, -2, 2, -7].sort() // -> [-2, -7, 2, 6]
null == 0; // -> false
null > 0; // -> false
null >= 0; // -> true
早期Java尚不支持泛型,为了支持对数组的通用操作,引入了数组协变:如果A
是B
的子类型,那么A[]
也是B[]
的子类型。然而这又是一个引来麻烦的设计:
String[] strings = new String[1];
Object[] objects = strings;
objects[0] = 12;
这是运行时错误,所以现代语言大多禁止了数组及其他容器的协变。
通过泛型机制,在保证代码正确的基础上,复用代码。我个人将支持泛型的语言分为两类:
C++在20版本中也引入了Concept,但历史包袱很重。现代语言大多采用有约束泛型。
这里包含一些可能过于琐碎和深奥的章节。
程序分析通常都是不是万能的。例如,loop
和while true
在rust中是基本等价的,只有下面的不同:
let x;
loop { x = 1; break; }
println!("{}", x)
let x;
while true { x = 1; break; }
println!("{}", x)
这是因为编译器不认为while
循环一定会被执行一次。
考虑下面的C++代码:
foo(std::unique_ptr<X>(new X), std::unique_ptr<Y>(new Y))
由于C++中表达式的求值顺序不定。假设求值顺序是:先执行new X
,再执行new Y
,最后执行两个unique_ptr
的构造函数。此时,如果Y
的构造函数抛出了异常,就会出现内存泄漏。所以上述代码应当改为:
foo(std::make_unique<X>(), std::make_unique<Y>())
考虑一个C++某类的拷贝赋值函数设计方式:
Person& operator=(const Person& that)
{
if (this != &that) {
delete[] name;
name = new char[std::strlen(that.name) + 1];
std::strcpy(name, that.name);
age = that.age;
}
return *this;
}
当new
抛出异常,this
所指对象被析构时,就会出现重复释放内存。在拷贝对象时,先销毁旧状态再复制新状态,通常很危险。
(续)
使用Copy-And-Swap模式就能解决这个问题,这需要类实现拷贝构造函数和移动构造函数,后者在C++中不应该抛出异常:
Person& operator=(Person that)
{
std::swap(*this, that);
return *this;
}