rust 集合、错误处理、泛型、Trait、生命周期、包

集合组织特性相同的数据;泛型可以定义任何抽象数据类型;生命周期限制所有权的作用域范围;错误处理使程序更健壮。

集合

一组特性相同的数据集合,除了基本数据类型的元组、数组。rust 标准库提供了一些非常有用的数据结构。

Vector 存储列表

通过类型Vec<T>定义。只能存储相同类型的值,在内存中彼此相邻排列存储。

let v:Vec<i32> = Vec::new();

通过Vec::new()创建一个类型实例。因为没有初始化任何类型数据,就必须指定数据类型。定义集合实例就只允许存储指定的类型数据。

另一种方便创建集合实例的方式通过 rust 提供的vec!

let v = vec![3,5,6];

定义了实例v,可以初始化数据,rust 会推导出数据的类型。示例中默认推导出类型是 i32

可以通过内部方法,操作实例来添加、修改里面的数据

  • v.push(val) 添加值。

  • v.get(index)获取值。会得到一个可用于match匹配的Option<&T>

    也可以使用索引取值&v[1]。使用索引取值,如果超出最大索引,会报错;使用get()方法会返回None

  • v.insert(index,val) 向指定 index 位置插入数据。

  • v.remove(index) 移除指定 index 位置的数据,并返回该数据。

  • v.pop() 移除最后一个元素,并返回。

  • v.clear() 清空实例。移除所有元素。

  • v.len() 返回当前数据个数。

要可编辑实例,声明必须使用mut可变。

let mut v:Vec<i32> = vec![];

// 更新值
v.push(23);
v.push(4);
v.push(15);
v.push(56);
//  取值
v.get(2); // 4
v[2]; // 4

在操作vec时,注意引用所有权的转义。最好的方式就是只是值借用&v

通过for循环来遍历 vector 中的值。

for i in &v{
    println!("{i}");
}

在遍历时实例v不能插入、删除项。如果需要想遍历修改每一项值,可以传递可变引用

for i in &mut v {
    *i += 5;
    println!("{i}");
}

因为是对值做操作。通过*解引用取到指针指向的值。再次从实例v取值时,都是最新计算过的值。

通过枚举存储多种类型

因为 vector 只能存储相同类型的值。实际开发中如果需要存储不同类型的值,可以使用枚举定义。

这样对于 vector 而言,它都是同一种枚举类型。

enum Color{
    Red(String),
    Green(u32,u32,u32),
    Green(u32,u32,u32,u8)
}

fn main(){
    let colors = vec![Color::Red(String::from("red")), Color::Green(0, 255, 0)];
    for i in &colors {
        println!("{:?}", i);
    }
}

字符串

之前已经通过String::from()来创建一个字符串变量值。字符串是字节的集合。

在 rust 中只有一种字符串类型:字符串 slice str;通常是以借用的方式&str

作为一个集合,也可以通过 new 操作符创建一个实例。

let mut str = String::new();

但是通过 new 创建是的实例不能初始化数据值。所以之前一直使用String::from()

也可以用一个字符串字面值创建 String

let s = "hboot";
let str = s.to_string();

字符串是utf-8编码的。可以包含任何可以正确编码的数据。

操作字符串,作为一个集合,也有许多更新的方法:

  • push_str 尾部附加字符串。不会获得变量的所有权,内部采用字符串 slice。
  • push 尾部附加字符。
let mut s = String::from("hboot");

s.push_str(" hello");
s.push('A');

也可以通过+运算符拼接字符串,运算位加值将会转义所有权,而被加值则必须引用

let s1 = String::from("hboot");
let s2 = String::from("hello");

let s = s1+&s2; // s1的所有权没有了,s2的所有权仍然存在

也就是只能是&strString相加。不能两个String相加,它们类型不同,确定相加是因为 rust 内部把 String 强制转换为&str

当拼接值过多时,我们可以通过format!宏来处理。它不会获取任何字符串的所有权

let s1 = String::from("hboot");
let s2 = String::from(" hello");
let s3 = String::from(" world");

let s = format!("{s1}{s2}{s3}");

rust 的字符串不支持索引。

  • 对于字符串值在内存中是以字符编码 code 存储的,而不是字符。通过下标获取到并不是想看到的值。
  • 访问效率不高。通常索引预期复杂度(O(1)),但是在 rust 中,需要需要从头开始到索引位置遍历字符是否有效。

