Rust:5种实现编译时多态的方式

719 阅读6分钟

在 Java、C#、C++、Kotlin 等语言中,我们可以定义多个同名函数,但它们的参数不同:

void connect(int ip, int port) { … }
void connect(int ip) { … }
void connect(string address) { … }

这种语言特性被称为“函数重载(编译时多态)”。就我个人而言,在这些语言中编程多年后,我已经习惯了这种特性,并期望语言能够支持它。但如果你在 Rust 中尝试这样做:

fn add(a: i32, b: i32) {}
fn add(a: u32, b: u32) {}

你会得到一个错误:

image.png

但多态并不仅仅局限于函数重载。你还可以做更多事情并称之为多态。让我们来讨论一下。

1. 枚举(健壮的语法)

enum ServerAddress {
    Ip(u32), // IPv4 是 32 位(4 字节)
    IpAndPort(u32, u16),
    Address(String),
}

fn connect(address: ServerAddress) {
    match address {
        ServerAddress::Ip(ip) => {},
        ServerAddress::IpAndPort(ip, port) => {},
        ServerAddress::Address(address) => {},
    }
}

你可以这样调用:

connect(ServerAddress::Ip(1));
connect(ServerAddress::IpAndPort(1, 80));
connect(ServerAddress::Address("192.168.1.1:80".to_string()));

使用枚举有时是动态的,有时是静态的。如果编译器能够在编译时推断出枚举的变体,它会跳过运行时的类型检查,从而提高速度。但如果变体在编译时未知,它会在运行时进行类型检查,这会带来性能开销。

你可能会觉得这种语法过于复杂和冗长,是的,确实如此!但通过阅读函数调用,你能获得更多信息:


// Java
connect("192.168.1.1:80");
// 这意味着有一个名为 'connect' 的函数,接受一个字符串参数。

// Rust
connect(ServerAddress::Address("192.168.1.1:80".to_string()));
// 这意味着你可以用一个字符串参数调用 connect 函数,
// 但还有更多选项,你可以在 ServerAddress 枚举中找到其他选项。

虽然冗长,但阅读时能获得更多信息。

不过!!还有更多方法,让我继续“烹饪” 🧑‍🍳。

2. Trait(编译时且语法简洁)👽

不同的函数重载只是为调用者提供了多种选择,而 connect 函数实际上只需要底层的 ip: u32 和 port: u16。因此,我们可以为此定义一个结构体。

struct NetworkAddress {
    ip: u32,
    port: u16,
}

fn connect(address: NetworkAddress) {}

有一个通用的 trait 叫 Into,用于类型转换。我们可以为 NetworkAddress 实现 Into,以便将其他类型转换为 NetworkAddress。

但建议实现 From 而不是 Into,这样会自动为我们实现 Into,从而可以调用 into() 函数。

例如,为 u32 实现 From:

// 仅 IP,默认端口 80
impl From<u32> for NetworkAddress {
    fn from(value: u32) -> Self {
        NetworkAddress { ip: value, port: 80 } // 80 作为默认端口
    }
}

就是这样!现在我们可以用 u32 调用 connect:

// 这会通过调用你的转换函数将 u32 转换为 NetworkAddress。
connect(1.into());

现在我们可以为任何需要的类型实现这个 trait:

impl From<&str> for NetworkAddress {
    fn from(value: &str) -> Self {
        todo!(); // 解析地址
    }
}

调用者可以这样:

connect("192.168.1.1:80".into());
那么,对于 (ip: u32, port: u16) 这种参数数量不同的情况呢?在这种情况下,你可以使用元组:
impl From<(u32, u16)> for NetworkAddress {
    fn from(value: (u32, u16)) -> Self {
        NetworkAddress { ip: value.0, port: value.1 }
    }
}

调用者会有些奇怪:

connect((1, 80).into()); // (1,80) 是一个 (u32, u16) 元组
// 语法有点丑陋,我知道。

当然,你也可以直接传递 NetworkAddress:

connect(NetworkAddress { ip: 1, port: 80 });

