Idea: Explicit implicit conversion

Yes, you read the title right! I was to add a new explicit way to apply an implicit conversion on multiple branches.


EDIT for future readers. This post and the following discussions touch 3 very related by different topics:

  • allowing more unification in the branches of if/match/loop/for/while.
  • anonymous enum with named variant like ParseIntError | ParseFloatError
  • compiler-generated anonymous enum that implement a trait like impl Iterator<Item=Foo>

I certain cases, we need to use .into() many times.

// we assume that the `From` and `Into` traits are implemented
let output: Frob = match something {
    Foo(a) => foo(a).into(),
    Bar(b) => bar(b).into(),
    Baz(c) => baz(c).into(),
};

It would be obviously more concise to have implicit conversion, but explicitness is an important part of Rust. Unfortunately, we can't use the From trait instead of Into since the arms don't match:

// this doesn't compiles
let output = Frob::From(match something {
    Foo(a) => foo(a),
    Bar(b) => bar(b), // incompatible type with `foo(a)`
    Baz(c) => baz(c), // incompatible type with `foo(a)`
});

I suggest the introduction of a new keyword that would takes all branches or the right-hand side, and apply .into() to them. This convertion logic is very similar to what the ? operator is doing with the Err.

let output: Frob = become match something {
    Foo(a) => foo(a), // implicit `.into()`
    Bar(b) => bar(b),
    Baz(c) => baz(c),
};

Lets take a more realistic example: combining two iterators that output the same type.

enum EitherIterator<It1: Iterator, It2: Iterator> {
    It1(It1),
    It2(It2),
}

impl<It1, It2> Iterator for EitherIterator<It1, It2>
where
    It1: Iterator,
    It2: Iterator,
    It2::Item: Into<It1::Item>,
{
    type Item = It1::Item;
    fn next(&mut self) -> Option<Self::Item> {
        match self {
            EitherIterator::It1(it) => it.next(),
            EitherIterator::It2(it) => {
               it.next().map(|item| item.into())
            },
        }
    }
}
fn main() {
    let v: [usize; 4] = [1, 2, 3, 4];
    let iter: EitherIterator<_,_> = if rand::random() {
        EitherIterator::It1(v.iter().copied())
    } else {
        EitherIterator::It2(v.iter().rev().map(|x| x*3))
    };
    for it in iter {
        print!("{} ", it);
    }
}
More general version where the Output can be user-defined
enum EitherIterator<It1, It2, Out> 
where
    It1: Iterator,
    It2: Iterator,
    Out: From<It1::Item>,
    Out: From<It2::Item>,
{
    It1(It1),
    It2(It2),
    Marker(std::marker::PhantomData<Out>),
}

impl<It1, It2, Out> Iterator for EitherIterator<It1, It2, Out>
where
    It1: Iterator,
    It2: Iterator,
    Out: From<It1::Item>,
    Out: From<It2::Item>,
{
    type Item = Out;
    fn next(&mut self) -> Option<Out> {
        match self {
            EitherIterator::It1(it) => it.next().map(|item| item.into()),
            EitherIterator::It2(it) => it.next().map(|item| item.into()),
            EitherIterator::Marker(_) => panic!(),
        }
    }
}

fn main() {
    let v: [i16; 4] = [1, 2, 3, 4];
    let iter: EitherIterator<_,_,f32> /* impl Iterator<Item=f32> */ = if rand::random() {
        EitherIterator::It1(v.iter().copied()) // Iterator<Item=u16>
    } else {
        EitherIterator::It2(v.iter().rev().map(|x| (*x as i16) * -1)) // Iterator<Item=i16>
    };
    for it in iter {
        print!("{} ", it);
    }
}

This new become keyword would simplify the call site and improve readability:

// EitherIterator is implemented the same way
fn main() {
    let v: [usize; 4] = [1, 2, 3, 4];
    let iter: EitherIterator<_,_> = become if rand::random() {
        v.iter().copied() // no more boilerplate here
    } else {
        v.iter().rev().map(|x| x*3) // nor there
    };
    for it in iter {
        print!("{} ", it);
    }
}

As such, this feature is useful, but probably not enough to hold its weight. However, if we combine it with impl Trait type ascription, this can become extremely powerful: the whole EitherIterator enum be compiler generated. EDIT: This obviously need compiler support to be able to generate such kind of anonymous enum that implement a given trait.

