前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >66个让你对Rust又爱又恨的场景之一:变量与值

66个让你对Rust又爱又恨的场景之一:变量与值

原创
作者头像
程序员吾真本
发布2024-07-18 10:04:44
4700
发布2024-07-18 10:04:44
举报
文章被收录于专栏:Rust避坑式入门

讲动人的故事,写懂人的代码

传统编程语言的堆内存管理机制,一般分为手动内存管理和垃圾回收两种流派。

属于手动内存管理流派的C++,虽然提供了手动管理内存的灵活性,但容易因程序员的失误导致内存泄漏、悬垂指针、双重释放和野指针等问题。

属于垃圾回收流派的Java,虽然省去了手动内存管理,但带来了内存释放不可预知且系统开销较大的问题。

另外,在多线程环境中,多个线程同时访问和修改同一块内存时,可能会发生数据竞争,导致未定义行为或数据损坏。

该如何解决这些问题?Rust的解决方案是实现编译器参与检查的“出域即清”内存自动释放机制。这使Rust成为以内存安全著称的编程语言。

Rust编译器参与检查“出域即清”内存自动释放机制,指当堆上值、栈上值和其他系统资源(如文件句柄)的所有者超出作用域时,Rust会自动释放该值所占用的内存资源(对于大多数类型无须显式编写内存释放代码),或关闭相关资源(需要显式编写资源关闭代码,以便Rust调用)。同时,在编译阶段,通过Rust编译器,尤其是其内部的借用检查器(borrow checker),对代码进行全面分析。它不仅能检查“出域即清”机制的正确应用,还能验证更广泛的所有权和借用规则。这包括检测潜在的内存泄漏、悬垂指针、数据竞争等问题。

通过这种机制,Rust能在编译时发现违背所有权机制规则的源代码,并给出明确的错误提示,要求程序员修改。这样做的目的是将大量可能在运行时出现的bug,消灭在编译阶段,极大地节省了返工成本,提高了程序的内存安全性和并发安全性。

除了编译时检查,Rust还保留了一些必要的运行时安全检查,如数组边界检查,以提供额外的安全保障。这种多层次的安全机制使Rust在保证高性能的同时,大幅度降低内存相关错误和并发问题的风险。

Rust编译器参与检查“出域即清”的内存自动释放机制虽然好处多多,但它涉及变量、堆上值、栈上值、不可变借用和可变借用这5个角色,分别在所有权、所有权移动、作用域、生存期、丢弃和复制这6个方面的30种场景(注意,栈上值与堆上值在作用域方面不适用,所以应该是28种场景)。

如果再把1个适合单线程和多线程开发的智能指针Box<T>,4个适合单线程开发的智能指针Rc<T>、RefCell<T>、Ref<T>、RefMut<T>,以及5个适合多线程开发的智能指针Arc<T>、Mutex<T>、MutexGuard<T>、RwLock<T>、RwLockReadGuard<T>和RwLockWriteGuard<T>,一共11个智能指针都补充到之前的5个角色里,与那6个方面组合起来,那么场景会陡增到66个!

怎么会是66个?16乘以6,应该是96个场景呀。为了减少你的焦虑,我把其中8个智能指针中联系紧密的分为一组。这样8个智能指针就分成了3组。让每组充当一个角色,就能减少场景数量。再减去前面说到的2个不适用的场景,实际共计64个场景。但66比较好记,所以标题就写成66个场景,如表1所示。

表1 Rust所有权机制的66个场景

角色/方面

所有权

所有权移动

作用域

生存期

丢弃

复制

变量

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. 可变引用关于复制场景规则

Box<T>

31. Box<T>关于所有权场景规则

32. Box<T>关于所有权移动场景规则

33. Box<T>关于作用域场景规则

34. Box<T>关于生存期场景规则

35. Box<T>关于丢弃场景规则

36. Box<T>关于复制场景规则

Rc<T>

37. Rc<T>关于所有权场景规则

38. Rc<T>关于所有权移动场景规则

