• 【Rust基础③】方法method、泛型与特征


    6 方法 Method

    Rust中,方法往往和对象成对出现:object.method()。Rust 的方法往往跟结构体、枚举、特征(Trait)一起使用

    6.1 定义方法

    Rust 使用 impl 来定义方法,例如以下代码:

    struct Circle {
        x: f64,
        y: f64,
        radius: f64,
    }
    
    impl Circle {
        // new是Circle的关联函数,因为它的第一个参数不是self,且new并不是关键字
        // 这种方法往往用于初始化当前结构体的实例
        fn new(x: f64, y: f64, radius: f64) -> Circle {
            Circle {
                x: x,
                y: y,
                radius: radius,
            }
        }
    
        // Circle的方法,&self表示借用当前的Circle结构体
        fn area(&self) -> f64 {
            std::f64::consts::PI * (self.radius * self.radius)
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

    注:Rust 的对象定义和方法定义是分离的,这种数据和使用分离的方式,会给予使用者极高的灵活度。

    self、&self 和 &mut self

    在一个 impl 块内,Self 指代被实现方法的结构体类型,self 指代此类型的实例,即为哪个结构体实现方法,那么 self 就是指代哪个结构体的实例。self 依然有所有权的概念:

    • self 表示 Rectangle 的所有权转移到该方法中,这种形式用的较少
    • &self 表示该方法对 Rectangle 的不可变借用
    • &mut self 表示可变借用

    self 的使用就跟函数参数一样,要严格遵守 Rust 的所有权规则。使用方法代替函数有以下好处:

    • 不用在函数签名中重复书写 self 对应的类型
    • 代码的组织性和内聚性更强,对于代码维护和阅读来说,好处巨大

    在 Rust 中,允许方法名跟结构体的字段名相同,往往适用于实现 getter 访问器:

    pub struct Rectangle {
        width: u32,
        height: u32,
    }
    
    impl Rectangle {
        pub fn new(width: u32, height: u32) -> Self {
            Rectangle { width, height }
        }
        pub fn width(&self) -> u32 {
            return self.width;
        }
    }
    
    fn main() {
        let rect1 = Rectangle::new(30, 50);
    
        println!("{}", rect1.width());
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    用这种方式,我们可以把 Rectangle 的字段设置为私有属性,只需把它的 newwidth 方法设置为公开可见,那么用户就可以创建一个矩形,同时通过访问器 rect1.width() 方法来获取矩形的宽度,因为 width 字段是私有的,当用户访问 rect1.width 字段时,就会报错。注意在此例中,Self 指代的就是被实现方法的结构体 Rectangle

    6.2 自动引用和解引用

    在 C/C++ 语言中,有两个不同的运算符来调用方法:. 直接在对象上调用方法,而 -> 在一个对象的指针上调用方法,这时需要先解引用指针。换句话说,如果 object 是一个指针,那么 object->something()(*object).something() 是一样的。

    Rust 并没有一个与 -> 等效的运算符;相反,Rust 有一个叫 自动引用和解引用的功能。方法调用是 Rust 中少数几个拥有这种行为的地方。他是这样工作的:当使用 object.something() 调用方法时,Rust 会自动为 object 添加 &&mut* 以便使 object 与方法签名匹配。也就是说,这些代码是等价的:

    p1.distance(&p2);
    (&p1).distance(&p2);
    
    • 1
    • 2

    第一行看起来简洁的多。这种自动引用的行为之所以有效,是因为方法有一个明确的接收者———— self 的类型。在给出接收者和方法名的前提下,Rust 可以明确地计算出方法是仅仅读取(&self),做出修改(&mut self)或者是获取所有权(self)。事实上,Rust 对方法接收者的隐式借用让所有权在实践中更友好。

    6.3 关联函数

    思考一个问题,如何为一个结构体定义一个构造器方法?也就是接受几个参数,然后构造并返回该结构体的实例。其实答案在开头的代码片段中就给出了,很简单,参数中不包含 self 即可。

    这种定义在 impl 中且没有 self 的函数被称之为关联函数: 因为它没有 self,不能用 f.read() 的形式调用,因此它是一个函数而不是方法,它又在 impl 中,与结构体紧密关联,因此称为关联函数。

    在之前的代码中,我们已经多次使用过关联函数,例如 String::from,用于创建一个动态字符串。

    impl Rectangle {
        fn new(w: u32, h: u32) -> Rectangle {
            Rectangle { width: w, height: h }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    Rust 中有一个约定俗成的规则,使用 new 来作为构造器的名称,出于设计上的考虑,Rust 特地没有用 new 作为关键字

    因为是函数,所以不能用 . 的方式来调用,我们需要用 :: 来调用,例如 let sq = Rectangle::new(3, 3);。这个方法位于结构体的命名空间中::: 语法用于关联函数和模块创建的命名空间。

    Rust 允许我们为一个结构体定义多个 impl 块,目的是提供更多的灵活性和代码组织性,例如当方法多了后,可以把相关的方法组织在同一个 impl 块中,那么就可以形成多个 impl 块,各自完成一块儿目标。

    除了结构体,还可以为枚举、特征(trait)实现方法,如:

    #![allow(unused)]
    enum Message {
        Quit,
        Move { x: i32, y: i32 },
        Write(String),
        ChangeColor(i32, i32, i32),
    }
    
    impl Message {
        fn call(&self) {
            // 在这里定义方法体
        }
    }
    
    fn main() {
        let m = Message::Write(String::from("hello"));
        m.call();
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    7 泛型和特征

    7.1 泛型 Generics

    使用泛型参数,有一个先决条件,必需在使用前对其进行声明:

    fn largest<T>(list: &[T]) -> T {}
    
    • 1

    可以这样理解这个函数定义:函数 largest 有泛型类型 T,它有个参数 list,其类型是元素为 T 的数组切片,最后,该函数返回值的类型也是 T

    7.1.1 结构体中使用泛型

    结构体中的字段类型也可以用泛型来定义,下面代码定义了一个坐标点 Point,它可以存放任何类型的坐标值:

    struct Point<T> {
        x: T,
        y: T,
    }
    
    fn main() {
        let integer = Point { x: 5, y: 10 };
        let float = Point { x: 1.0, y: 4.0 };
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    这里有两点需要特别的注意:

    • 提前声明,跟泛型函数定义类似,首先我们在使用泛型参数之前必需要进行声明 Point,接着就可以在结构体的字段类型中使用 T 来替代具体的类型
    • x 和 y 是相同的类型,若需要不同类型,将声明改为struct Point { x: T, y: U, }

    7.1.2 枚举中使用泛型

    Option 枚举类型

    enum Option<T> {
        Some(T),
        None,
    }
    
    • 1
    • 2
    • 3
    • 4

    Option 是一个拥有泛型 T 的枚举类型,它第一个成员是 Some(T),存放了一个类型为 T 的值。可以在任何一个需要返回值的函数中,去使用 Option 枚举类型来做为返回值,用于返回一个任意类型的值 Some(T),或者没有值 None

    Result枚举类型

    enum Result<T, E> {
        Ok(T),
        Err(E),
    }
    
    • 1
    • 2
    • 3
    • 4

    主要用于函数返回值,如果函数正常运行,则最后返回一个 Ok(T)T 是函数具体的返回值类型,如果函数异常运行,则返回一个 Err(E)E 是错误类型。例如打开一个文件:如果成功打开文件,则返回 Ok(std::fs::File),因此 T 对应的是 std::fs::File 类型;而当打开文件时出现问题时,返回 Err(std::io::Error)E 对应的就是 std::io::Error 类型。

    7.1.3 方法中使用泛型

    在结构体的方法中定义额外的泛型参数,就跟泛型函数一样:

    struct Point<T, U> {
        x: T,
        y: U,
    }
    
    impl<T, U> Point<T, U> {
        fn mixup<V, W>(self, other: Point<V, W>) -> Point<T, W> {
            Point {
                x: self.x,
                y: other.y,
            }
        }
    }
    
    fn main() {
        let p1 = Point { x: 5, y: 10.4 };
        let p2 = Point { x: "Hello", y: 'c'};
    
        let p3 = p1.mixup(p2);
    
        println!("p3.x = {}, p3.y = {}", p3.x, p3.y);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

    这个例子中,T,U 是定义在结构体 Point 上的泛型参数,V,W 是单独定义在方法 mixup 上的泛型参数,可以理解为,一个是结构体泛型,一个是函数泛型。

    为具体的泛型类型实现方法

    对于 Point 类型,你不仅能定义基于 T 的方法,还能针对特定的具体类型,进行方法定义:

    impl Point<f32> {
        fn distance_from_origin(&self) -> f32 {
            (self.x.powi(2) + self.y.powi(2)).sqrt()
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    这段代码意味着 Point 类型会有一个方法 distance_from_origin,而其他 T 不是 f32 类型的 Point 实例则没有定义此方法。这个方法计算点实例与坐标(0.0, 0.0) 之间的距离,并使用了只能用于浮点型的数学运算符。

    这样我们就能针对特定的泛型类型实现某个特定的方法,对于其它泛型类型则没有定义该方法

    7.1.4 const 泛型

    const 泛型,针对值的泛型,可以用于处理数组长度的问题:

    fn display_array<T: std::fmt::Debug, const N: usize>(arr: [T; N]) {
        println!("{:?}", arr);
    }
    fn main() {
        let arr: [i32; 3] = [1, 2, 3];
        display_array(arr);
    
        let arr: [i32; 2] = [1, 2];
        display_array(arr);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    如上所示,我们定义了一个类型为 [T; N] 的数组,其中 T 是一个基于类型的泛型参数,重点在于 N 这个泛型参数,它是一个基于值的泛型参数!因为它用来替代的是数组的长度。

    注意的是需要对 T 加一个限制 std::fmt::Debug,该限制表明 T 可以用在 println!("{:?}", arr) 中,因为 {:?} 形式的格式化输出需要 arr 实现该特征。

    N 就是 const 泛型,定义的语法是 const N: usize,表示 const 泛型 N ,它基于的值类型是 usize

    Rust 通过在编译时进行泛型代码的 单态化(monomorphization)来保证效率。单态化是一个通过填充编译时使用的具体类型,将通用代码转换为特定代码的过程。

    编译器所做的工作正好与我们创建泛型函数的步骤相反,编译器寻找所有泛型代码被调用的位置并针对具体类型生成代码。

    7.2 特征 Trait

    特征类似于其他语言中的接口,定义了一组可以被共享的行为,只要实现了特征,你就能使用这组行为

    7.2.1 为类型实现特征

    因为特征只定义行为看起来是什么样的,因此我们需要为类型实现具体的特征,定义行为具体是怎么样的。

    首先来为 PostWeibo 实现 Summary 特征:

    //特征定义,也可以在特征中定义具有默认实现的方法
    pub trait Summary {
        fn summarize(&self) -> String;
    }
    
    pub struct Post {
        pub title: String, // 标题
        pub author: String, // 作者
        pub content: String, // 内容
    }
    //特征实现
    impl Summary for Post {
        fn summarize(&self) -> String {
            format!("文章{}, 作者是{}", self.title, self.author)
        }
    }
    
    pub struct Weibo {
        pub username: String,
        pub content: String
    }
    
    impl Summary for Weibo {
        fn summarize(&self) -> String {
            format!("{}发表了微博{}", self.username, self.content)
        }
    }
    
    • 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

    实现特征的语法与为结构体、枚举实现方法很像:impl Summary for Post,读作“为 Post 类型实现 Summary 特征”,然后在 impl 的花括号中实现该特征的具体方法。

    关于特征实现与定义的位置,有一条非常重要的原则:如果你想要为类型 A 实现特征 T,那么 A 或者 T 至少有一个是在当前作用域中定义的!

    7.2.2 使用特征作为函数参数

    定义一个函数,使用特征作为函数参数:

    pub fn notify(item: &impl Summary) {
        println!("Breaking news! {}", item.summarize());
    }
    
    • 1
    • 2
    • 3

    impl Summary,它的意思是 实现了Summary特征item 参数。

    你可以使用任何实现了 Summary 特征的类型作为该函数的参数,同时在函数体内,还可以调用该特征的方法,例如 summarize 方法。具体的说,可以传递 PostWeibo 的实例来作为参数,而其它类如 String 或者 i32 的类型则不能用做该函数的参数,因为它们没有实现 Summary 特征。

    7.2.3 特征约束(trait bound)

    虽然 impl Trait 这种语法非常好理解,但是实际上它只是一个语法糖:

    pub fn notify<T: Summary>(item: &T) {
        println!("Breaking news! {}", item.summarize());
    }
    
    • 1
    • 2
    • 3

    真正的完整书写形式如上所述,形如 T: Summary 被称为特征约束

    在简单的场景下 impl Trait 这种语法糖就足够使用,但是对于复杂的场景,特征约束可以让我们拥有更大的灵活性和语法表现能力,例如一个函数接受两个 impl Summary 的参数:

    pub fn notify(item1: &impl Summary, item2: &impl Summary) {}
    
    • 1

    如果函数两个参数是不同的类型,那么上面的方法很好,只要这两个类型都实现了 Summary 特征即可。但是如果我们想要强制函数的两个参数是同一类型呢?上面的语法就无法做到这种限制,此时我们只能使特征约束来实现:

    pub fn notify<T: Summary>(item1: &T, item2: &T) {}
    
    • 1

    泛型类型 T 说明了 item1item2 必须拥有同样的类型,同时 T: Summary 说明了 T 必须实现 Summary 特征。

    多重约束
    pub fn notify<T: Summary + Display>(item: &T) {}
    
    • 1

    通过这两个特征,就可以使用 item.summarize 方法,以及通过 println!("{}", item) 来格式化输出 item

    where约束

    当特征约束变得很多时,函数的签名将变得很复杂:

    fn some_function<T: Display + Clone, U: Clone + Debug>(t: &T, u: &U) -> i32 {}
    
    • 1

    严格来说,上面的例子还是不够复杂,但是我们还是能对其做一些形式上的改进,通过 where

    fn some_function<T, U>(t: &T, u: &U) -> i32
        where T: Display + Clone,
              U: Clone + Debug
    {}
    
    • 1
    • 2
    • 3
    • 4

    7.2.4 函数返回中的impl trait

    可以通过 impl Trait 来说明一个函数返回了一个类型,该类型实现了某个特征:

    fn returns_summarizable() -> impl Summary {
        Weibo {
            username: String::from("sunface"),
            content: String::from(
                "m1 max太厉害了,电脑再也不会卡",
            )
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    因为 Weibo 实现了 Summary,因此这里可以用它来作为返回值。要注意的是,虽然我们知道这里是一个 Weibo 类型,但是对于 returns_summarizable 的调用者而言,他只知道返回了一个实现了 Summary 特征的对象,但是并不知道返回了一个 Weibo 类型。

    当函数返回的真实类型非常复杂,不知道该怎么声明时(毕竟 Rust 要求你必须标出所有的类型),此时就可以用 impl Trait 的方式简单返回。例如,闭包和迭代器就是很复杂,只有编译器才知道那玩意的真实类型,如果让你写出来它们的具体类型,估计内心有一万只草泥马奔腾,好在你可以用 impl Iterator 来告诉调用者,返回了一个迭代器,因为所有迭代器都会实现 Iterator 特征。

    7.2.5 两个综合例子

    为自定义类型实现 + 操作

    在 Rust 中除了数值类型的加法,String 也可以做加法,因为 Rust 为该类型实现了 std::ops::Add 特征,同理,如果我们为自定义类型实现了该特征,那就可以自己实现 Point1 + Point2 的操作:

    use std::ops::Add;
    
    // 为Point结构体派生Debug特征,用于格式化输出
    #[derive(Debug)]
    struct Point<T: Add<T, Output = T>> { //限制类型T必须实现了Add特征,否则无法进行+操作。
        x: T,
        y: T,
    }
    
    impl<T: Add<T, Output = T>> Add for Point<T> {
        type Output = Point<T>;
    
        fn add(self, p: Point<T>) -> Point<T> {
            Point{
                x: self.x + p.x,
                y: self.y + p.y,
            }
        }
    }
    
    fn add<T: Add<T, Output=T>>(a:T, b:T) -> T {
        a + b
    }
    
    fn main() {
        let p1 = Point{x: 1.1f32, y: 1.1f32};
        let p2 = Point{x: 2.1f32, y: 2.1f32};
        println!("{:?}", add(p1, p2));
    
        let p3 = Point{x: 1i32, y: 1i32};
        let p4 = Point{x: 2i32, y: 2i32};
        println!("{:?}", add(p3, p4));
    }
    
    • 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
    自定义类型的打印输出

    在开发过程中,往往只要使用 #[derive(Debug)] 对我们的自定义类型进行标注,即可实现打印输出的功能:

    #[derive(Debug)]
    struct Point{
        x: i32,
        y: i32
    }
    fn main() {
        let p = Point{x:3,y:3};
        println!("{:?}",p);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    但是在实际项目中,往往需要对我们的自定义类型进行自定义的格式化输出,以让用户更好的阅读理解我们的类型,此时就要为自定义类型实现 std::fmt::Display 特征:

    #![allow(dead_code)]
    
    use std::fmt;
    use std::fmt::{Display};
    
    #[derive(Debug,PartialEq)]
    enum FileState {
      Open,
      Closed,
    }
    
    #[derive(Debug)]
    struct File {
      name: String,
      data: Vec<u8>,
      state: FileState,
    }
    
    impl Display for FileState {
       fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
         match *self {
             FileState::Open => write!(f, "OPEN"),
             FileState::Closed => write!(f, "CLOSED"),
         }
       }
    }
    
    impl Display for File {
       fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
          write!(f, "<{} ({})>",
                 self.name, self.state)
       }
    }
    
    impl File {
      fn new(name: &str) -> File {
        File {
            name: String::from(name),
            data: Vec::new(),
            state: FileState::Closed,
        }
      }
    }
    
    fn main() {
      let f6 = File::new("f6.txt");
      //...
      println!("{:?}", f6);
      println!("{}", f6);
    }
    
    • 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
    • 50

    7.3 特征对象

    可以通过 & 引用或者 Box 智能指针的方式来创建特征对象。

    trait Draw {
        fn draw(&self) -> String;
    }
    
    impl Draw for u8 {
        fn draw(&self) -> String {
            format!("u8: {}", *self)
        }
    }
    
    impl Draw for f64 {
        fn draw(&self) -> String {
            format!("f64: {}", *self)
        }
    }
    
    // 若 T 实现了 Draw 特征, 则调用该函数时传入的 Box 可以被隐式转换成函数参数签名中的 Box
    fn draw1(x: Box<dyn Draw>) {
        // 由于实现了 Deref 特征,Box 智能指针会自动解引用为它所包裹的值,然后调用该值对应的类型上定义的 `draw` 方法
        x.draw();
    }
    
    fn draw2(x: &dyn Draw) {
        x.draw();
    }
    
    fn main() {
        let x = 1.1f64;
        // do_something(&x);
        let y = 8u8;
    
        // x 和 y 的类型 T 都实现了 `Draw` 特征,因为 Box 可以在函数调用时隐式地被转换为特征对象 Box 
        // 基于 x 的值创建一个 Box 类型的智能指针,指针指向的数据被放置在了堆上
        draw1(Box::new(x));
        // 基于 y 的值创建一个 Box 类型的智能指针
        draw1(Box::new(y));
        draw2(&x);
        draw2(&y);
    }
    
    • 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

    上面代码,有几个非常重要的点:

    • draw1 函数的参数是 Box 形式的特征对象,该特征对象是通过 Box::new(x) 的方式创建的
    • draw2 函数的参数是 &dyn Draw 形式的特征对象,该特征对象是通过 &x 的方式创建的
    • dyn 关键字只用在特征对象的类型声明上,在创建时无需使用 dyn

    因此,可以使用特征对象来代表泛型或具体的类型。

    泛型是在编译期完成处理的:编译器会为每一个泛型参数对应的具体类型生成一份代码,这种方式是静态分发(static dispatch),因为是在编译期完成的,对于运行期性能完全没有任何影响。

    与静态分发相对应的是动态分发(dynamic dispatch),在这种情况下,直到运行时,才能确定需要调用什么方法。之前代码中的关键字 dyn 正是在强调这一“动态”的特点。当使用特征对象时,Rust 必须使用动态分发。

    特征对象特点:

    • 特征对象大小不固定(对于不同的类型可以实现相同特征)
    • 几乎总是使用特征对象的引用方式,如 &dyn DrawBox(引用大小是固定的,占用两个指针大小:ptr-指向实现了特征的具体类型的实例/vptr-指向虚表vtable)

    在Rust中,一般Self代表类型,self指代当前实例对象。

    不是所有特征都能拥有特征对象,只有对象安全的特征才行。当一个特征的所有方法都有如下属性时,它的对象才是安全的:

    • 方法的返回类型不能是 Self
    • 方法没有任何泛型参数
  • 相关阅读:
    如何顺时针或者逆时针记录多边形的每个点
    OpenFaaS梳理之二:函数入门
    【docker专栏5】详解docker镜像管理命令
    软考中级(软件设计师)——数据库系统(上下午各占6-8分)
    Java多线程详解
    12种绝佳买入形态k线图(下)
    泛微E9,独立选择框对应数据库表查询
    Java使用lowagie根据模版动态生成PDF(无需额外设置系统字体)
    (附源码)计算机毕业设计SSM基于大数据的汽车流量监控
    新手也能上手的天气预报demo(高德API+echarts)
  • 原文地址:https://blog.csdn.net/qq_51601649/article/details/133786301