// No need to write EitherIterator, the compiler could do it for us
fn main() {
    let v: [usize; 4] = [1, 2, 3, 4];
    let iter: impl Iterator<Item=usize> = become if rand::random() {
        v.iter().copied()
    } else {
        v.iter().rev().map(|x| x*3)
    };
    for it in iter {
        print!("{} ", it);
    }
}

Another example:

fn forward_or_reverse<T>(v: &Vec<T>, forward: bool) -> impl Iterator<Item=&T> {
    become if forward {
        v.iter()
    } else {
        v.iter().rev()
    }
}
2 Likes

The compiler doesn’t know that you intend to use an EitherIterator here at all. You cannot even implement the relevant From/Into implementations that are needed to support these conversions, but even then everything is still ambiguous.

For a practical solution to your concrete example that works today, do something like:

use either::Either::*;
// No need for any weird new features in Rust at all!
fn main() {
    let v: [usize; 4] = [1, 2, 3, 4];
    let iter = if rand::random() {
        Left(v.iter().copied())
    } else {
        Right(v.iter().rev().map(|x| x*3))
    };
    for it in iter {
        print!("{} ", it);
    }
    // prints 1 2 3 4 
    //     or 12 9 6 3
}

(playground)

1 Like

I didn't meant that I want to use EitherIterator specifically (it's of course totally impossible for the compiler to guess it), but any anonymous object, that would be generated by the compiler, and implement Iterator<Item=usize> as requested by the user. In practice such compiler generated anonymous object would be an anonymous enum (RFC to be proposed, discussed, accepted is left as an exercise ;)).

1 Like

Instead of inventing new syntax for this, what if the type constraints on match were relaxed such that you could write

let output = Frob::From(match something {
    Foo(a) => foo(a),
    Bar(b) => bar(b),
    Baz(c) => baz(c),
});

or equivalently

let output: Frob = (match something {
    Foo(a) => foo(a),
    Bar(b) => bar(b),
    Baz(c) => baz(c),
}).into();

The new rule would be that when match is a subexpression, each of the arms may yield a different type as long as each satisfies the type requirements of the outer expression, and the outer expression yields the same type in each case. I'm not great with the formal theory of types so I may not have phrased that coherently, but I think the concept should be clear.

Another way to describe it would be in terms of pushing the outer expression down to each match arm:

let v = fun(match something { Foo(a) => foo(a), Bar(b) => bar(b) });

would be valid if and only if

let v = match something { Foo(a) => fun(foo(a)), Bar(b) => fun(bar(b)) };

would also be valid.

1 Like

You are doing exactly the same thing than what I did, except that you do it implicitly. The only reason to have an explicit keyword is to be able to search for implicit conversions. Currently, all places that have conversions have a marker, like ?, as, .into(), …

And to be consistent, the constraint of if, match, all loops (including break with value), and if let need to be relaxed (with or without keyword).

2 Likes

I don't see how there's any more implicitness in my suggestion than yours? There is still an .into() call (or whatever), it's just written once, on the outside of the match.

The advantage of doing it my way is not introducing new syntax.

to be consistent, the constraint of if , match , all loops (including break with value), and if let need to be relaxed

Yes, I suppose so. I can definitely see the utility for if [let] chains where each alternative produces a value. Not so sure about loops; the only case where I can think it would come up is

let v = (for x in xs {
  match x {
    Target1(val) => break val,
    Target2(val) => break val,
    _ => ()
  }
}).into();

and to me this seems pretty unnatural to write by hand. But I suppose it might arise after macro expansion or some such.

2 Likes

You can also have

let value = loop {
    let data = get_data();
    if data.is_frob() {
        break data;
    }
    let foo = data.make_foo();
    if foo.has_bar() {
        break foo;
    }
}

I think it could be useful when creating an error among a list of multiple possible error type.

I've run into this problem in several places, too, and it would sure be nice to have a solution. But, that's because I don't see this issue as being limited to conversion: I think it applies to any trait. So the problem in my view is a bunch of repetition due to avoiding the overhead of boxing a trait object (leaving aside the can of worms due to some traits not being object safe).

2 Likes

This issue of conversion has also come up in error handling, where Rust was supposed to automagically invent anonymous enums for possible error types:

fn parse() -> Result<(), ParseIntError | ParseFloatError> 

So I suggest exploring idea of anonymous enums where types don't match, and having them automatically implement from/into for their variants. That would be more general-purpose than spicy match.

