lloydmeta / frunk

Funktional generic type-level programming in Rust: HList, Coproduct, Generic, LabelledGeneric, Validated, Monoid and friends.
https://beachape.com/frunk/
MIT License
1.28k stars 58 forks source link

Introduce Transmogrifying from A to B #124

Closed lloydmeta closed 6 years ago

lloydmeta commented 6 years ago

This PR adds the ability to do "transmogrifying" (inspired by this Haskell repo and this Scala SO answer) to Frunk.

What is "transmogrifying"? In this context, it means to convert some data of type A into data of type B, in a typesafe, recursive way, as long as A and B are "similarly-shaped". In other words, as long as B's fields and their subfields are subsets of A's fields and their respective subfields, then A can be turned into B.

As usual, the goal with Frunk is to do this:

I think the compiler should be able to compile away most, if not all, of the performance penalties this imposes, much like it does for non-recursive "sculpting", but benchmarks will follow in another PR.

In any case, here is an example:

use frunk::labelled::Transmogrifier;
#[derive(LabelledGeneric)]
struct InternalPhoneNumber {
    emergency: Option<usize>,
    main: usize,
    secondary: Option<usize>,
}

#[derive(LabelledGeneric)]
struct InternalAddress<'a> {
    is_whitelisted: bool,
    name: &'a str,
    phone: InternalPhoneNumber,
}

#[derive(LabelledGeneric)]
struct InternalUser<'a> {
    name: &'a str,
    age: usize,
    address: InternalAddress<'a>,
    is_banned: bool,
}

#[derive(LabelledGeneric, PartialEq, Debug)]
struct ExternalPhoneNumber {
    main: usize,
}

#[derive(LabelledGeneric, PartialEq, Debug)]
struct ExternalAddress<'a> {
    name: &'a str,
    phone: ExternalPhoneNumber,
}

#[derive(LabelledGeneric, PartialEq, Debug)]
struct ExternalUser<'a> {
    age: usize,
    address: ExternalAddress<'a>,
    name: &'a str,
}

let internal_user = InternalUser {
    name: "John",
    age: 10,
    address: InternalAddress {
        is_whitelisted: true,
        name: "somewhere out there",
        phone: InternalPhoneNumber {
            main: 1234,
            secondary: None,
            emergency: Some(5678),
        },
    },
    is_banned: true,
};

/// Boilerplate-free conversion of a top-level InternalUser into an
/// ExternalUser, taking care of subfield conversions as well.
let external_user: ExternalUser = internal_user.transmogrify();

println!("{:?}", external_user);

let expected_external_user = ExternalUser {
    name: "John",
    age: 10,
    address: ExternalAddress {
        name: "somewhere out there",
        phone: ExternalPhoneNumber {
            main: 1234,
        },
    }
};

assert_eq!(external_user, expected_external_user);
ExpHP commented 6 years ago

I'm not entirely sure I understand the purpose of PluckedValue, but this test has me worried:

fn test_transmogrify_simple_identity() {
    let one: PluckedValue<i32> = PluckedValue(1);
    let one_again: i32 = one.transmogrify();
    assert_eq!(one_again, 1);
}

I can't put my finger on exactly why I feel this way... but it feels to me like there are demons lurking just beneath the surface here, waiting for their chance to jump out and cause type inference errors.

lloydmeta commented 6 years ago

@ExpHP Thanks for the quick review :) IIRC I had to do use it in order to get make it so that the following 3 wouldn't clash with the non-trivial case where we needed to pluck things out.

/// Implementation of Transmogrifier for when the target is empty and the Source is non-empty
impl<SourceHead, SourceTail> Transmogrifier<HNil, HNil> for HCons<SourceHead, SourceTail> {
    #[inline(always)]
    fn transmogrify(self) -> HNil {
        HNil
    }
}