这种方法的好处是它非常可扩展。调用者可以为任何需要的类型实现 Into,而这在枚举技术中是不可能做到的,除非修改枚举本身。

如果需要更简洁的语法,你可以将 into() 调用移到 connect 函数内部:

// 这意味着该函数的输入参数应该实现 Into<NetworkAddress> trait
// 这也是静态分发
fn connect(address: impl Into<NetworkAddress>) {
    let address: NetworkAddress = address.into();
}

现在你可以从调用者中移除 .into():

connect("192.168.1.1:80"); // &str
connect(NetworkAddress { ip: 1, port: 80 }); // NetworkAddress
connect((1, 80)); // 元组 (u32, u16)

“好的、坏的和丑的。”

注意:使用 impl 时,它是静态分发,意味着类型检查在编译时进行,但你也可以使用 dyn 关键字以动态分发的方式实现,这在运行时会慢一些,但更灵活。

缺点是,如果你想传递多个参数给函数,你需要使用额外的“( )”,这很丑陋(我们会解决这个问题,让我继续“烹饪” 🧑‍🍳!好吗?)

3. 不同的函数名(简单 🤷‍♂️)

在讨论了 trait 和枚举之后,这看起来可能有些愚蠢,但有时这样做是可以的。


fn connect_with_ip(ip: u32) { … }
fn connect_with_ipv6(ip: u128) { … }
fn connect_with_ip_port(ip: u32, port: u16) { … }
fn connect_with_ipv6_port(ip: u128, port: u16) { … }
fn connect_with_address(addr: String) { … }

很多人更喜欢这种方法(我也是),有趣的是,Rust 标准库中大量使用了这种技术,为什么不呢?🤷‍♂️:

Vec::new();
Vec::with_capacity(usize);
Vec::from_raw_parts(ptr: *mut T, length: usize, capacity: usize);
// 所有这些函数都是 Vec 的工厂函数,但名称不同。

这种技术也常与宏结合使用。

4. 宏(10x 开发者 🥷)

假设我们有两个参数数量不同的函数:

fn connect_with_ip(ip: u32) {} // 一个参数
fn connect_with_ip_and_port(ip: u32, port: u16) {} // 两个参数

我们可以简单地编写一个宏:

macro_rules! connect {
    ($param:expr) => {
        connect_with_ip($param);
    };
    ($param1:expr, $param2:expr) => {
        connect_with_ip_and_port($param1, $param2);
    };
}

这个宏会在编译时为你生成代码,并根据你传递的参数数量调用相应的函数。

现在我们可以调用宏:

connect!(12); // 生成:connect_with_ip(12);
connect!(12, 80); // 生成:connect_with_ip_and_port(12, 80);
// 没有丑陋的 () 元组语法

5. 宏 + Trait(20x 开发者 🧙‍♂️)

最后的终极解决方案!

macro_rules! connect {
    ($param:expr) => {
        connect_with_ip($param);
    };
    ($param1:expr, $param2:expr) => {
        connect_with_ip_and_port($param1, $param2);
    };
}

struct IP(u32);

impl From<u32> for IP {
    fn from(value: u32) -> Self {
        IP(value)
    }
}

impl From<&str> for IP {
    fn from(value: &str) -> Self {
        todo!() // 解析
    }
}

fn connect_with_ip(ip: impl Into<IP>) {
    let ip: IP = ip.into();
}

fn connect_with_ip_and_port(ip: impl Into<IP>, port: u16) {
    let ip: IP = ip.into();
}

fn main() {
    connect!("192.168.1.1", 80);
    connect!("192.168.1.1");
    connect!(1234, 80);
    connect!(1234);
}

好处:

没有“( )”和元组语法!(没有丑陋的语法) 支持不同数量的参数 静态分发 没有 into() 和 try_into() 调用 当你编写宏并告诉你的技术宅朋友时,你会感觉像个忍者!他们会觉得这像外星语言一样(主要好处)。 总结 如你所见,你可以将不同的技术混合使用:

枚举 + Trait 宏 + Trait(已解释) 枚举 + 宏 + Trait 枚举 + 宏? 通过这些技术,你可以最大化函数的灵活性。