rust高级话题

前言

每一种语言都有它比较隐秘的点。rust也不例外。

零大小类型ZST

struct Foo; //类单元结构
struct Zero(
(), //单元类型
[u8;0], //0大小的数组
Foo,
);//零大小类型组成的类型

动态大小类型DST

无法静态确定大小或对齐的类型。

  1. 特征对象trait objects:dyn Mytrait
  2. 切片slices:[T]、str

特征包含vtable,通过vtable访问成员。切片是数组或Vec的一个视图。

也许你会产生好奇,为什么字符串和特征不能像一般语言设计的那样,设计成一个指针就好,而弄成一个动态大小的类型。这和普通指针有什么不同?

从程序员的角度出发,所谓动态大小类型是不存在的,因为你不能构造一个动态大小类型的对象出来,不管如何你只能构造"动态大小类型的指针"。动态大小类型更像是一个思维过程的中间产物。

注意,动态大小类型的指针和普通指针是不同的:

  1. 动态大小类型指针是胖指针,有地址,还有大小,也就是多维的。

如&str 可以想象成:

&str{
ptr:*u8,
size:usize,
}

既然如此,那么解引用*&str就是无意义的,因为它丢失了对象的大小。这个角度去理解动态大小类型,或者比较具体。

rust中的动态大小类型,其本质是将原本对象中的大小信息,直接放到指针里面,形成一个胖指针,而对象自身是不包含大小的。这是合理的,比如c语言中的字符串,本质就是一个\0结束的字符串序列而已,并不包含什么大小字段,也不可能要求所有字符串都要带一个大小的字段。

为了类型安全,大小信息又是必要的,因而对这类基础类型做一个抽象,然后用胖指针来指向,不失为一个合理方案。

特征对象的指针也是如此,rust中作为一个胖指针来实现,因此特征对象本身就成了无法构造的动态大小类型了。

对于特征对象,rust有两个特殊关键字支撑其行为(impl 和 dyn):

  1. impl 静态分发,泛型技术。(不要和impl xxx for y搞混)
  2. dyn 动态分发,即指针。(用dyn能更好的对应静态分发的写法)
trait S{fn so(&self);};
impl S for i32{fn so(&self){println!("i32");}}
impl S for &str{fn so(&self){println!("&str");}} //静态分发,0成本
fn f(a:impl S)->impl S{
a.so();
1
}
f("hi").so(); //动态分发,少量代价
fn f2(a:&dyn S)->&dyn S{
a.so();
&"hi"
}
f2(&1).so();

正确的安装方法

rust是一个快速变化的语言和编译系统,建议用最新版,否则可能出现兼容性问题而无法通过编译。

rustup 管理工具链的版本,分别有三个通道:nightly、beta、stable。如上所述,建议同时安装nightly版本,保持最新状态。

wget -O- https://sh.rustup.rs |sh  #下载安装脚本并执行

安装之后,就可以用rustup命令来控制整个工具链的更新了。

rustup toolchain add nightly #安装每夜版本的工具链
rustup component add rust-src #安装源代码
cargo +nightly install racer #用每夜版本的工具链安装racer,一个代码补全的工具。因为当前只支持每夜版本
rustup component add rls # 这是面向编辑器的一个辅助服务
rustup component add rust-analysis #分析工具 #vscode : 搜索插件rls安装即可

结构体

struct 结构体是一种记录类型。成员称为域field,有类型和名称。也可以没有名称,称为元组结构tuple strcut。 只有一个域的特殊情况称为新类型newtype。一个域也没有称为类单元结构unit-like struct。

类别 域名称 域个数 写法举例
一般结构 >1 strcut S{x:i32,y:&str}
元组结构 >1 strcut S(i32,&str)
新类型 1 struct S(i32)
类单元结构 0 struct S

枚举和结构是不同的,枚举是一个集合(类似c语言种的联合union,变量的枚举成员可选,而不是全体成员),而结构是一个记录(成员必定存在)。

作为一个描述力比较强的语法对象,结构很多时候都在模拟成基础类型,但是更多时候是具备和基础类型不同的特征,而需要由用户来定制它的行为。

#[derive(Copy,Clone,Debug)] //模拟基础数据类型的自动复制行为,Debug 特征是为了打印输出
struct A; let a = A;
let b = a;//自动copy 而不是move
println!("{:?}", a);//ok