39. Rc<T>关于作用域场景规则

40. Rc<T>关于生存期场景规则

41. Rc<T>关于丢弃场景规则

42. Rc<T>关于复制场景规则

RefCell<T>

43. RefCell<T>关于所有权场景规则

44. RefCell<T>关于所有权移动场景规则

45. RefCell<T>关于作用域场景规则

46. RefCell<T>关于生存期场景规则

47. RefCell<T>关于丢弃场景规则

48. RefCell<T>关于复制场景规则

Arc<T>

49. Arc<T>关于所有权场景规则

50. Arc<T>关于所有权移动场景规则

51. Arc<T>关于作用域场景规则

52. Arc<T>关于生存期场景规则

53. Arc<T>关于丢弃场景规则

54. Arc<T>关于复制场景规则

Mutex<T>

55. Mutex<T>关于所有权场景规则

56. Mutex<T>关于所有权移动场景规则

57. Mutex<T>关于作用域场景规则

58. Mutex<T>关于生存期场景规则

59. Mutex<T>关于丢弃场景规则

60. Mutex<T>关于复制场景规则

RwLock<T>

61. RwLock<T>关于所有权场景规则

62. RwLock<T>关于所有权移动场景规则

63. RwLock<T>关于作用域场景规则

64. RwLock<T>关于生存期场景规则

65. RwLock<T>关于丢弃场景规则

66. RwLock<T>关于复制场景规则

这66个场景形成了Rust陡峭的学习曲线,让很多初学者望而却步。

只有搞清这66种场景,才能翻越陡峭的Rust学习高峰,赶走Rust入门的拦路虎。

在介绍这66个场景之前,先熟悉一下11个角色和6个方面。

1. 参与所有权机制的角色

Rust的所有权机制涉及多个角色。这些角色可以分为三类,即变量、引用和智能指针。这些角色在不同场景下发挥着各自的作用。

变量是最基本的角色,它拥有栈上值或堆上值。当一个变量离开作用域时,它所拥有的值也随之被释放。

引用则是对变量所拥有的值的借用,分为不可变引用和可变引用。在同一作用域内,要么只能有一个可变引用,要么可以有多个不可变引用。但不能同时存在可变和不可变引用。

智能指针是更高级的抽象,它们在实现上利用了Rust的所有权规则。但提供了如下更灵活方便的使用模式。

Box<T>提供了堆内存分配,常用于表达递归的数据结构。

Rc<T>通过引用计数实现共享不可变所有权,适合单线程内表达数据结构。

RefCell<T>提供了运行时借用检查,可以在运行时动态检查借用规则,在回调函数这样的场景下,比编译时检查更为灵活。

Ref<T>RefMut<T>RefCell<T>的两个关联类型,它们分别代表了RefCell<T>的不可变借用和可变借用。

Arc<T>Rc<T>多线程版本。

Mutex<T>RwLock<T>用于多线程内的共享可变访问,其中Mutex<T>适合写操作较多的场景,而RwLock<T>适合读操作较多的场景。

MutexGuard<T>Mutex<T>的一个关联类型,代表了对Mutex<T>的锁定和访问。RwLockReadGuard<T>RwLockWriteGuard<T>RwLock<T>的两个关联类型,分别代表了对RwLock<T>的读锁定和写锁定。

1.1. 拥有值的变量

为了给所存储的值起名字,我们需要变量。变量(variable)是用于存储数据的命名空间。

与许多其他编程语言不同,Rust默认情况下变量的值是不可变的,这意味着一旦变量被赋值,它的值就不能再被改变。这个特性有助于提高程序的内存安全性和可预测性。

在Rust中,变量作为值的所有者,遵循所有权规则。每个值在任一时刻只能有一个所有者。当变量离开其作用域时,如果它仍然拥有某个值的所有权,该值会被丢弃,相关的内存(无论是在栈上还是堆上)都会被释放,如代码清单1所示。

代码清单1 Rust 变量可变性与所有权示例