let unified = match { a => a.enum, b => b.enum }.into();
let either_iter = if reverse { iter.rev().enum } else { iter.enum };

(I've changed it to postfix .enum, because since .await prefixes are uncool :wink: )

2 Likes

Perhaps match should perform more unification and evaluate to a dyn Into<Frob>. This could be more general with dependent types, but that is a bigger war.

@kornel That's not the same use-case even if it's very close. In the case of error handling, we want to know what are the possible variant in the anonymous enum ParseIntError | ParseFloatError while in the case of anonymous enum implementing a trait, don't want to know what are the concrete types impl Iterator<Item=Foo>.

And btw, the syntax you proposed is the same than the one I used except for the color of the bike :wink: (become versus enum).

Also using become to allow more unification can allow allow more use-cases, since it could works for regular enum like EitherIterator, anonymous enum with visible variant like ParseIntError | ParseFloatError and compiler-generated anonymous enum that implements a trait like impl Iterator<Item=Foo>.

This was my initial idea a few month ago, but there seems to be a strong opposition against implicit conversion. If Rust get anonymous enum that implements traits, the following construction should still fail, because both the writer and the reader may expect that no conversion occurs.

let data = [0, 1, 2, 3];
let no_conversion: impl Iterator<Iter = usize> = data.iter();
let maybe_conversion: impl Iterator<Iter = usize> = if something {
    data.iter()
} else {
    data.iter().rev()
};

Both of the branches, even if the both yield a value than implements Iterator<Item = usize> have different types. Currently this fail to compile, and without become if should stay the same. Whereas with become if this would apply an implicit conversion to an anonymous enum implementing the Iterator<Item=usize> trait.

Adding become would in practice just means "allow more unification for the following if/match/loop/for/while", but with an explicit marker instead of being implicit.

1 Like

As alternative notation we could use match::<T> to say the branches should be interpreted as type T.

let output = Frob::From(match::<dyn Into<Frob>> something {
    Foo(a) => foo(a),
    Bar(b) => bar(b), // incompatible type with `foo(a)`
    Baz(c) => baz(c), // incompatible type with `foo(a)`
});

And then we could also have if::<T> { exp1 } else { exp2 }. It is a bit ugly, but it could offer more control.

3 Likes

I don't think that it gives more control that a let with an explicit type annotation. And it dupplicates information that the type checker have already.

If anonymous sum types are going to be implementated, I don't think we'll need another keyword to do the conversion. We could just write

let x: Foo | Bar = if .. {
    Foo
} else {
    Bar
};

This becomes even more concise with type ascription.

I'm not sure how to handle impl Trait conversions though. Maybe this could "just work":

let x: impl Iterator<Item = i32> = if .. {
    Foo
} else {
    Bar
};

For this to work, the compiler would have to detect that the branches have different types, and automatically cast them to Foo | Bar (and this should work even if the type can't be named, e.g. because it contains a closure).

2 Likes

First, I absolutely agree with the motivation, this is a problem and I'd like to see a solution. Thank you for picking it up.

That being said, I'm not really a fan of one-off solutions and new keywords. It would be great if no new keyword was needed and if it could work to any trait, not just for Into. I much more like moving in the general direction of automatic impl Trait or the A | B types mentioned here. All of that probably needs a lot of problems being solved.

2 Likes

This was my initial idea, but people disagree with this, because they don't like that the compiler does modification in their back.

fn foo_or_bar() -> Foo | Bar { ... }
fn foo() -> Foo { ... }

let a: Foo | Bar = foo(); // implicit convertion?
let b: Foo | Bar = foo_or_bar(); // no conversion needed
let c: Foo | Bar | Baz = foo(); // implicit convertion?
let d: Foo | Bar | Baz = foo_or_bar(); // implicit convertion?

fn get_iter() -> impl Iterator<Item=Foo> { ... }
fn get_dyn_iter() -> dyn Iterator<Item=Foo> { ... }

let e: impl Iterator<Item=Foo> = get_iter(); // no convertion
let f: impl Iterator<Item=Foo> = get_dyn_iter(); // auto-boxing?

Having an explicit keyword (that can totally be removed in a later edition if deemed unnecessary allow to experiment with the idea without having all the downside of implicit transformations.

It would be great if no new keyword was needed and if it could work to any trait, not just for Into

That become keyword, would today work only for Into, but if impl anonymous enum or auto-boxing where added to the language, they could totally re-use it.

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.