所以遍历字符串最好的方式明确需要的是字符还是字节。字符通过chars方法将其分开并返回多个char类型的值;字节则使用bytes方法返回字符的编码值。

let s1 = String::from("hboot");

// 遍历获取字符
for c in s1.chars() {
    println!("{c}");
}

// 遍历获取字节
for c in s1.bytes() {
    println!("{c}");
}

对于字节,有的语言编码后可能不止一个字节组成,这个需要注意。

HashMap 存储键值对

创建HashMap实例,因为 HashMap 没有被 prelude。所以需要手动引入。

use std::collections::HashMap;

fn main(){
    let mut map = HashMap::new();
}

当未被使用时,键值对的数据类型是unknown。在第一次插入数据后,则决定了后面的数据类型

let mut map = HashMap::new();

map.insert(1, 10);
map.insert(2, 30);

此时默认类型为HashMap<i32, i32>。当时用 String 作为键值是,变量的所有权将被转移给 map。字符串变量不可用

let mut map = HashMap::new();

let s = String::from("red");
map.insert(s, "red");

通过map.get()获取 HashMap 中的值,返回Option<&V>,如果没有键时,则返回None.

可以通过copied()方法来获取Option<V>;如果没有键时,可以通过uwrap_or()在没有键值时,设置一个替代值。

map.get(&String::from("yellow")).copied().unwrap_or('yellow');

注意get方法接受是一个&str类型。

当我们重复对同一个键赋值时,后面的会覆盖之前的。如果需要判断是否存在键,不存在插入数据;存在则不做任何操作

map.entry(String::from("green")).or_insert("green");

entryor_insert()方法在键存在时会返回这个值的可变引用。不存在则将参数作为新值插入并返回值的可变引用。

一个示例,通过 HashMap 统计字符串中出现的字符数。

let s1 = String::from("hboot");
let mut map = HashMap::new();
for c in s1.chars() {
    let num = map.entry(c).or_insert(0);
    *num += 1;
}

dbg!("{:?}", map);

HashMap默认使用了叫做 SipHash 的哈希函数,可以抵御哈希表的拒绝服务攻击。

泛型、trait 和生命周期

泛型是具体类型和其他属性的抽象替代。定义时不必知道这里实际代表什么,比如之前的实例中的Option<T> / Vec<T>都已经接触了。

泛型

通过定义泛型,可以抽离一些重复的代码逻辑。使得我们的代码更具维护性、适应性更强。

创建一个泛型函数。类型参数声明必须在函数名称和参数列表中间尖括号<>里面。

fn largest<T: std::cmp::PartialOrd>(list: &[T]) -> &T {
    let mut large = &list[0];

    for val in list {
        if val > large {
            large = val;
        }
    }

    large
}

fn main(){
    let v1 = vec![12, 34, 5, 56, 7];
    let v2 = vec![34.23, 12.12, 56.1223, 23.12];

    dbg!(largest(&v1));
    dbg!(largest(&v2));
}

实例中为了找出给定 vector 结构数据中的最大值。但是调用的两次结构实例是不同的数据类型i32、f64,使用泛型则可以只写一个公用的函数。

泛型函数中通过遍历结构中的数据进行对比排序。但是<T>泛型是任何类型,存在有的数据类型不能进行排序,rust 在编译阶段会报错。所以增加了泛型限制,std::cmp::PartialOrd 标识传入的类型都可以进行排序。

在结构体使用泛型,作为数据类型。

struct Size<T>{
    width:T,
    height:T
}

也可以传入多个泛型,对应不同的字段数据类型Size<T,U>

在枚举中使用泛型。之前已经使用的枚举Option<T>

enum Status<T, U> {
    YES(T),
    NO(U),
}

也可以在结构体、枚举的方法定义中使用泛型。此时需要在impl后声明泛型T

impl<T> Size<T> {
    fn width(&self) -> &T {
        &self.width
    }
}

如果在方法中,指定了具体的数据类型,那么创建的实例,不是该数据类型时,则不能调用该方法。

impl Size<u8> {
    fn height(&self) -> &u8 {
        &self.height
    }
}