代码语言:javascript
复制
1  fn main() {
2      let x = 5;
3      // x = 10;
4  
5      let mut y = 10;
6      y = 20;
7  
8      {
9          let z = String::from("hello");
10         println!("{}", z);
11     }
12 
13     // println!("{}", z);
14 }
// 运行结果:
// hello

代码清单1解释如下。

第2行:声明了一个不可变变量x,并将其与值5绑定。这体现了Rust默认情况下变量的值是不可变的特性。

第3行:如果取消注释,会导致编译错误“cannot assign twice to immutable variable x”,因为x是不可变的,不能被重新赋值。

第5行:使用mut关键字声明了一个可变变量y

第6行:对可变变量y进行重新赋值,这是允许的。

第8-11行:创建了一个新的作用域,并在其中声明并绑定了变量z

第9行:z被与一个String类型的值绑定,z成为这个值的所有者。

第11行:作用域结束,z离开作用域,它拥有的String值被丢弃,相关内存被释放。这体现了所有权规则和作用域结束时的自动清理。

第13行:如果取消注释,会导致编译错误“cannot find value z in this scope”,因为z已经离开作用域,不能再被使用。这再次体现了所有权规则。

Rust的变量拥有值的过程,可以通过所有权转移(如变量赋值、函数调用或函数返回值等)来改变。对于实现了 Copy trait 的类型,则会进行值的复制而非所有权转移。Rust 还提供了借用机制,允许在不转移所有权的情况下临时使用值。

在C++中,与Rust不同,C++默认情况下变量是可变的。如果想让变量不可变,需要使用const关键字。

C++没有像Rust那样的所有权系统,但它提供了手动内存管理机制。

C++的变量的生命周期由其作用域决定,当离开作用域时,栈上的变量会自动销毁。

与C++类似,Java变量默认也是可变的。要创建不可变变量,需要使用final关键字。

对于引用类型,Java变量存储的是对象的引用,而非对象本身。

Java使用自动垃圾回收机制管理内存,无需手动释放。

1.2. 访问快捷的栈上值

为了存储局部变量、函数调用信息和在编译时大小已知且固定的值,我们需要栈上值。

栈(stack)是一种快速的内存分配区域,用于存储在编译时大小已知且固定的值。

在Rust中,典型的栈上值包括基本类型(如整型、浮点型、布尔型和字符型)以及包含这些类型的数组和元组。

Rust的栈上值具有以下优势。首先是访问速度快,因为遵循后进先出(LIFO)的原则,栈上值的分配和释放非常快速。其次是自动管理内存,不需要手动分配和释放内存。第三是确定性,栈上值的生命周期在编译时就能确定。

Rust的栈上值有以下劣势。首先是大小限制,栈空间通常较小,不适合存储大量数据。其次是固定大小,栈上值的大小必须在编译时确定,不能动态改变。

Rust的栈上值适用于以下场景。首先是存储小型、固定大小的数据。其次是存储需要快速访问的临时变量。最后是存储函数参数和返回值(当它们是固定大小时),如代码清单2所示。

代码清单2 Rust栈上值示例:基本类型、数组、元组和函数调用

代码语言:javascript
复制
 1  fn main() {
 2      let integer: i32 = 42;
 3      let float: f64 = 3.14;
 4      let boolean: bool = true;
 5      let character: char = 'A';
 6
 7      let array: [i32; 5] = [1, 2, 3, 4, 5];
 8      let tuple: (i32, f64, bool) = (10, 2.5, false);
 9
10      let result = calculate_sum(integer, array[0]);
11      println!("Result: {}", result);
12  }
13
14  fn calculate_sum(a: i32, b: i32) -> i32 {
15      let sum = a + b;
16      sum
17  }
// 运行结果:
// Result: 43

代码清单2解释如下。

第2-5行:展示了Rust中典型的栈上值,包括基本类型(整型、浮点型、布尔型和字符型)。这些都是在编译时大小已知且固定的值。

第7-8行:演示了包含基本类型的数组和元组,它们也是栈上值。