As I mentioned in the reply to your comment, the straight labelled-HList version doesn't compile at this point (though I think at sometime during my experimentation, I had something like that working), but the following, with LabelledGeneric s does, even though it's largely the same structurally.

  1. Identical structure transform

    #[derive(LabelledGeneric)]
    struct InternalPerson<'a> {
        name: &'a str,
        age: usize,
        is_banned: bool,
    }
    
    #[derive(LabelledGeneric, Debug)]
    struct ExternalPerson<'a> {
        name: &'a str,
        age: usize,
        is_banned: bool,
    }
    
    let internal_user = InternalPerson {
        name: "John",
        age: 10,
        is_banned: true,
    };
    
    let external_user: ExternalPerson = internal_user.transmogrify();
    println!("{:?}", external_user);
  2. Non-identity transform where a labelled field gets an identity transform

    
    #[derive(LabelledGeneric)]
    struct InternalPhoneNumber {
        emergency: Option<usize>,
        main: usize,
        secondary: Option<usize>,
    }
    
    #[derive(LabelledGeneric)]
    struct InternalAddress<'a> {
        is_whitelisted: bool,
        name: &'a str,
        phone: InternalPhoneNumber,
    }
    
    #[derive(LabelledGeneric)]
    struct InternalPerson<'a> {
        name: &'a str,
        age: usize,
        address: InternalAddress<'a>,
        is_banned: bool,
    }
    
    #[derive(LabelledGeneric, Debug)]
    struct ExternalPhoneNumber {
        main: usize,
    }
    
    #[derive(LabelledGeneric, Debug)]
    struct ExternalAddress<'a> {
        name: &'a str,
        phone: ExternalPhoneNumber,
    }
    
    #[derive(LabelledGeneric, Debug)]
    struct ExternalPerson<'a> {
        name: &'a str,
        age: usize,
        address: ExternalAddress<'a>,
        is_banned: bool,
    }
    
    let internal_user = InternalPerson {
        name: "John",
        age: 10,
        address: InternalAddress {
            is_whitelisted: true,
            name: "somewhere out there",
            phone: InternalPhoneNumber {
                main: 1234,
                secondary: None,
                emergency: Some(1234),
            },
        },
        is_banned: true,
    };
    
    let external_user: ExternalPerson = internal_user.transmogrify();
    println!("{:?}", external_user);

It would be really awesome if you could check out this branch and kick the tires so to speak and play around with it locally to see if you could figure out where I went wrong here and see if we can't get the straight-labelled HList version working (again) too; getting it working to this point for the LabelledGeneric usage my main aim and it was already quite taxing on my type-foo-mana 😅 .

lloydmeta commented 6 years ago

Adding a PoC label as well just to make it clear this is early stages and needs all the help it can get :)

lloydmeta commented 6 years ago

@ExpHP so, I managed to get rid of the PluckedValue thing by leaning on Field<Name, Value> as a wrapper instead :) Thanks for pushing me towards getting rid of one more unnecessary task :)

ExpHP commented 6 years ago

So, unfortunately, it doesn't really surprise me that one of those tests didn't work, and I'm pretty sure it's impossible to fix. The fundamental problem is that the only feasible way to let primitive and std types like u32 transform into u32 is with a blanket impl for T -> T, and this must necessarily overlap with the specialized impls on HList.

Well, I shouldn't say that's the only feasible way. It's clear from the Haskell package's README how they handle it:

An explicit list of types is needed to be able to stop type recursion; this is currently limited to numeric types and Char.

which is a pretty serious limitation.

IMO, it is too easy to see the man behind the curtain, and it's too easy to break working impls by removing a field from the source type or adding one to the target type.

ExpHP commented 6 years ago

Absolutely insane idea. Make of this what you will.

#[derive(FlatLabelledGeneric)]
struct Field {
    apples: u32,
    oranges: u32,
}

#[derive(FlatLabelledGeneric)]
struct Thing {
    number: u32,
    #[frunk(flatten)]
    field: Field,
    string: String,
}

// <Thing as FlatLabelledGeneric>::Repr will be
//
// Hlist![
//     field!("number", u32),
//     field!("field.apples", u32),
//     field!("field.oranges", u32),
//     field!("string", String),
// ]
//
// where field!("string literal", ...) is understood to construct the type-level
// string corresponding to that literal
//
// the fields added through #[frunk(flatten)] are produced without
// knowledge of that type's definition, with the help of this trait:

/// Prefixes every field name in an HList of Fields with a fixed string.
trait PrefixFieldLabels<PrefixStr> {
    type Prefixed;

    fn prefix_field_labels(self) -> Self::Prefixed;
}

With that, the implementation of transmogrify becomes:

let repr = FlatLabelledGeneric::into(self);
let mogged = repr.sculpt();
FlatLabelledGeneric::from(mogged)

and correctly handles all fields, at the cost of the user needing to maintain the #[frunk(flatten)] tags.

lloydmeta commented 6 years ago

Absolutely insane idea. Make of this what you will.

That's a really cool idea :D I'd probably need a bunch more time to explore it and build something that works 😄 I think one challenge there might be finding a way to unflatten the keys back out; super interesting though and I'm keen to explore in a follow up attempt to improve this !

lloydmeta commented 6 years ago

Gonna merge this in for now since it feels mostly Good Enough and I don't want Perfect to be the enemy of Good. ; I think the overall traits + type-param signatures will probably remain more-or-less the same and we can always re-iterate on the implementation later.