复制和移动

rust的基本类型分类可以如此安排:

  1. 有所有权

    1. 默认实现copy

      1. 基本数据类型
      2. 元素实现copy的复合类型
        1. 数组
        2. 元组
    2. 没有默认实现copy,都是move
      1. 结构

        1. 标准库中的大部分智能指针(基于结构来实现)
        2. 标准库中的数据结构
        3. String
      2. 枚举
  2. 无所有权
    1. 引用

      1. &str
      2. &[T]
    2. 指针

基础数据类型,引用类1(引用&T,字符串引用&str,切片引用&[T],特征对象&T:strait,原生指针*T),元组(T2),数组[T 2;size],函数指针fn()默认都是copy;而结构、枚举和大部分标准库定义的类型(智能指针,String等)默认是move。

注意:

  1. 引用只是复制指针本身,且没有数据的所有权;因为可变引用唯一,在同一作用域中无法复制,此时是移动语义的,但可以通过函数参数复制传递,此时等于在此位置重新创建该可变引用。
  2. 元素必须为可复制的

特征对象

特征是一个动态大小类型,它的对象大小根据实现它的实体类型而定。这一点和别的语言中的接口有着本质的不同。rust定义的对象,必须在定义的时候即获知大小,否则编译错误。

但是,泛型定义除外,因为泛型的实际定义是延迟到使用该泛型时,即给出具体类型的时候。

或者,可以用间接的方式,如引用特征对象。

trait s{}
fn foo(a:impl s)->impl s{a} //这里impl s相等于泛型T:s,属于泛型技术,根据具体类型单态化

引用、生命周期、所有权

rust的核心创新:

  • 引用(借用):borrowing
  • 所有权:ownership
  • 生命周期:lifetimes

必须整体的理解他们之间的关系。

借用(引用)是一种怎样的状态?

例子:

  • let mut 旧 = 对象;
  • let 新 = &旧 或 &mut 旧;
操作 旧读 旧写 新读 新写 新副本
copy
move
& ✔1
&mut ✔1

注1:旧写后引用立即无效。

从上表格可以看出,引用(借用)并不只是产生一个读写指针,它同时跟踪了引用的原始变从量是否有修改,如果修改,引用就无效。这有点类似迭代器,引用是对象的一个视图。

一般性原则:可以存在多个只读引用,或者存在唯一可写引用,且零个只读引用。(共享不可变,可变不共享)

特殊补充(便利性规则):

可以将可写引用转换为只读引用。该可写引用只要不写入或传递,rust会认为只是只读引用间的共享,否则,其他引用自动失效(rust编译器在不停的进化,其主要方向是注重实质多于形式上,提供更大的便利性)。

let mut a = 1;
let mut rma = &mut a;
let mut ra = &a;//error
let ra = rma as &i32; //ok
println!("*ra={}", ra);//ok
println!("*rma={}", rma);//ok *rma = 2; //ra 失效
println!("*ra={}", ra);//error
println!("*rma={}", rma);//ok
a = 3; //ra、rma 都失效
println!("*ra={}", ra);//error
println!("*rma={}", rma);//error

用途在哪里?

let p1 = rma; //error
let p2 = ra; //ok

即:需要产生多个引用,但不是传递函数参数的场合。如转换到基类或特征接口(如for循环要转换到迭代器)。

let v=[1,2,3];
let p1=&mut v;
let p2=p1 as &[i32]; // 注释掉其中一组代码
// 1
for item in p1{}
println!("{}",p1);//error // 2
for item in p2{}
println!("{}",p2);//ok

引用无法移动指向的数据:

let  a = String::from("hi");
let pa = &a;
let b = *pa; //error

生命周期

  1. 所有权生命周期 > 引用

引用并不管理对象生命周期,因为它没有对象的所有权。引用需要判定的是引用的有效性,即对象生命周期长于引用本身即可。

当需要管理生命周期时,不应该使用引用,而应该用智能指针。

一般而言,我们不喜欢引用,因为引用引入了更多概念,所以我们希望智能指针这种东西来自动化管理所有资源。但不可否认,很多算法直接使用更底层的引用能提高效率。个人认为:结构组织需要智能指针,算法内部可以使用引用。

错误处理

为了处理异常情况,rust通过Result<T,E>定义了基础的处理框架。