第10行:调用函数calculate_sum,展示了函数调用信息存储在栈上。参数integerarray[0]都是栈上值。

第14行:定义了一个名为 calculate_sum 的函数,接受两个 i32 类型的参数 ab,并返回一个 i32 类型的值。

第14-17行:calculate_sum函数定义,展示了函数参数和返回值(固定大小)存储在栈上。sum是一个局部变量,也存储在栈上。

第16行:函数最后一个不带分号的表达式sum,就是这个函数的返回值。

与Rust的栈上值相似,C++的栈上值同样包括基本类型、固定大小的数组、结构体和非动态分配的类对象。C++的栈上值也具有快速访问和自动内存管理的优势。通常,C++栈上值的生命周期也是可预测的,基于其所对应的变量的作用域。

C++的栈上值与Rust的栈上值相比存在以下区别。首先是安全性,C++缺乏Rust的所有权系统和借用检查器,可能导致一些内存安全问题。其次是未定义行为,C++允许一些可能导致未定义行为的操作,如返回局部变量的引用,这在Rust中是被禁止的。最后是编译时检查,虽然C++栈上值的生命周期通常可预测,但缺乏Rust那样严格的编译时生命周期检查。

Java的栈上值处理与Rust有显著差异,主要体现在以下方面。

  • 类型限制:Java的栈仅用于存储基本类型值和对象引用,而不存储完整的对象。这与Rust可以在栈上存储完整结构体的做法不同。
  • 对象存储位置:Java中所有对象实例(包括数组)都存储在堆上,栈只存储对这些对象的引用。这与Rust可以在栈上存储完整对象的能力形成对比。
  • 生命周期管理:对于基本类型,行为类似Rust和C++中的栈上值。对于对象类型,生命周期由垃圾回收器管理,不完全由编译时的作用域决定。这与Rust的确定性生命周期管理形成鲜明对比。
  • 内存管理:Java依赖垃圾回收器自动管理内存,而Rust通过所有权系统在编译时管理内存。这导致Java在运行时的内存管理开销较大,但编程较为简单。
  • 性能考虑:由于Java的对象分配在堆上,并依赖垃圾回收,在处理大量小对象时可能比Rust的栈分配方式效率更低。
  • 编译时保证:Java缺乏Rust那样严格的编译时内存安全检查,更多依赖于运行时检查和垃圾回收。

1.3. 可动态分配的堆上值

为了存储在编译时大小未知,或在运行时大小可能会改变的值,我们需要堆上值。

堆(heap)是一种动态内存分配区域。堆上值是那些因为在编译时大小未知,或者在运行时大小可能会改变,而需要存储在堆内存上的数据。

在Rust中,通常使用Box<T>Vec<T>String等智能指针类型来在堆上分配内存。在Rust中,堆内存的管理方式与C++有很大不同。Rust采用了一种独特的内存管理模型,它既不需要程序员手动管理内存,也不依赖垃圾回收器,而是凭借所有权机制、借用机制、生存期、智能指针、Drop trait和编译时检查,保证内存安全,同时也实现了高性能。

Rust的堆上值具有以下优势。首先是动态大小,堆允许在运行时动态分配之前未知大小的数据。其次是长生命周期,堆上的数据可以存活超过创建它的作用域。最后是大量数据,适合存储大量数据,而不受栈大小限制。

Rust的堆上值有以下劣势。首先是性能开销,堆分配比栈分配慢,且需要手动或自动的内存管理。其次是缓存效率,堆上的数据可能分散在内存中,影响缓存效率。

Rust的堆上值适用于以下场景。首先是当数据大小在编译时未知时。其次是当需要数据在多个作用域间共享时。最后是实现递归数据结构如链表或树时。如代码清单3所示。

代码清单3 Rust堆上值示例:智能指针与动态数据结构

