d系统编程内存安全
系统语言中的内存安全第3部分
本系列中的第一篇,演示如何使用新的DIP1000
规则让切片和指针
内存安全的引用栈
.本系列的第二篇介绍了,ref
存储类及DIP1000
如何与(类,构和联
等)聚集类型
一起工作.
中文第1篇
中文第2篇
目前,该系列故意避开模板
和自动
功能.这使得前两篇文章更简单,因为不必处理推导函数属性
,我叫"自动推导
属性".
但是,在D代码中,自动
函数和模板
都很常见,因此如果不解释这些特征
如何与语言
更改配合
使用,DIP1000
系列就不完整.推导函数属性
是避免所谓的"属性汤
"的最重要工具,“属性汤
”:函数用多个
属性修饰,降低了可读性
.
还将深入挖掘
不安全代码.本系列的前两篇
文章侧重于scope
属性,但本篇更侧重于属性和内存安全
.由于DIP1000
最终是关于内存安全
的,因此无法绕过.
避免重复属性
推导函数属性表明,语言
会分析函数的主体,并在适用
时自动添加@safe,pure,nothrow
和@nogc
属性.它还试添加域(scope)
或中域(return scope)
属性到参数,及添加return ref
到的ref
(不加,不会编译它)参数.
某些属性
永远不会推导
.如,编译器不会插入ref,lazy,out
或@trusted
属性,因为有时明确
不需要它们
.
有多种
方法可打开推导函数属性
.一是在函数签名
中省略返回类型
.注意,auto
关键字不需要它.auto
是未指定返回类型,存储类或属性
时使用的占位符关键字
.
如,不解析:
half(int x) { return x/2; }
所以用
auto half(int x) { return x/2; }
替代.但是也可这样:
@safe half(int x) { return x/2; }
就像使用了auto
关键字一样,会推导
出来其余的(pure,nothrow
和@nogc
)属性.
第二种启用推导属性
方法是模板函数
.用半
示例,可如下完成:
int divide(int denominator)(int x) { return x/denominator; }
alias half = divide!2;
D规范没有说模板
必须有参数
.空参数
列表可打开推导属性:
int half()(int x) { return x/2; }
甚至不需要调用点
的模板实例化语法
,就可调用此函数
,如,不需要half!()(12)
,因为half(12)
就可编译.
在另一个
函数中存储函数
也可启用推导属性
.这些函数叫嵌套
函数,这里.不仅,在另一个函数中直接嵌套
的函数上启用
推导,而且,在函数内的类型或模板
中嵌套
的大多数内容
上也启用推导
.例:
@safe void parentFun()
{
// 这是自动推导
int half(int x){ return x/2; }
class NestedType
{
// 这是自动推导
final int half1(int x) { return x/2; }
//这不是自动推导的;这是虚函数,在继承类中,编译器不知道是否有`不安全`覆盖
int half2(int x) { return x/2; }
}
int a = half(12); // 按@safe推导,工作.
auto cl = new NestedType;
int b = cl.half1(18); // 按@safe推导,工作.
int c = cl.half2(26); // 错误.
}
嵌套函数
的缺点是,它们只能按词法顺序
使用(调用点
必须在函数声明
下方),除非嵌套函数
和调用
都在父函数内的同一构,类,联或模板
中,或依次都在父函数
中.另一个缺点是不适合统一函数调用语法
,ufcs.
最后,总是为函数字面(也叫λ
函数)启用推导
属性.
可这样定义减半
函数:
enum half = (int x) => x/2;
并完全如常
调用.但是,该语言不按函数
对待它.而是按函数指针
.表明,在全局域内,使用枚举(enum)
或不变(immutable)
而不是动
很重要.否则,可从程序中其他位置更改λ
,且纯函数
无法访问它.
极少数时,该可变性
可能是可取
的,但一般,它是反模式
(如一般
的全局变量
).
推导的局限
最少手动输入
或最大属性膨胀
都不是明智
的目标.
自动推导
的主要问题是代码中的细微更改
,可能不受控制
的打开和关闭推导属性
.要了解何时
重要,及会推导
出什么.
编译器一般会尽量推导@safe,pure,nothrow
和@nogc
属性.如果函数可
有这些,它几乎总是
会有.规范
说递归
是异常
:除非明确指定调用
自身的函数
不应是@safe,pure,nothrow
.但是在测试
中,我发现对递归
函数,可推导这些属性.表明,人们正在努力使推导递归
属性工作,并且它已部分工作.
在函数参数
上推导scope
和return
不太可靠.一般,它会起作用,但编译器
很快就会放弃.推导
引擎越智能
,编译
所需时间越多
,因此当前设计策略
是仅在最简单
时才推导
这些属性.
编译器在哪推导?
D
应养成此习惯:“如果错误地使函数
,不安全,不纯,抛,垃集或逃逸
,会怎样
?如果答案是"立即发出编译器错误
”,则自动推导
没问题.另一方面,如果答案是"更新我正在维护的该库
时,会破坏
用户代码".此时,请手动加注解
.
除了可能丧失作者
准备应用的属性
外,还有另一个风险:
@safe pure nothrow @nogc firstNewline(string from)
{
foreach(i; 0 .. from.length) switch(from[i])
{
case '\r':
if(from.length > i+1 && from[i+1] == '\n')
{
return "\r\n";
}
else return "\r";
case '\n': return "\n";
default: break;
}
return "";
}
你可能认为
,由于是作者
手动指定
属性的,因此不会有问题.可惜,这是错误
的.假设作者决定按所有返回值
都是from
参数的切片
而不是串字面
来重写
函数:
@safe pure nothrow @nogc firstNewline(string from)
{
foreach(i; 0 .. from.length) switch(from[i])
{
case '\r':
if (from.length > i + 1 && from[i + 1] == '\n')
{
return from[i .. i + 2];
}
else return from[i .. i + 1];
case '\n': return from[i .. i + 1];
default: break;
}
return "";
}
奇怪
!以前按域
推导from
参数,库用户
依赖它,但现在按中域
推导它,从而破坏
了客户代码.
但,对内部函数
,自动推导
是节省手指
及方便阅读的好方法.注意,只要在@safe
函数或单元测试
中,显式使用
该函数,就可依赖@safe
属性的自动推导
.
在自动推导函数
中,如果做了些潜在不安全
事情,它会按@system
而不是@trusted
推导.从@safe
函数调用@system
函数会发出编译器错误
,表明此时可安全
依赖自动推导
.
对内部函数
,有时手动指定
属性仍然
有意义,因为违反
这些属性时,手动
属性会生成更好
的错误消息.
模板呢
总是对模板函数
启用自动推导
.如果库接口
需要公开一个
怎么办?尽管很丑陋,可阻止
推导:
private template FunContainer(T)
{
// 未自动推导
// (仅同名模板函数是)
@safe T fun(T arg){return arg + 3;}
}
//自动推导自身,但由于调用函数不是,只推导`@safe`
auto addThree(T)(T arg){return FunContainer!T.fun(arg);}
但是,模板一般根据编译时参数
决定应有哪些属性
.可用元编程
按模板参数
指定属性
,但工作量太大
,难以阅读,且很容易像依赖自动推导
一样易出错
.
更实用
方法是,测试函数模板
是否推导出期望属性
.每次更改
函数时,此不必也不应手动测试
它.相反:
float multiplyResults(alias fun)(float[] arr)
if (is(typeof(fun(new float)) : float))
{
float result = 1.0f;
foreach (ref e; arr) result *= fun(&e);
return result;
}
@safe pure nothrow unittest
{
float fun(float* x){return *x+1;}
//用静态数组可确保按`域或中域`推导`arr`参数
float[5] elements = [1.0f, 2.0f, 3.0f, 4.0f, 5.0f];
//无需实际处理结果.想法是,既然可编译,就证明乘法结果是`@safe,pure,nothrow`,且参数是`域或中域.`
multiplyResults!fun(elements);
}
感谢D的编译时自省
能力,还测试了不需要
的属性
:
@safe unittest
{
import std.traits : attr = FunctionAttribute,functionAttributes, isSafe;
float fun(float* x)
{
//使函数依赖抛和垃集
if (*x > 5) throw new Exception("");
static float* impureVar;
// 使函数不纯
auto result = impureVar? *impureVar: 5;
// 使参数非域
impureVar = x;
return result;
}
enum attrs = functionAttributes!(multiplyResults!fun);
assert(!(attrs & attr.nothrow_));
assert(!(attrs & attr.nogc));
//检查是否接受域参数.注意,此检查,对`@system`函数不管用.
assert(!isSafe!(
{
float[5] stackFloats;
multiplyResults!fun(stackFloats[]);
}));
//如果测试函数有属性错误,最好用阳性测试等类似方法确保上述测试会失败
assert(attrs & attr.safe);
assert(isSafe!(
{
float[] heapFloats;
multiplyResults!fun(heapFloats[]);
}));
}
在运行单元测试
之前,如果编译时
想要断定
失败,则,在每个断定
前添加static
关键字就行.通过转换单元测试
为普通函数
,甚至可在非单元测试
构建中显示这些编译器错误
,如用
private @safe testAttrs()
//替换
@safe unittest
//.
实弹演习:@system
记住D是一种系统语言
.在大多数D代码
中,可很好
地避免内存错误
,但如果禁止D
按与C
或C++
相同进入低级
并绕过类型系统
,那么就不是D
了:指针
位算术,直接读写
硬件端口,在原始字节块
析构等,D
旨在完成
所有这些工作.
不同于,C和C++
中的,只要一个错误
就可破坏类型
系统,并任意位置都可能使未定义行为.D
只有在不在@safe
函数中,或使用(如-release
或-check=assert=off
等)危险
的编译器开关
时才会有危险(禁止
断定失败是未定义行为(ub)
),即使这样,语义也不太容易UB
.如:
float cube(float arg)
{
float result;
result *= arg;
result *= arg;
return result;
}
这是语言无关
函数,可用C,C++
和D
编译.有人准备计算arg
的立方,但忘记用arg
初化结果.在D中,尽管这是@system
函数,但不会有危险
.没有初化值
表明,结果
(result
)默认
初化为NaN
(非数字),这使结果
也是NaN
,这是第一次
使用此函数
时的明显"错误"
值.
但是,在C
和C++
中,不初化
局部变量表明读取它
是(无较小异常)未定义行为
.函数
甚至不处理指针,但根据标准,就像如下,调用该函数
,
*(int*) rand() = 0XDEADBEEF;
一切都是由于很简单
的错误.虽然许多启用了警告
的编译器
都会抓此警告,但并非
所有编译器都会抓此警告
,并且这些语言有大量类似示例
,即使有警告
也没用.
在D中,即使显式用如下
float result = void;
来空初化
,也只表明
未定义函数返回值
,而不会调用
函数.因此,即使用此初化器
,也可用@safe
注解函数.
尽管如此,对关心内存安全
的人来说,假设D
的@system
代码足够
安全到可为默认
模式,也是不对的.两个示例会演示可能情况.
未定义行为可做什么
有人认为"未定义行为"
,仅表明"错误行为"
或运行时崩溃
.虽然一般是这样
,但未定义行为
比未抓的异常
或无限循环
危险得多.不同在,你根本
无法保证未定义行为
会怎样.听起来可能并不比
无限循环更糟糕,但第一次
进入时,就会发现意外
的无限循环
.
另一方面,具有未定义行为
代码,测试时,可能会执行期望操作
,但生产中
执行完全不同操作
.在生产环境
中,即使,用与测试相同
标志编译
,行为也可能从A
编译器版本更改为B
编译器版本,或造成代码
完全不相关
的更改.示例:
//返回异常自身是否在数组中
bool replaceExceptions(Object[] arr, ref Exception e)
{
bool result;
foreach (ref o; arr)
{
if (&o is &e) result = true;
if (cast(Exception) o) o = e;
}
return result;
}
这里想法是,该函数用e
替换数组
中的所有异常
.如果e
自身在数组中,则返回真
,否则返回假
.事实上,测试证实
它有效.如下使用该函数
:
auto arr = [new Exception("a"), null, null, new Exception("c")];
auto result = replaceExceptions
(
cast(Object[]) arr,
arr[3]
);
转换
不是问题
吧?无论对象引用
类型如何
,它们总是
具有相同大小
,转换
异常为Object
父类型.它不像包含对象引用
以外的内容数组
.
可惜,D
规范(这里)不是这样看待
它的.相同内存位置中,有不同类型
的两个类引用
(或其它引用),然后赋值
其中一个
给另一个
,这是未定义行为
.这正是在
if (cast(Exception) o) o = e;
这里的本质.
如果数组
确实包含e参数
.由于仅在触发
未定义行为时才能返回真
,表明编译器
可自由优化replaceExceptions
,来总是返回假
.这是休眠
错误,测试中无法发现,但几年
后,使用高级编译器的强大优化
编译时,会完全
搞砸应用.
要求转换
来使用函数
似乎是明显
的警告信号
,好的D
不会忽视.我不太确定.即使在精细
的高级代码中,转换
也并不罕见.即使不同意,其他
情况也可咬人.去年夏天,在D论坛
上出现了该案例:
string foo(in string s)
{
return s;
}
void main()
{
import std.stdio;
string[] result;
foreach(c; "hello")
{
result ~= foo([c]);
}
writeln(result);
}
史蒂文
遇见了该问题,他是位长期的D老兵
,他自己不止一次讲过@safe
和@system
.这里和这里.能烧死
他的东西都可烧死
人.
一般,它就像人们想象
的那样
工作,并且根据规范
很好.但是,如果使用-preview=in
(将成为语言默认功能)的编译器开关,这里,并和DIP1000
一起启用,则程序
开始出现故障
.in
的旧语义与const
相同,但新语义
使其成为常域
.
由于foo
的参数是域(scope)
,在返回
它,或返回
其他东西前,编译器假定foo
会复制[c]
.
因此在相同栈位置
上,它为"hello"
的每个字母
分配[c]
.结果是程序
打印["o","o","o","o","o"]
.至少对我,已很难
理解该简单
示例中了什么.在复杂
代码基中找该错误,可能是一场噩梦
.
这两个
示例中的基本问题
是相同的:未用@safe
.如果用了,这两种
未定义行为都会使编译错误
(可replaceExceptions
函数自身可加上@safe
,但在使用点
不能转换).到现在
为止,应该很清楚应该
谨慎使用@system
代码.
何时前进
然而,迟早有一天,必须暂时
降低护栏.下面是很好示例
:
//未定义行为:传递非空针,给除了`"\0"`以外的独立符,或如`utf8Stringz`,在`指向符`位置或之后,给不带`\0`的数组,
extern(C) @system pure
bool phobosValidateUTF8(const char* utf8Stringz)
{
import std.string, std.utf;
try utf8Stringz.fromStringz.validate();
catch (UTFException) return false;
return true;
}
此函数,使用Phobos
来验证UTF-8
串,用另一种语言编写的代码.C
是C
,它喜欢用以零
结尾的串,因此该函数按参数接受
指针,而不是D数组
.
因此该功能必然是不安全
的.无法安全检查utf8Stringz
是否指向null
或有效的C串
.如果指向的符不是"\0"
,表明必须读下个符
,则该函数
无法知道该符
是否属于为串
分配的内存
.它只能相信调用代码
是正确的.
尽管如此,此函数
,正确使用了@system
属性.
首先,主要从C或C++
调用它.这些语言
基本不保证安全
.即使@safe
函数,也只有在它仅取可在D的@安全
代码中创建的那些参数
时,才是安全的.
无论属性
说什么,按参数传递
cast(const char*) 0xFE0DA1
给函数
都是不安全
的,且C
或C++
不验证
传递的参数.
其次,该函数
清楚地记录了会触发
未定义行为的情况.但是,它没有提到,传递无效指针
(如前的cast(const char*)0xFE0DA1
)是UB
,因为除非可另外显示,UB
总是默认假定仅具有@system
值.
第三,函数小且易于人工
审核.函数都不应
是不必要
的大函数
,但保持@system
和@trusted
函数小且易于审查
比平时
重要很多倍.
测试可调试@safe
函数到相当好
形式,但正如之前看到的,未定义行为
可不受测试
影响.分析代码
是UB
的唯一通用答案
.
参数
没有域
属性是有原因
的.可
有它,则不会逃逸
串指针.但是,好处不大.调用该函数
的代码都必须是@system,@trusted
或其他语言,表明总是可传递栈指针
.如果错误
重构此函数,域
可能会提高D客户
代码性能
,以换取增加未定义行为
的可能.
除非可证明该属性
帮助解决性能
问题,一般不需要
该平衡.另一方面,该属性使读者
更清楚,不应逃逸串.很难判断在此域
是否是明智
的.
进一步改进
不明显
时,应该记录
为什么@system
函数是@system
的.一般有更安全
替代方案,示例
函数可用D数组
,或本系列上一篇文章中的CString
构.为什么不采取替代方案?示例中,可写,对每个选项,ABI
会有所不同,在C端,增加复杂性,且(C
代码)总是不安全
的.
除了可从@safe
函数调用它们,@trusted
函数类似@system
函数,而不能调用@system
函数.声明为@trusted
时,表明与实际的@safe
函数一样,作者
已验证了,可用在安全代码
中创建的参数使用该函数
.需要像@system
函数一样
(或更
)仔细审查它们.
此时,应记录
(对其他开发人员
,而不是用户
),该所有时,该函数如何
是安全的.或,如果该函数
用起来并不完全安全
,且该属性
只是临时侵改
,那么应该有很大
的丑陋警告
.
在较大的@safe
函数中,定义一个小的@trusted
函数来执行不安全
的操作,从而不必禁止检查
整个函数是很诱人
的:
extern(C) @safe pure
bool phobosValidateUTF8(const char* utf8Stringz)
{
import std.string, std.utf;
try (() @trusted => utf8Stringz.fromStringz)().validate();//这里
catch (UTFException) return false;
return true;
}
但记住,要像显性@trusted
函数一样记录和审查
,父函数
,因为封装的@trusted
函数可让父函数
执行任意操作
.此外,由于按@safe
标记该函数
,乍一看,并不是需要特别注意
的函数.因此,如果选择这样用@trusted
,则需要可见
的警告注解
.
最重要的是,不要相信
自己!就像任意非平凡
大小的代码基
都有错误
一样,有时,超过10个@system
函数,就会包含潜在
的UB
.
应积极
使用D
的其余增强
功能(即断定,合约,不变量和检查边界
),并在生产
中保持启用
.即使程序完全@safe
,也建议
这样.此外,对有大量不安全
代码项目,应尽量使用如LLVM
地址清理器和Valgrind
等外部工具.
请注意,许多这些增强
工具(包括语言
和外部
工具中的加固工具
)的思想是,一旦检测到故障
就崩溃.它减少
了未定义行为
,造成更严重损害
的机会.
要求按随时接受崩溃
设计程序.程序绝不能持有大量
未保存数据,以至于崩溃时都丢失了.如果控制
重要资源,必须可在重启用户或其他进程
后,重新
获得控制权
,或必须有另一个备份程序
.在系统
编程中不能信任,不能承受
崩溃检查
的程序
.
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列01:轻松3步本地部署deepseek,普通电脑可用
· 25岁的心里话
· 按钮权限的设计及实现