fn f1()->Result<(),Error>{
File::open("file")?; //?自动返回错误
OK(()) //正常情况
} //main()函数怎么处理?
fn main()->Result<(),Error>{} pub trait Termination{ //返回类型须支持该特征
fn report(self) -> i32;
}
impl Termination for (){
fn report(self) ->i32{
ExitCode::SUCCESS.report()
}
}
impl<E: fmt::Debug> Termination for Result<(),E>{
fn report(self)->i32{
match self{
Ok(())=>().report(),
Err(err)=>{
eprintln!("Error:{:?}",err);
ExitCode::FAILURE.report()
}
}
}
}

交叉编译

rustup target add wasm-wasi #编译目标为wasm(网页汇编)
cargo build --target=wasm-wasi

智能指针

解引用:

  1. 当调用成员函数时,编译器会自动调用解引用,这样&T => &U => &K自动转换类型,直到找到该成员函数。
// *p=>*(p.deref())
// =>*(&Self)
// =>Self
pub trait Deref {
type Target: ?Sized;
fn deref(&self) -> &Self::Target;
} // *p=>*(p.deref_mut())
// =>*(&mut Self)
// =>mut Self
pub trait DerefMut: Deref {
fn deref_mut(&mut self) -> &mut Self::Target;
}

常用智能指针(管理堆内存上的数据):

拥有所有权:

  1. Box<T> =>T 堆上变量,对应普通栈上变量
  2. Vec<T> =>[T] 序列
  3. String == Vec<u8> =>[u8] 满足utf8编码

共享所有权(模拟引用):

  1. Rc<T> =>T 共享,智能化的&,采用引用计数技术

    1. Rc<T>::clone() 产生镜像,并增加计数
    2. Rc<RefCell<T>> 模拟&mut,内部元素可写
  2. Arc<T> 多线程共享

无所有权:

  1. Weak<T> 弱引用

特殊用途:

  1. Cell<T> 内部可写
  2. RefCell<T> 内部可写
  3. Cow<T> Clone-on-Write
  4. Pin<T> 防止自引用结构移动

产生循环引用的条件(如:Rc<RefCell<T>>):

  1. 使用引用计数指针
  2. 内部可变性

设计递归型数据结构例子:

//递归型数据结构,需要使用引用
//引用是间接结构,否则该递归数据结构有无限大小
//其次,引用需要明确初始化,引用递归自身导致无法初始化
//因此,需要Option来定义没引用的情况
//Node(Node) :无限大小
//=> Node(Box<Node>) :不能初始化
//=> Node(Option<Box<Node>>) :ok struct Node<T>{
next: Option<Box<Self>>,
data:T,
}

有些数据结构,一个节点有多个被引用的关系,比如双向链表,这时只能用Option<Rc<RefCell<T>>>这套方案,但存在循环引用的风险,程序员需要建立一套不产生循环引用的程序逻辑,如正向强引用,反向弱引用。

编译器会对自引用(自己的成员引用自己,或另一个成员)的情况进行检查,因为违反了移动语义。

闭包

pub trait FnOnce<Args> {
type Output;
extern "rust-call" fn call_once(self, args: Args) -> Self::Output;
} pub trait FnMut<Args> : FnOnce<Args> {
extern "rust-call" fn call_mut(&mut self, args: Args) -> Self::Output;
} pub trait Fn<Args> : FnMut<Args> {
extern "rust-call" fn call(&self, args: Args) -> Self::Output;
}

动态分派和静态分派

静态分派:泛型

动态反派:特征对象(指针)

特殊类型

// 只读转可写引用:
#[lang = "unsafe_cell"]
#[stable(feature = "rust1", since = "1.0.0")]
pub struct UnsafeCell<T: ?Sized> {
value: T,
} // 假类型(占位类型)
#[lang = "phantom_data"]
#[stable(feature = "rust1", since = "1.0.0")]
pub struct PhantomData<T:?Sized>; // 内存分配器
#[derive(Copy, Clone, Default, Debug)]
pub struct Heap;

成员方法

struct A;