代码语言:javascript
复制
1  use std::rc::Rc;
2
3  struct Node {
4      value: i32,
5      next: Option<Rc<Node>>,
6  }
7
8  fn main() {
9      let mut vec = Vec::new();
10     vec.push(42);
11
12     let boxed_value = Box::new(100);
13
14     let mut string = String::from("Hello");
15     string.push_str(", world!");
16
17     let node1 = Rc::new(Node {
18         value: 1,
19         next: None,
20     });
21
22     let node2 = Rc::new(Node {
23         value: 2,
24         next: Some(Rc::clone(&node1)),
25     });
26
27     println!("Vec size: {}", vec.len());
28     println!("String content: {}", string);
29     println!("Boxed value: {}", boxed_value);
30     println!("Node2 value: {}", node2.value);
31 }
// 运行结果:
// Vec size: 1
// String content: Hello, world!
// Boxed value: 100
// Node2 value: 2

代码清单3解释如下。

第1行:引入标准库中的Rc(引用计数智能指针),允许多所有者。

第3行:定义一个结构体Node,用来表示链表节点。

第4行:结构体中的一个字段value,类型为i32,表示节点的值。

第5行:结构体中的另一个字段next,类型为Option<Rc<Node>>,表示下一个节点的引用,使用Rc允许多个节点共享同一个下一个节点。

第5行中的Option是Rust标准库中的一个枚举,用来表示一个值可能存在也可能不存在的情况。具体来说,Option<T>定义如下:

代码语言:javascript
复制
enum Option<T> {
    Some(T),
    None,
}

这个枚举有两个变体:

  • Some(T):表示存在一个值,值的类型为T
  • None:表示没有值。

在第5行中,next字段的类型为Option<Rc<Node>>,其含义是这个字段可以有两种状态:

  • Some(Rc<Node>):表示存在下一个节点,并且这个节点是通过引用计数智能指针Rc进行引用的。
  • None:表示不存在下一个节点,即这是链表的终止节点。

使用Option枚举的好处是,它强制程序员显式地处理可能不存在的值,从而提高代码的安全性和健壮性。例如,在访问next字段时,必须先检查它是否为Some,否则会遇到编译错误,这避免了很多空指针异常的潜在问题。

在实际代码中,我们看到第19行node1next字段被设置为None,表示node1是链表的终止节点。

node2next字段被设置为Some(Rc::clone(&node1)),表示node2的下一个节点是node1

这种设计使得链表节点可以灵活地表示是否有下一个节点,从而实现了更安全和健壮的链表结构。

第9行:声明一个可变的空向量veclet关键字用来声明变量。mut关键字表示这个变量是可变的,意味着可以对它进行修改操作(例如添加或删除元素)。vec是变量名,用来引用这个动态数组。

第9行中的Vec是Rust标准库中的动态数组类型,提供了一个可变长度的序列。Vec类型的全称是Vec<T>,其中T表示向量中元素的类型。在这一行中,Vec用于创建一个动态数组,可以根据需要添加、删除或访问元素。

Vec::new()是一个关联函数(即静态方法),用于创建一个新的、空的Vec。这个函数返回一个空的动态数组,其初始容量为零,但会根据需要自动调整大小。

Vec类型具有以下特点。

  • 动态数组:Vec的长度是可变的,可以根据需要动态增加或减少元素。
  • 高效:Vec在堆上分配内存,并且通常会预留比当前需要更多的空间,以减少频繁的内存分配和复制操作。
  • 灵活:可以存储任何类型的元素,只需在声明时指定类型参数。例如,Vec<i32>表示存储i32类型的整数。
  • 方便:提供了丰富的方法,例如push(添加元素)、pop(移除元素)、len(获取长度)等。

第10行:向向量vec中添加一个值42。演示了堆上值的动态大小特性。

第12行:使用Box<T>在堆上分配一个整数,展示了智能指针的使用。

第14行:将初始值为"Hello"绑定到一个可变字符串变量string上。

第15行:向字符串string中追加", world!"。说明了堆上值在运行时可以改变大小。

第17行:创建第一个节点node1,使用Rc包装以便在第24行共享所有权,即node2在第24行和node1共享这一行所创建的node1的不可变所有权。