fn main(){
    let size1: Size<u8> = Size {
        width: 34,
        height: 45,
    };
    let size2: Size<f64> = Size {
        width: 34.12,
        height: 45.34,
    };

    size1.height(); // size1 实例上有height方法。size2则没有
}

泛型不会使程序比具体类型运行的慢。rust 通过在编译时进行泛型代码的单态化,也就是重复将泛型声明为具体的定义。

trait定义共同行为

什么是 trait,在之前的描述已多次出现。它定义了某个特定类型拥有可能与其他类型相同的功能。

  • 可以通过trait以一种抽象的方式定义共享的行为。
  • 可以使用trait bounds指定泛型是任何拥有特定行为的类型。

类比接口行为。抽象定义属性、方法,然后其他的实例创建实现接口中的方法。

通过trait定义一个抽象方法。

trait Log {
    fn log(&self)->String;
}

声明一个Logtait,包含了一个方法 log。它用来记录实例创建产生行为后日志记录。

每个声明的集合数据都必须实现这个方法。

struct Size<T> {
    width: T,
    height: T,
}

// std::fmt::Debug 是为了打印输出
impl<T: std::fmt::Debug> Log for Size<T> {
    fn log(&self) -> String {
        let str = format!("{:?}-{:?}", &self.width, &self.height);
        println!("变更值:{str}");
        str
    }
}

fn main(){
    let mut size2: Size<f64> = Size {
        width: 34.12,
        height: 45.34,
    };

    size2.width = 45.111;
    size2.log();
}

也可以提供一个默认实现,这样可以选择重载这个方法或者保留默认实现。

trait Log {
    fn entry_log(&self) -> String {
        String::from("entry log...")
    }
}

然后在其他类型实现 trait 时,可以保留默认的行为。

// 在上方实现的结构体size2,可以直接调用

println!("{}", size2.entry_log());

也可以在默认实现中,调用其他方法。

trait Log {
    fn log(&self) -> String;
    fn entry_log(&self) -> String {
        let entry = String::from("entry log...");
        println!("{}", entry);
        // 调用log方法
        let content = self.log();
        format!("{}", content)
    }
}

fn main(){
    // size2 实现不变,仅需要调用entry_log方法即可
    // entry.log();
    size2.entry_log();
}

实现了trait这些定义后,如何将其作为参数传递呢。使用impl trait语法

fn notify(item: &impl Log) {
    println!("Log! {}", item.entry_log());
}

fn main(){
    // 通过传递实例 size2直接调用该方法
    notify(&size2);
}

也可以通过泛型来定义参数,专业术语称为trait bound

fn notify<T: Log>(item: &T) {
    println!("Log! {}", item.entry_log());
}

这种方式在对于多个参数的书写友好。可以通过泛型限制参数的类型。

fn notify<T: Log>(item: &T,item1:&T) {
    println!("Log! {}", item.entry_log());
}

也可以通过+指定多个 trait。

fn notify(item: &(impl Log + Display)) {}
// 或者使用泛型
fn notify<T: Log + Display>(item: &T) {}

调用传参时的实例则必须实现Log和Display,但是当有很多个 trait 时,书写起来就会很多。

可以通过where关键字简化书写,看起来更加的清晰。

fn notify<T, U>(item: &T, item2: &U)
where
    T: Log + Display,
    U: Clone + Display,
{
}

也可以通过函数返回某个实现了trait的类型实例

fn return_log() -> impl Log {
    Size {
        width: 23,
        height: 45,
    }
}

fn main(){
    let size3 = return_log();
    size3.entry_log();
}

在闭包和迭代器场景中十分有用。但是这种适用于返回单一类型的情况。

通过trait bound可以有条件的控制实例可调用的类型方法。只有类型实现了某些方法,实例才会有指定的方法。

生命周期

也就是对于引用、借用的有效作用域的限制。在引用或借用之前,保证被引用或借用的变量在当前作用域一直有效。

这个特性避免了悬垂引用,防止了程序引用未定义数据的问题;如下例子:

fn main(){
    let a;
    {
        let b = "admin";
        a = &b;
    }

    println!("{}",a)
}

运行cargo run这段代码,将会报错,变量a得到了局部作用域变量b的引用,在最后的作用域中使用了a。但是变量b在局部作用域结束时就已经释放了,导致引用它的a在使用时就会报错。