impl A{
fn f1(self){}
fn f2(this:Self){} fn f3()->Self{A}
} trait X{fn fx(self);}
impl X for A{fn fx(self){}} //Self 表示实现的具体类型,本例为A
//self = self:Self
//&self = self:&Self
//&mut self = self:&mut Self
let a = A;
a.f1(); //ok 可以用.调用成员方法
a.f2(); //error 不可以,因为第一个参数名字不是self,虽然是Self类型
A::f2(a); //ok let a=A::f3(); //无参数构造函数的形式,名字随意 impl i32{} //error ,i32不是当前项目开发的,不能添加默认特征的实现,但可以指定一个特征来扩展i32
impl X for i32{} //ok

规则: 特征和实现类型,必须有一个是在当前项目,也就是说不能对外部类型已经实现的特征进行实现。也就是说,库开发者应该提供完整的实现。

规则: 用小数点.调用成员函数时,编译器会自动转换类型为self的类型(只要可能)。

但是要注意,&self -> self是不允许的,因为引用不能移动指向的数据。可以实现对应&T的相同接口来解决这个问题。

let a = &A;
a.fx(); //error 相当于调用fx(*a)
impl X for &A{
fn fx(self){} //这里的self = self:&A
}
a.fx(); //ok

容器、迭代器、生成器

容器 说明
Vec 序列
VecDeque 双向队列
LinkedList 双向链表
HashMap 哈希表
BTreeMap B树表
HashSet 哈希集
BTreeSet B树集
BinaryHeap 二叉堆
trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
...
} //for 循环实际使用的迭代器
trait IntoIterator {
type Item;
type IntoIter: Iterator<Item=Self::Item>;
fn into_iter(self) -> Self::IntoIter;
}

容器生成三种迭代器:

  1. ·iter() 创造一个Item是&T类型的迭代器;
  2. ·iter_mut() 创造一个Item是&mut T类型的迭代器;
  3. ·into_iter() 根据调用的类型来创造对应的元素类型的迭代器。
    1. T => R 容器为T,返回元素为R,即move
    2. &T => &R
    3. &mut T=> &mut R

适配器(运算完毕返回迭代器):

生成器(实验性):

