当前位置: 首页 > news >正文

揭秘指针魔法,让你的编程之旅如虎添翼!‍♂️✨

1.1 指针

Zig 不包含垃圾回收器。管理内存的重任由开发者负责。这是一项重大责任,因为它直接影响到应用程序的性能、稳定性和安全性。

我们将从指针开始讨论,这本身就是一个重要的话题,同时也是训练我们从面向内存的角度来看待程序数据的开始。

下面的代码创建了一个 power 为 100 的用户,然后调用 levelUp 函数将用户的 power 加一。你能猜到它的输出结果吗?

const std = @import("std");pub fn main() void {var user = User{.id = 1,.power = 100,};// this line has been addedlevelUp(user);std.debug.print("User {d} has power of {d}\n", .{user.id, user.power});
}fn levelUp(user: User) void {user.power += 1;
}pub const User = struct {id: u64,power: i32,
};

这是一个小把戏;代码无法编译:不能赋值给常量。我们在第一部分中看到函数参数是常量,因此 user.power += 1 是无效的。为了解决这个错误,我们可以将 levelUp 函数改为

fn levelUp(user: User) void {var u = user;u.power += 1;
}

虽然编译成功了,但输出结果却是User 1 has power of 100,而我们代码的目的显然是让 levelUp 将用户的 power 提升到 101。这是怎么回事?

要理解这一点,我们可以将数据与内存联系起来,而变量只是将类型与特定内存位置关联起来的标签。例如,在 main 中,我们创建了一个User。内存中数据的简单可视化表示如下

user -> ------------ (id)|    1     |------------ (power)|   100    |------------

有两点需要注意:

  1. 我们的user变量指向结构的起点
  2. 字段是按顺序排列的

请记住,我们的user也有一个类型。该类型告诉我们 id 是一个 64 位整数,power 是一个 32 位整数。有了对数据起始位置的引用和类型,编译器就可以将 user.power 转换为:访问位置在结构体第 64 位上的一个 32 位整数。这就是变量的威力,它们可以引用内存,并包含以有意义的方式理解和操作内存所需的类型信息。

下面是一个稍有不同的可视化效果,其中包括内存地址。这些数据的起始内存地址是我想出来的一个随机地址。这是user变量引用的内存地址,也是第一个字段 id 的值所在的位置。由于 id 是一个 64 位整数,需要 8 字节内存。因此,power 必须位于 $start_address + 8 上:

user ->   ------------  (id: 1043368d0)|    1     |------------  (power: 1043368d8)|   100    |------------

为了验证这一点,我想介绍一下取地址符运算符:&。顾名思义,取地址运算符返回一个变量的地址(它也可以返回一个函数的地址,是不是很神奇?)保留现有的 User 定义,试试下面的代码:

pub fn main() void {var user = User{.id = 1,.power = 100,};std.debug.print("{*}\n{*}\n{*}\n", .{&user, &user.id, &user.power});
}

这段代码输出了useruser.id、和user.power的地址。根据平台等差异,可能会得到不同的输出结果,但都会看到useruser.id的地址相同,而user.power的地址偏移量了 8 个字节。输出的结果如下:

learning.User@1043368d0
u64@1043368d0
i32@1043368d8

取地址运算符返回一个指向值的指针。指向值的指针是一种特殊的类型。类型T的值的地址是*T。因此,如果我们获取 user 的地址,就会得到一个 *User,即一个指向 User 的指针:

pub fn main() void {var user = User{.id = 1,.power = 100,};const user_p = &user;std.debug.print("{any}\n", .{@TypeOf(user_p)});
}

我们最初的目标是通过levelUp函数将用户的power值增加 1 。我们已经让代码编译通过,但当我们打印power时,它仍然是原始值。虽然有点跳跃,但让我们修改代码,在 main 和 levelUp 中打印 user的地址:

pub fn main() void {const user = User{.id = 1,.power = 100,};// added thisstd.debug.print("main: {*}\n", .{&user});levelUp(user);std.debug.print("User {d} has power of {d}\n", .{user.id, user.power});
}fn levelUp(user: User) void {// add thisstd.debug.print("levelUp: {*}\n", .{&user});var u = user;u.power += 1;
}

如果运行这个程序,会得到两个不同的地址。这意味着在 levelUp 中被修改的 user与 main 中的user是不同的。这是因为 Zig 传递了一个值的副本。这似乎是一个奇怪的默认行为,但它的好处之一是,函数的调用者可以确保函数不会修改参数(因为它不能)。在很多情况下,有这样的保证是件好事。当然,有时我们希望函数能修改参数,比如 levelUp。为此,我们需要 levelUp 作用于 main 中 user,而不是其副本。我们可以通过向函数传递 user的地址来实现这一点:

const std = @import("std");pub fn main() void {var user = User{.id = 1,.power = 100,};// user -> &userlevelUp(&user);std.debug.print("User {d} has power of {d}\n", .{user.id, user.power});
}// User -> *User
fn levelUp(user: *User) void {user.power += 1;
}pub const User = struct {id: u64,power: i32,
};

我们必须做两处改动。首先是用 user 的地址(即 &user )来调用 levelUp,而不是 user。这意味着我们的函数参数不再是 User,取而代之的是一个 *User,这是我们的第二处改动。

现在,代码已按预期运行。虽然在函数参数和内存模型方面仍有许多微妙之处,但我们正在取得进展。现在也许是一个好时机来说明一下,除了特定的语法之外,这些都不是 Zig 所独有的。我们在这里探索的模型是最常见的,有些语言可能只是向开发者隐藏了很多细节,因此也就隐藏了灵活性。

1.2 方法

一般来说,我们会把 levelUp 写成 User结构的一个方法:

pub const User = struct {id: u64,power: i32,fn levelUp(user: *User) void {user.power += 1;}
};

这就引出了一个问题:我们如何调用带有指针参数的方法?也许我们必须这样做:&user.levelUp()?实际上,只需正常调用即可,即 user.levelUp()。Zig 知道该方法需要一个指针,因此会正确地传递值(通过引用传递)。

1.3 常量函数参数

在默认情况下,Zig 会传递一个值的副本(称为 "按值传递")。很快我们就会发现,实际情况要更微妙一些。

即使坚持使用简单类型,事实也是 Zig 可以随心所欲地传递参数,只要它能保证代码的意图不受影响。在我们最初的 levelUp 中,参数是一个User,Zig 可以传递用户的副本或对 main.user 的引用,只要它能保证函数不会对其进行更改即可。(我知道我们最终确实希望它被改变,但通过采用 User 类型,我们告诉编译器我们不希望它被改变)。

这种自由度允许 Zig 根据参数类型使用最优策略。像 User 这样的小类型可以通过值传递(即复制),成本较低。较大的类型通过引用传递可能更便宜。只要代码的意图得以保留,Zig 可以使用任何方法。在某种程度上,使用常量函数参数可以做到这一点。

现在你知道函数参数是常量的原因之一了吧。

1.4 指向指针的指针

我们之前查看了main函数中 user 的内存结构。现在我们改变了 levelUp,那么它的内存会是什么样的呢?

main:
user -> ------------  (id: 1043368d0)  <---|    1     |                      |------------  (power: 1043368d8)  ||   100    |                      |------------                      ||.............  empty space        |.............  or other data      ||
levelUp:                                  |
user -> -------------  (*User)            || 1043368d0 |-----------------------------------

在 levelUp 中,user 是指向 User 的指针。它的值是一个地址。当然不是任何地址,而是 main.user 的地址。值得明确的是,levelUp 中的 user 变量代表一个具体的值。这个值恰好是一个地址。而且,它不仅仅是一个地址,还是一个类型,即 *User。这一切都非常一致,不管我们讨论的是不是指针:变量将类型信息与地址联系在一起。指针的唯一特殊之处在于,当我们使用点语法时,例如 user.power,Zig 知道 user 是一个指针,就会自动跟随地址。

重要的是要理解,levelUp函数中的user变量本身存在于内存中的某个地址。就像之前所做的一样,我们可以亲自验证这一点:

fn levelUp(user: *User) void {std.debug.print("{*}\n{*}\n", .{&user, user});user.power += 1;
}

上面打印了user变量引用的地址及其值,这个值就是main函数中的user的地址。

如果user的类型是*User,那么&user呢?它的类型是**User, 或者说是一个指向User指针的指针。我可以一直这样做,直到内存溢出!

1.5 嵌套指针

到目前为止,User 一直很简单,只包含两个整数。很容易就能想象出它的内存,而且当我们谈论『复制』 时,也不会有任何歧义。但是,如果 User 变得更加复杂并包含一个指针,会发生什么情况呢?

pub const User = struct {id: u64,power: i32,name: []const u8,
};

我们已经添加了name,它是一个切片。回想一下,切片由长度和指针组成。如果我们使用名字Goku初始化user,它在内存中会是什么样子?

