2
votes

I have a struct that has some common fields, but there are two fields that must contain related types. I also want to implement a new method that would allow combining only valid pairs. This would greatly improve struct ergonomics.

To illustrate that. I've got a struct named Query, which has the following fields:

#[derive(Debug)]
pub struct Query<V, U> {
    pub value: V,
    pub unit: U,
    pub name: String,
}

value can be either of Time or Distance type, unit can be either of TimeUnit or DistanceUnit type.

#[derive(Debug)]
pub struct Time;

#[derive(Debug)]
pub enum TimeUnit {
    Seconds,
    Hours,
    Days,
    Weeks,
    Months,
    Years,
}

#[derive(Debug)]
pub struct Distance;

#[derive(Debug)]
pub enum DistanceUnit {
    Meters,
    Kilometers,
    Miles,
}

Having such kind of structure makes it easy to implement new generic methods:

impl Query<Time, TimeUnit> {
    pub fn new(value: Time, unit: TimeUnit, name: String) -> Self {
        Self { value, unit, name }
    }
}

impl Query<Distance, DistanceUnit> {
    pub fn new(value: Distance, unit: DistanceUnit, name: String) -> Self {
        Self { value, unit, name }
    }
}

However this leaves a hole and allows creating such kind of struct manually:

/// Do not allow creating query with Time and DistanceUnit and vice-versa
fn do_not_allow_such_case() {
    let _ = Query {
        value: Time {},
        unit: DistanceUnit::Kilometers,
        name: "query".into(),
    };
}

The alternative and probably a better approach would be making impossible states impossible to represent, so I'd refactor struct by introducing an Enum with allowed pairs:

#[derive(Debug)]
pub enum Pair {
    Time(Time, TimeUnit),
    Distance(Distance, DistanceUnit),
}

#[derive(Debug)]
pub struct Query2 {
    pub query: Pair,
    pub name: String,
}

I like this way better, however I struggle to implement a new method as in previous case. My first thought was to implement From traits for previously declared enums and implement a generic new method where I could use these From traits as constraints:

impl From<(Time, TimeUnit)> for Pair {
    fn from(q: (Time, TimeUnit)) -> Self {
        Self::Time(q.0, q.1)
    }
}

impl From<(Distance, DistanceUnit)> for Pair {
    fn from(q: (Distance, DistanceUnit)) -> Self {
        Self::Distance(q.0, q.1)
    }
}
#[derive(Debug)]
pub enum ValueEnum {
    Time(Time),
    Distance(Distance),
}

impl From<Time> for ValueEnum {
    fn from(v: Time) -> Self {
        Self::Time(v)
    }
}

impl From<Distance> for ValueEnum {
    fn from(v: Distance) -> Self {
        Self::Distance(v)
    }
}
#[derive(Debug)]
pub enum UnitEnum {
    Time(TimeUnit),
    Distance(DistanceUnit),
}

impl From<TimeUnit> for UnitEnum {
    fn from(u: TimeUnit) -> Self {
        Self::Time(u)
    }
}

impl From<DistanceUnit> for UnitEnum {
    fn from(u: DistanceUnit) -> Self {
        Self::Distance(u)
    }
}

And this is my attempt to implement new method, but it would not compile.

impl Query2 {
    pub fn new<V, U>(value: V, unit: U, name: String) -> Self
    where
        V: Into<ValueEnum>,
        U: Into<UnitEnum>,
        /// No idea how to constrain the pair
    {
        Self {
            /// This would not allow compiling
            query: (value, unit).into(),
            name,
        }
    }
}

Is there a way to achieve what I want?

This is a link to Rust playground: https://play.rust-lang.org/?version=stable&mode=debug&edition=2018&gist=64ac6f5cb9b439e9943925c7ee8dd2e6

1

1 Answers

7
votes

I would use traits and associated types

use std::fmt::Debug;
pub trait Value {
    type Unit: Debug;
}

#[derive(Debug)]
pub struct Time;

impl Value for Time {
    type Unit = TimeUnit;
}

#[derive(Debug)]
pub enum TimeUnit {
    Seconds,
    Hours,
    Days,
    Weeks,
    Months,
    Years,
}

#[derive(Debug)]
pub struct Query<T: Value> {
    pub value: T,
    pub unit: <T as Value>::Unit,
    pub name: String,
}

impl<T: Value> Query<T> {
    pub fn new(value: T, unit: <T as Value>::Unit, name: String) -> Self {
        Self { value, unit, name }
    }
}

fn main() {
    let time_query: Query<Time> = Query::new(Time {}, TimeUnit::Seconds, "Seconds".to_owned());
}

Playground link

Another, somewhat simpler way to couple value and it's unit is to make unit a member of value. This approach has advantage of making API somewhat smaller, but the trade-off is that type of unit becomes essentially unnameable.

#[derive(Debug)]
pub struct Distance {
    unit: DistanceUnit,
}

#[derive(Debug)]
pub enum DistanceUnit {
    Meters,
    Kilometers,
    Miles,
}

#[derive(Debug)]
pub struct Query<T> {
    pub value: T,
    pub name: String,
}

impl Query<Distance> {
    pub fn new(value: Distance, name: String) -> Self {
        Self { value, name }
    }
}
fn main() {
    let time_query: Query<Distance> = Query::<Distance>::new(
        Distance {
            unit: DistanceUnit::Meters,
        },
        "Seconds".to_owned(),
    );
}

Playground link