let mut g=||{loop{yield 1;}};
let mut f = ||{ match Pin::new(&mut g).resume(){
GeneratorState::Yielded(v)=>println!("{}", v),
GeneratorState::Complete(_)=>{},
};
f(); //print 1
f(); //print 1

类型转换

pub trait AsRef<T: ?Sized> {
fn as_ref(&self) -> &T;
} pub trait AsMut<T: ?Sized> {
fn as_mut(&mut self) -> &mut T;
} //要求hash不变
pub trait Borrow<Borrowed: ?Sized> {
fn borrow(&self) -> &Borrowed;
} pub trait From<T> {
fn from(T) -> Self;
} //标准库已经有默认实现,即调用T::from
pub trait Into<T> {
fn into(self) -> T;
} //克隆后转换
pub trait ToOwned {
type Owned: Borrow<Self>;
fn to_owned(&self) -> Self::Owned; fn clone_into(&self, target: &mut Self::Owned) { ... }
} //克隆写入
pub enum Cow<'a, B>
where
B: 'a + ToOwned + ?Sized,
{
Borrowed(&'a B),
Owned(<B as ToOwned>::Owned),
} pub trait ToString {
fn to_string(&self) -> String;
} pub trait FromStr {
type Err;
fn from_str(s: &str) -> Result<Self, Self::Err>;
}

运算符重载

trait Add<RHS = Self> {
type Output;
fn add(self, rhs: RHS) -> Self::Output;
}

I/O 操作

平台相关字符串:

  • OsString

    • PathBuf
  • OsStr
    • Path

文件读写:

  • File
  • Read
    • BufReader
  • Write

标准输入输出:

  • Stdin

    • std::io::stdin()
  • Stdout
    • std::io::stdout()
  • std::env::args()
  • std::process::exit()

反射

std::any

多任务编程

启动新线程框架代码:

use std::thread;

let child = thread::spawn(move ||{});

child.join(); //等待子线程结束

数据竞争三个条件:

  1. 数据共享
  2. 数据修改
  3. 没有同步( rust 编译器保证同步)

数据同步框架:

  • Sync 访问安全

    1. rust引用机制保证了数据访问基本是安全的
    2. 内部可变的类型除外(用了不安全代码)
    3. lock()的内部可变类型也是安全的
  • Send 移动安全
    1. 没有引用成员的类型;
    2. 泛型元素T是Send的;
    3. 或被lock()包装的。

多线程下的数据总结:

  1. T :移动(或复制),因而无法共享(也就是只能给一个线程使用)
  2. &T
    1. 指向局部变量,生命周期报错
    2. 指向static T,ok
  3. &mut T
    1. 同理
    2. 指向static mut T, 不安全报错
  4. Box<T>:普通智能指针没有共享功能,等价T
  5. Rc<T>:普通共享指针没有Send特征,技术实现使用了内部可变,但没有加线程锁进行安全处理
  6. Arc<T> 提供了线程安全的共享指针
  7. Mutex<T>提供了线程安全的可写能力。
    1. RwLock
    2. AtomicIsize
use std::sync::{Arc,Mutex};
use std::thread;
const COUNT:u32=1000000; let a = Arc::new(Mutex::new(123));//线程安全版共享且内部可变
// 1
let c = a.clone();
let child1 = thread::spawn(move ||{for _ in 0..COUNT {*c.lock().unwrap()+=2;}});
// 2
let c = a.clone();
let child2 = thread::spawn(move ||{for _ in 0..COUNT {*c.lock().unwrap()-=1;}}); // 多任务同步
child1.join().ok();
child2.join().ok();
println!("final:{:?}", a);//1000123

模式匹配

模式匹配我之前没什么接触,所以感觉挺有意思的(所以有了这一节)。

在rust中,let,函数参数,for循环,if let,while let,match等位置实际上是一个模式匹配的过程,而不是其他语言中普通的定义变量。

let x = 1; //x 这个位置是一个模式匹配的过程

模式匹配会有两种结果,一种是不匹配,一种是匹配。一个模式匹配位置,要求不能存在不匹配的情况,这种叫必然匹配“irrefutable”,用于定义。否则,用于分支判断。

很明显定义变量必然是要求匹配的,如let,函数参数,for循环。而用于分支判断的是if let,wdhile let,match。

那为什么要用模式匹配来定义变量?因为很灵活,看一下它的表达能力:

  1. 普通类型的匹配:x --> T
  2. 解构引用:&x --> &T 得 x=T
  3. 解构数组:[_,_,x,_,_] --> [T;5] 得 x= 第三个元素
  4. 解构元组:(..,x) --> (1,2,"x") 得 x= 第三个成员
  5. 解构结构:T{0:_,1:x} --> T(1,"x") 得 x= 第二个成员

通过与目标类型差不多的写法,对复杂类型进行解构,相对直观。

另一方面,用于判断的模式匹配可以针对动态的内容,而不只是类型来进行匹配,如:

  1. 值范围匹配:x@1...10 --> 7 得 x=7
  2. 切片属于值匹配:x@[2,_] --> &[1,2,3][1..3] 得 x=&[2,3]
//演示代码
let [_,_,x,_,_] = [1;5];
let (..,x) = (1,2,'y');
let hi = &['h','e','l','l','o'][2..4]; struct Ax(i32,char);
let Ax{1:_,0:x} = Ax(1,'x');
if let x@['l',_]=hi {println!("{:?}", x)};
if let x@1...10 = 7 {println!("{}",x)};

rust 高级话题的更多相关文章

  1. 转:如何学习SQL(第四部分:DBMS扩展功能与SQL高级话题)

    转自:http://blog.163.com/mig3719@126/blog/static/285720652010950102575/ 9. DBMS提供的扩展功能 掌握了基本的关系模型原理和DB ...

  2. QuantLib 金融计算——高级话题之模拟跳扩散过程

    目录 QuantLib 金融计算--高级话题之模拟跳扩散过程 跳扩散过程 模拟算法 面临的问题 "脏"的方法 "干净"的方法 实现 示例 参考文献 如果未做特别 ...

  3. Spring高级话题-@Enable***注解的工作原理

    出自:http://blog.csdn.net/qq_26525215 @EnableAspectJAutoProxy @EnableAspectJAutoProxy注解 激活Aspect自动代理 & ...

  4. C#中的线程(四)高级话题

    C#中的线程(四)高级话题   Keywords:C# 线程Source:http://www.albahari.com/threading/Author: Joe AlbahariTranslato ...

  5. Dynamics CRM图表高级话题:创建跨实体的图表

    关注本人微信和易信公众号: 微软动态CRM专家罗勇 ,回复147或者20150728可方便获取本文,同时可以在第一时间得到我发布的最新的博文信息,follow me! 制作图表你会发现,在界面上只能选 ...

  6. Hawk 6. 高级话题:子流程系统

    子流程的定义 当流程设计的越来越复杂,越来越长时,就难以进行管理了.因此,采用模块化的设计才会更加合理.本节我们介绍子流程的原理和使用. 所谓子流程,就是能先构造出一个流程,然后被其他流程调用.被调用 ...

  7. 《Python 学习手册4th》 第十九章 函数的高级话题

    ''' 时间: 9月5日 - 9月30日 要求: 1. 书本内容总结归纳,整理在博客园笔记上传 2. 完成所有课后习题 注:“#” 后加的是备注内容 (每天看42页内容,可以保证月底看完此书) “重点 ...

  8. Spring Boot实战笔记(九)-- Spring高级话题(组合注解与元注解)

    一.组合注解与元注解 从Spring 2开始,为了响应JDK 1.5推出的注解功能,Spring开始大量加入注解来替代xml配置.Spring的注解主要用来配置注入Bean,切面相关配置(@Trans ...

  9. Spring Boot实战笔记(八)-- Spring高级话题(条件注解@Conditional)

    一.条件注解@Conditional 在之前的学习中,通过活动的profile,我们可以获得不同的Bean.Spring4提供了一个更通用的基于条件的Bean的创建,即使用@Conditional注解 ...

随机推荐

  1. C#控制内插字符串的格式

    C#6.0推出了内插字符串 结果展示: 内插表达式字段宽度和对齐方式: 结果展示:(+/-代表右对齐.左对齐,数字表示显示宽度)

  2. SSM框架之SpringMVC(2)参数绑定及自定义类型转换

    SpringMVC(2)参数绑定及自定义类型转换 1.请求参数的绑定 1.1. 请求参数的绑定说明 1.1.1.绑定机制 表单提交的数据都是k=v格式的 username=haha&passw ...

  3. 【转载】Android内存泄漏的8种可能

    Java是垃圾回收语言的一种,其优点是开发者无需特意管理内存分配,降低了应用由于局部故障(segmentation fault)导致崩溃,同时防止未释放的内存把堆栈(heap)挤爆的可能,所以写出来的 ...

  4. 高性能go服务之高效内存分配

    高性能go服务之高效内存分配 手动内存管理真的很坑爹(如C C++),好在我们有强大的自动化系统能够管理内存分配和生命周期,从而解放我们的双手. 但是呢,如果你想通过调整JVM垃圾回收器参数或者是优化 ...

  5. 解决Flask和Django的错误“TypeError: 'bool' object is not callable”

    跟着欢迎进入Flask大型教程项目!的教程学习Flask,到了重构用户模型的时候,运行脚本后报错: TypeError: 'bool' object is not callable 这是用户模型: c ...

  6. TensorFlow从1到2(十五)(完结)在浏览器做机器学习

    TensorFlow的Javascript版 TensorFlow一直努力扩展自己的基础平台环境,除了熟悉的Python,当前的TensorFlow还实现了支持Javascript/C++/Java/ ...

  7. 垃圾收集器GC

    (1)DefNew(串行)收集器 Serial(串行)垃圾收集器是最基本.发展历史最悠久的收集器:JDK1.3.1前是HotSpot新生代收集的唯一选择: 特点: (1) 针对新生代采用复制算法,单线 ...

  8. 一、itk在VS2019上面的安装 和例子(HelloWorld)运行

    一.Itk简介 vtk是专门用于医疗图像处理的函数库,类似opencv. 这篇博客主要是讲解安装vtk之后的例子的运行,即如何构建自己的第一个ITK例子 二.Itk安装 Itk安装参考这篇博客: ht ...

  9. CSRF介绍

    对于常规的Web攻击手段,如XSS.CRSF.SQL注入.(常规的不包括文件上传漏洞.DDoS攻击)等,防范措施相对来说比较容易,对症下药即可,比如XSS的防范需要转义掉输入的尖括号,防止CRSF攻击 ...

  10. Maven为项目配置仓库

    Maven为项目配置仓库 参考 https://mp.weixin.qq.com/s?__biz=MzA5MTkxMDQ4MQ==&mid=2648933541&idx=1&s ...