在 rust 中,通过借用检查器来检测作用域之间的借用是否都是有效的。并在编译阶段给出错误提示,上面的代码不需要运行,也可以看到编译器给出的错误提示。

为了解决上面这问题,我们可以将 b的所有权交出去,因为b作用域结束,并没有什么作用了。

{
    let b = "admin";
    a = b;
}

还有在第一篇文章所有权的问题

fn print_info() -> String {
    let str = String::from("hboot");

    // 这是错误的,函数执行完毕,必须交出所有权
    // &str
    // 直接返回创建的字符串
    str
}

生命周期注解

还有一些问题,在函数调用的时候,需要传参处理完后返回某个参数的值。如下示例:

fn main(){
    let a = String::from("abcd");
    let b = String::from("efg");
    println!("{}", longest(&a, &b));
}

fn longest(a: &str, b: &str) -> &str {
    if a.len() > b.len() {
        a
    } else {
        b
    }
}

编译器直接就会提示错误信息,我们执行cargo run看详细的错误信息。错误也很明确expected named lifetime parameter,并且给出了解决示例。

fn longest<'a>(a: &'a str, b: &'a str) -> &'a str {
    if a.len() > b.len() {
        a
    } else {
        b
    }
}

'a就是生命周期的注解语法。

  • 它可以给描述多个引用生命周期的关系,而不影响参数a、b的生命周期。
  • 在函数指定了泛型生命周期,函数就可以接受任何生命周期的引用。
  • 生命周期参数以'撇号开头,小写的名称。
  • 它位于引用&之后,它表明了被借用的变量存在的时间和借用变量的生命周期存在的一样久
  • 它保证了在引用值a、b中作用域最短的那个生命周期结束之前有效

需要注意的的就是最后一个,它的存在时间长久在于作用域最短的那一个

fn main(){
    let a = String::from("abcd");

    let result;
    {
        let b = String::from("efg");
        result = longest(&a, &b);
    }
    println!("{}", result);
}

函数的调用在b的局部作用域中,调用结束后的结果值result使用超出了b的作用域,编译器报错。

可以把result的使用范围局限在b的作用域内。

{
    let b = String::from("efg");
    result = longest(&a, &b);
    println!("{}", result);
}

结构体中的生命周期注解

同设置泛型一样,在结构体名称后面使用简括号<>声明泛型生命周期。

struct User<'b> {
    name: &'b str,
}

fn main(){
    let name = String::from("hboot");
    let user = User { name: &name };
}

结构体的实例user的生命周期不能比字段name的引用存在的更久。

生命周期注解是为了 rust 检查器推断出引用的生命周期。有时候就会书写大量的这种模板式的注解,这种场景有时候 rust 会纳入到编译器中,这样就不在显示声明,而这些模式统称为生命周期省略规则。我们在书写时,只要总训这些规则就可以不用声明式书写生命周期了。

编译器推断生命周期的规则:

  1. 编译器为每一个输入的参数都分配一个生命周期参数
  2. 如果只有一个输入参数,那么它的生命周期参数赋予给所有的输出生命周期
  3. 如果有多个输入参数,其中之一个参数是&self &mut self,所有输出生命周期被赋予 self 的生命周期。

static静态生命周期

通过static声明一个静态生命周期,它存活于整个程序运行期间。

let str:&'static str = "hello rust";

str文本直接存储在程序的二进制文件中。在使用时考虑是否真的需要。

包、create

通过拆解模块来创建多个文件组织代码。更好的重用代码,定义哪些内容可以公开,哪些是私有的。

这里有一些概念:
- Cargo 的一个功能,允许构建、测试和分享 crate。
crates - 一个模块的树形结构,它形成了库或者二进制项目。
模块/use - 允许控制作用域和路径的私有性。
路径 - 命名例如结构体、函数或模块等方式

包、crate

crate分为库和二进制。二进制可以被编译为可执行文件,有一个main函数来执行程序需要做的事情;库用来作为工具,提供诸如函数的功能。

包是一些列功能的一个或多个crate。包含Cargo.toml文件,阐述如何去构建这些 crate。

往往src/lib.rs就表示这是一个库;而src/main.rs表示这是一个包。这也是编译时的入口点。

通过mod声明一个模块,通过内联方式声明mod user{};或者创建文件src/user.rs或者src/user/mod.rs

// 内联声明

mod user {

}