user -> -------------  (id: 1043368d0)|     1     |-------------  (power: 1043368d8)|    100    |-------------  (name.len: 1043368dc)|     4     |-------------  (name.ptr: 1043368e4)------| 1182145c0 ||     -------------||     .............  empty space|     .............  or other data|--->  -------------  (1182145c0)|    'G'    |-------------|    'o'    |-------------|    'k'    |-------------|    'u'    |-------------

新的name字段是一个切片,由lenptr字段组成。它们与所有其他字段一起按顺序排放。在 64 位平台上,lenptr都将是 64 位,即 8 字节。有趣的是name.ptr的值:它是指向内存中其他位置的地址。

通过多层嵌套,类型可以变得比这复杂得多。但无论简单还是复杂,它们的行为都是一样的。具体来说,如果我们回到原来的代码,levelUp 接收一个普通的 User,Zig 提供一个副本,那么现在有了嵌套指针后,情况会怎样呢?答案是只会进行浅拷贝。或者像有些人说的那样,只拷贝了变量可立即寻址的内存。这样看来,levelUp 可能只会得到一个 user 残缺副本,name 字段可能是无效的。但请记住,像 user.name.ptr 这样的指针是一个值,而这个值是一个地址。它的副本仍然是相同的地址:

main: user ->    -------------  (id: 1043368d0)|     1     |-------------  (power: 1043368d8)|    100    |-------------  (name.len: 1043368dc)|     4     |-------------  (name.ptr: 1043368e4)| 1182145c0 |-------------------------
levelUp: user -> -------------  (id: 1043368ec)       ||     1     |                        |-------------  (power: 1043368f4)    ||    100    |                        |-------------  (name.len: 1043368f8) ||     4     |                        |-------------  (name.ptr: 104336900) || 1182145c0 |--------------------------------------                        ||.............  empty space           |.............  or other data         ||-------------  (1182145c0)        <---|    'G'    |-------------|    'o'    |-------------|    'k'    |-------------|    'u'    |-------------

从上面可以看出,浅拷贝是可行的。由于指针的值是一个地址,复制该值意味着我们得到的是相同的地址。这对可变性有重要影响。我们的函数不能更改 main.user 中的字段,因为它得到了一个副本,但它可以访问同一个name,那么它能更改 name 吗?在这种特殊情况下,不行,因为 name 是常量。另外,Goku是一个字符串字面量,它总是不可变的。不过,只要花点功夫,我们就能明白浅拷贝的含义:

const std = @import("std");pub fn main() void {var name = [4]u8{'G', 'o', 'k', 'u'};var user = User{.id = 1,.power = 100,// slice it, [4]u8 -> []u8.name = name[0..],};levelUp(user);std.debug.print("{s}\n", .{user.name});
}fn levelUp(user: User) void {user.name[2] = '!';
}pub const User = struct {id: u64,power: i32,// []const u8 -> []u8name: []u8
};

上面的代码会打印出Go!u。我们不得不将name的类型从[]const u8更改为[]u8,并且不再使用字符串字面量(它们总是不可变的),而是创建一个数组并对其进行切片。有些人可能会认为这前后不一致。通过值传递可以防止函数改变直接字段,但不能改变指针后面有值的字段。如果我们确实希望 name 不可变,就应该将其声明为 []const u8 而不是 []u8

1.6 递归结构

有时你需要一个递归结构。在保留现有代码的基础上,我们为 User 添加一个可选的 manager 字段,类型为 ?User。同时,我们将创建两个User,并将其中一个指定为另一个的管理者:

const std = @import("std");pub fn main() void {const leto = User{.id = 1,.power = 9001,.manager = null,};const duncan = User{.id = 1,.power = 9001,.manager = leto,};std.debug.print("{any}\n{any}", .{leto, duncan});
}pub const User = struct {id: u64,power: i32,manager: ?User,
};

这段代码无法编译:struct 'learning.User' depends on itself。这个问题的根本原因是每种类型都必须在编译时确定大小,而这里的递归结构体大小是无法确定的。

我们在添加 name 时没有遇到这个问题,尽管 name可以有不同的长度。问题不在于值的大小,而在于类型本身的大小。name 是一个切片,即 []const u8,它有一个已知的大小:16 字节,其中 len 8 字节,ptr 8 字节。

你可能会认为这对任何 Optional或 union 来说都是个问题。但对于它们来说,最大字段的大小是已知的,这样 Zig 就可以使用它。递归结构没有这样的上限,该结构可以递归一次、两次或数百万次。这个次数会因User而异,在编译时是不知道的。

我们通过 name 看到了答案:使用指针。指针总是占用 usize 字节。在 64 位平台上,指针占用 8 个字节。就像Goku并没有与 user一起存储一样,使用指针意味着我们的manager不再与user的内存布局绑定。

const std = @import("std");pub fn main() void {const leto = User{.id = 1,.power = 9001,.manager = null,};const duncan = User{.id = 1,.power = 9001,// changed from leto -> &leto.manager = &leto,};std.debug.print("{any}\n{any}", .{leto, duncan});
}pub const User = struct {id: u64,power: i32,// changed from ?const User -> ?*const Usermanager: ?*const User,
};

这里主要是想讨论指针和内存模型,以及更好地理解编译器的意图。很多开发人员都在为指针而苦恼,因为指针总是难以捉摸。它们给人的感觉不像整数、字符串或User那样具体。虽然你现在不必完全理解这些概念,但掌握它们是值得的,而且不仅仅是为了 Zig。这些细节可能隐藏在 Ruby、Python 和 JavaScript 等语言中,其次是 C#、Java 和 Go。它影响着你如何编写代码以及代码如何运行。因此,请慢慢来,多看示例,添加调试打印语句来查看变量及其地址。你探索得越多,就会越清楚。

相关文章:

  • 赶紧收藏!2024 年最常见 20道 Redis面试题(三)
  • 前端 CSS 经典:好看的标题动画
  • 深度学习之基于YOLOV5的口罩检测系统
  • mysql--数据库表的创建及基础命令
  • ACL的几种类型
  • linux:SElinux的实验之自动检查错误并提出解决方案
  • NB49 牛群的秘密通信
  • FFmpeg源码:bytestream_get_byte函数解析
  • linux中sysfs创建设备节点的方法和DEVICE_ATTR
  • Linux安装刻录软件
  • SpringBoot前置知识01-SPI接口
  • 谓词逻辑(一)
  • Vue3:可以使用.value获取ref()包裹的值,为何还要存在unref()
  • 基于Vue3 + js-tool-big-box工具库实现3个随机数字的小游戏动画,快来挑战你的非凡手气!
  • 列表的创建和删除
  • Bytom交易说明(账户管理模式)
  • go append函数以及写入
  • GraphQL学习过程应该是这样的
  • Phpstorm怎样批量删除空行?
  • Windows Containers 大冒险: 容器网络
  • 机器人定位导航技术 激光SLAM与视觉SLAM谁更胜一筹?
  • 解析 Webpack中import、require、按需加载的执行过程
  • 开源地图数据可视化库——mapnik
  • 利用阿里云 OSS 搭建私有 Docker 仓库
  • 如何进阶一名有竞争力的程序员?
  • 使用Envoy 作Sidecar Proxy的微服务模式-4.Prometheus的指标收集
  • 腾讯优测优分享 | 你是否体验过Android手机插入耳机后仍外放的尴尬?
  • 小程序01:wepy框架整合iview webapp UI
  • Salesforce和SAP Netweaver里数据库表的元数据设计
  • #[Composer学习笔记]Part1:安装composer并通过composer创建一个项目
  • #QT(TCP网络编程-服务端)
  • ${factoryList }后面有空格不影响
  • (09)Hive——CTE 公共表达式
  • (13)Hive调优——动态分区导致的小文件问题
  • (16)UiBot:智能化软件机器人(以头歌抓取课程数据为例)
  • (ZT)出版业改革:该死的死,该生的生
  • (保姆级教程)Mysql中索引、触发器、存储过程、存储函数的概念、作用,以及如何使用索引、存储过程,代码操作演示
  • (附源码)计算机毕业设计ssm基于Internet快递柜管理系统
  • (十七)devops持续集成开发——使用jenkins流水线pipeline方式发布一个微服务项目
  • (淘宝无限适配)手机端rem布局详解(转载非原创)
  • (学习日记)2024.01.19
  • (转)Windows2003安全设置/维护
  • .NET 程序如何获取图片的宽高(框架自带多种方法的不同性能)
  • .NET 使用配置文件
  • .net 桌面开发 运行一阵子就自动关闭_聊城旋转门家用价格大约是多少,全自动旋转门,期待合作...
  • .NET/C# 推荐一个我设计的缓存类型(适合缓存反射等耗性能的操作,附用法)
  • .NET是什么
  • /proc/vmstat 详解
  • ?.的用法
  • @NoArgsConstructor和@AllArgsConstructor,@Builder
  • @selector(..)警告提示
  • @test注解_Spring 自定义注解你了解过吗?
  • @transaction 提交事务_【读源码】剖析TCCTransaction事务提交实现细节
  • [ 常用工具篇 ] POC-bomber 漏洞检测工具安装及使用详解
  • [ACM] hdu 1201 18岁生日