第18行:node1value字段赋值为1

第19行:node1next字段赋值为None,表示这是链表的终止节点。

第22行:创建第二个节点node2,同样使用Rc包装。

第23行:node2value字段赋值为2

第24行:node2next字段指向node1,使用Rc::clone增加引用计数。这展示了如何在多个作用域间共享数据。

第24行中的Rc代表引用计数(Reference Counting),是一种智能指针,允许多所有者共享同一个数据。当你调用Rc::clone时,并不会创建数据的副本,而是增加引用计数。这使得多个变量可以安全地共享同一个数据。在这里:Rc::clone(&node1) 会增加node1的引用计数,而不会复制node1所指向的Node实例。这样做的好处是,当你需要多个变量引用同一个数据时,不必担心内存管理问题,Rc会自动处理这些引用的计数和释放。

第24行中的&node1 是一个引用,表示对node1的借用。借用的目的是为了只读访问node1,而不是获取其所有权。具体来说,Rc::clone需要一个对Rc<T>的引用作为参数,因此你需要传递&node1而不是node1本身。

第17-25行:使用Rc<T>(引用计数智能指针)创建一个递归数据结构(链表),展示了堆上值适用于实现递归数据结构的场景。

在C++中,堆上值包括使用new运算符动态分配的对象或数组、标准库容器(如std::vectorstd::stringstd::map等)以及任何在运行时需要动态分配内存的数据结构。与Rust不同,C++中程序员需要手动管理堆内存(使用delete释放new所分配的内存),或使用智能指针如std::unique_ptrstd::shared_ptr进行半自动管理。这种方法给予程序员更多控制权,但也增加了内存泄漏和悬垂指针的风险。C++的智能指针提供了类似Rust的所有权语义,但不像Rust那样在编译时强制执行。

C++与Rust关于堆上值有以下区别。首先是内存管理,C++需要手动管理或使用智能指针,Rust使用所有权系统。其次是安全性,C++依赖程序员谨慎性,Rust在编译时强制执行内存安全。最后是性能开销,C++可能因手动管理而略微提高性能,但也增加了出错风险。

在Java中,几乎所有对象都存储在堆上。Java的堆上值包括所有使用new关键字创建的对象、所有数组(无论是对象数组还是基本类型数组)、所有类的实例,包括String、集合类(如ArrayListHashMap)等。与Rust和C++不同,Java中堆内存由垃圾回收器自动管理,程序员不需要手动释放内存。这种方法虽然简化了开发,但也带来了垃圾回收不可预知和较大的系统开销,这是Rust刻意避免的。

Java与Rust关于堆上值有以下区别。首先是内存管理,Java使用垃圾回收,Rust使用所有权系统。其次是性能,Java可能因垃圾回收而有性能波动,Rust提供更可预测的性能。第三是资源管理,Java主要关注内存,Rust的所有权系统不仅适用于内存管理,也适用于其他资源(如文件句柄、网络套接字、数据库连接、线程句柄、锁和互斥量等)。最后是并发安全,Java依赖同步机制,Rust的所有权系统在编译时就能防止数据竞争。

(未完待续。划到文章下方能看目录和上下篇哦~😃)

如果喜欢这篇文章,别忘了给文章点个“赞”,好鼓励我继续写哦~😃

如果哪里没讲明白,就在评论区给我留言哦~😃

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 1. 参与所有权机制的角色
    • 1.1. 拥有值的变量
      • 1.2. 访问快捷的栈上值
        • 1.3. 可动态分配的堆上值
        相关产品与服务
        对象存储
        对象存储(Cloud Object Storage,COS)是由腾讯云推出的无目录层次结构、无数据格式限制,可容纳海量数据且支持 HTTP/HTTPS 协议访问的分布式存储服务。腾讯云 COS 的存储桶空间无容量上限,无需分区管理,适用于 CDN 数据分发、数据万象处理或大数据计算与分析的数据湖等多种场景。
        领券
        问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档