声明好模块后,要想在其他地方使用该模块,则需要加pub修饰,标识这是一个公用模块。

假设现在我们有以文件创建的模块src/user.rs,其中有两个声明的公用结构和枚举类。

pub struct model {
    name: String,
    age: i32,
}
pub enum status {
    online,
    offline,
}

通过mod关键字定义,来说明编译器在src/user.rs查找代码。

// 在main.rs中
mod user;

fn main(){
    // 可以直接通过模块名称来使用定义在模块中的类型
    let status = user::status::offline;
}

也可以通过use关键字来导入需要使用的公用类型。

// 在main.rs中
use crate::user::status;

mod user;

fn main(){
    let status = status::offline;
}

在一个模块中,也可以继续声明子模块。声明的方式同上

引用模块路径

刚才使用模块引入的方式crate::user是以 crate 跟开头的全路径。也可以相对于当前模块开始,以self或者super

// 在main.rs中
use user::status;

mod user;

由于模块 user 和main.rs是在同一路径下,所以可以通过相对路径引入。

如果模块层级嵌套,不在同一路径下,要想使用相对路径,可通过super相当于..,从父模块的路径引入。

// 在模块user中定义子模块
mod work {
    use super::model;
    use super::status;

    fn is_working(user: model) -> String {
        match user.status {
            status::online => String::from("在线"),
            status::offline => String::from("离线"),
        }
    }
}

虽然它们在同一文件中,但是work定义为子模块,有自己的作用域。所以不能直接访问父模块中定义的类型。可通过super引用。

self则表示自己,调用自己模块中的定义。

mod work {
    use super::model;
    use super::status;

    fn is_working(user: model) {
        // 直接调用
        init_user();
        // 通过self调用
        self::init_user();
    }

    fn init_user() {}
}

pub声明的公用方法、类型,对于结构体,它的字段却是私有的。如果想要创建实例,则必须声明字段为公用。

// 在main.rs中
use user::{model, status};

mod user;

fn main(){
    // 下面这个创建时编译不过的,错误提示字段私有。
    let u = model {
        name: String::from("admin"),
        age: 35,
        status: status::offline,
    };
}

但是对于子模块引入使用时,这些字段默认都是有效可用的。

mod work {
    use super::model;
    use super::status;

    fn init_user() {
        let user = model {
            name: String::from("hboot"),
            age: 34,
            status: status::online,
        };
    }
}

对于外部引入模块的结构体时,如果有私有属性,则需要提供实例化方法。

// 调整src/user.rs ,提供实例化方法
pub struct model {
    pub name: String,
    age: i32,
    status: status,
}
impl model {
    pub fn new(name: String) -> Self {
        Self {
            name,
            age: 35,
            status: status::online,
        }
    }
}

// 在src/main.rs
fn main(){
    let u = model::new(String::from("hboot"));
}

通过use引入,如果遇到同名类型时,引入路径可以只写到模块名称,然后通过模块名称调用方法、类型。

// 在main.rs中
use user::work;

mod user;

fn main(){
    let u2 = work::init_user();
    print!("{:?}", u2)
}

注意上面实例可被打印,修改模块 user 定义的结构体、枚举#[derive(Debug)]

也可通过 as关键字提供一个别名。

// 在main.rs中
use user::work::init_user as initUser;

mod user;

fn main(){
    let u2 = initUser();
}

可通过pub use继续导出到外部作用域使用。这可以避免路径过长引入,可以将子模块的定义导入到父级模块中。再重导出。

pub use user::work;

这样对于当前作用域路径的上级,可以继续导入 work 模块使用。

当一个功能模块子模块很多时,就需要从一个模块中导出很多的类型、结构体、方法等。就会出现很多use行,使用嵌套路径路径消除这种引入。

上面的示例已经展示了如何引入多个定义类型。

use user::model;
use user::status;

// 嵌套一行搞定
use user::{model, status};

也可以通过 glob 运算符*导入所有的公用项。

use crate::user::*

错误处理

在程序遇到错误时,分为可恢复和不可恢复。可恢复问题比如访问数据、文件未访问到,可通过日志方式告知用户;不可恢复问题比如越界,需要中止执行。

通过Result<T,E>处理可恢复的错误;panic!宏处理不可恢复的错误。终止程序执行。

通过panic!可以直接抛出一个错误。

fn main() {
    panic!("hello world");
}

程序执行到此处会终止执行,并报出错误打印从出 hello world。可以看到错误出现的代码位置信息

通过错误提示,可以设置环境变量RUST_BACKTRACE=1,查看调用栈信息

$> RUST_BACKTRACE=1 cargo run

发布生产环境包时,可以将panic禁止掉,从而得到更小的二进制文件。

# Cargo.toml
[profile.release]
panic='abort'

处理可恢复的错误

有一些错误不影响程序允许的情况,我们需要给出错误时得处理方案。

Result枚举类,标识程序方案按预期或者错误。

enum Result<T, E> {
    Ok(T),
    Err(E),
}

Result及其成员被提前导入。

比如读取文件时,如果文件存在,则读取成功,状态为Ok,类型 T 则为std::fs::File文件

use std::fs::File;

fn main(){
    let read_fs = File::open("hello.txt");

    // 通过match
    let read_file = match read_fs {
        Ok(file) => file,
        Err(error) => panic!("error info:{:#?}", error),
    };
}

因为我们文件目录下没有hello.txt文件,就会执行中断,报错。我们处理错误时,如果是文件未找到,则直接从创建一个。

use std::fs::File;
use std::io::ErrorKind;

fn main(){
    let read_fs = File::open("hello.txt");

    let read_file = match read_fs {
        Ok(file) => file,
        Err(error) => match error.kind() {
            ErrorKind::NotFound => match File::create("hello.txt") {
                Ok(f) => f,
                Err(err) => panic!("error in create file:${:?}", err),
            },
            other_error => {
                panic!("error info:{:#?}", error)
            }
        },
    };
}

执行后,在项目根目录下会生成hello.txt文件。如果是没有权限访问时,则还是打印输出错误。

我们写了很多的match来处理不同的情况,这看起来很让人难以理解。通过unwrapexpect简写处理

unwrap()方法调用,如果文件访问到,则返回Ok;读取不到则返回Err.

let read_file = File::open("hello.txt").unwrap();

通过expect()方法调用可以达到同样的功能。但是它允许我们自定义错误信息。

let read_file = File::open("hello.txt").expect("无法读取 hello.txt!!!");

有一些错误在每个方法中处理重复、麻烦。可以将错误信息传递到调用方,然后统一处理。

use std::fs::File;
use std::io::{self, ErrorKind, Read};

fn read_file() -> Result<String, io::Error> {
    let file_result = File::open("hello.txt");

    let mut file_name = match file_result {
        Ok(file) => file,
        Err(e) => return Err(e),
    };

    let mut name = String::new();

    match file_name.read_to_string(&mut name) {
        Ok(_) => Ok(name),
        Err(e) => Err(e),
    }
}

fn main(){
    let read = read_file();
    println!("{:?}", read)
}

hello.txt中写一段话,则会被打印出来。删除hello.txt文件,则打印的是错误信息。

通过运算符?简写,处理错误信息,替代match的返回错误信息。

fn read_file() -> Result<String, io::Error> {
    let mut file_name = File::open("hello.txt")?;

    let mut name = String::new();

    file_name.read_to_string(&mut name)?;
    Ok(name)
}

使用?简化了很多代码,还可以通过链式调用操作,使代码更为简短。

fn read_file() -> Result<String, io::Error> {
    let mut name = String::new();

    File::open("hello.txt")?.read_to_string(&mut name)?;
    Ok(name)
}

rust 还提供了更为方便的写法,fs::read_to_string,它做了这些事情:打开文件、新建一个 String、读取文件的内容。将内容放入 String,并返回它。

fn read_file() -> Result<String, io::Error> {
    fs::read_to_string("hello.txt")
}

?运算符只能用在返回值为Result类型的方法中。

什么情况下使用panic!

在更多的情况,我们都希望程序不要中断执行,所以处理结果返回Result类型是最好的选择。

还有一些,希望不执行的情况。

  • 示例代码、测试运行出错时,可以中断执行,执行panic!
  • 当我们很明确某种情况下程序是不能继续运行的。并给出错误信息和其他有用的信息,
  • 创建自定义类型进行有效性验证

panic!代表了无法处理的错误。停止执行以防止代码继续执行出现的不可预估的错误。

热门相关:峡谷正能量   天神诀   民国之文豪崛起   半仙   前任无双