Transpose versus try_map

I'm gonna be honest: I never liked {Option,Result}::transpose.

I mean, on the one hand, it's one of those tools that you can't possibly use incorrectly (due to the type system), but on the other hand:

  • I don't like the fact that they have the same name. When I see a call to transpose(), it's not easy to tell if it's converting from Option<Result<T>> to Result<Option<T>> or the other way around.
  • I find it conceptually hard to deal with, because it takes two layers of an abstraction (both closely associated with control flow) and pulls them inside-out. I feel this obscures the flow of control versus simply constructing the right type to begin with.
  • As a personal issue, I write a lot of numerical code that deals with matrices. Having a call to .transpose() in such code could look extremely misleading, so I wrote an extension trait to rename it to fold_ok().

But even more notably: Today, I had just written some code that used transpose when it suddenly dawned on me what the abstraction is that I was really looking for:

impl<T> Option<T> {
    fn try_map<B, E>(
        self,
        func: impl FnOnce(T) -> Result<B, E>,
    ) -> Result<Option<B>, E> {
        self.map(func).transpose()
    }
}

I searched my codebase, and sure enough, every single call to transpose (fold_ok in the bulk of my codebase) was immediately preceded by a call to map:

opt.map(|s| {
    let tokens = lex(s.span(), &format!("where {}", &s[..]))?;
    Ok(syn::parse2(tokens)?)
}).transpose()

// --------

*bonds = settings.bond_radius.map(|bond_radius| FailOk({
    Rc::new(FracBonds::compute(&original_coords, bond_radius)?)
})).fold_ok()?

// --------

nonempty_var("RSP2_LOG_MOD")?
    .map(|s| match &s[..] {
        "1" => Ok(true),
        "0" => Ok(false),
        _ => bail!("Invalid setting for RSP2_LOG_MOD: {:?}", s),
    }).fold_ok()?
    .unwrap_or(false)

// --------

nonempty_var(MAX_OMP_NUM_THREADS)?
    .map(|s| s.parse())
    .fold_ok()?
    .unwrap_or(::num_cpus::get() as u32)

Isn't try_map the method that we really want?

3 Likes

That does seem like a reasonable change. I've used transpose in two places in actual codebases, once exactly like your usage:

func_returning_option(...)
    .map(|s| crate::util::base32768::decode(&s))
    .transpose()

And one doing something a bit different, which could probably be written without tranpose by using .map(Some)...

if val == Value::Null || val == Value::Undefined {
    Ok(None)
} else {
    Some(val.try_into()).transpose()
}

I'm not sure I'd prefer a try_map function to transpose, though. I think my functional programming background makes transpose meaning "switch the inner and outer containers" quite natural.

Not saying try_map is a bad idea, but I don't think transpose is entirely without merit. With transpose, I very clearly know that I'm not changing the data, and I'm not getting rid of any abstraction - I'm just switching out containers. If I have some data wrapped in two containers and I call transpose, I know for sure I get the same data, still wrapped in two containers. try_map isn't entirely as clear in that way - without knowing about it, I wouldn't really know whether to expect it to return Result<T, E> or again Option<Result<T, E>>, or as it actually would, Result<Option<T>, E>.

1 Like

try_map() has a nice name, but a suggestion to rename transpose() IMO should also have a good name for the reverse operation, ResultOption. It would obviously be different, but I can't come up with any alternative myself.

EDIT: For reference, this is the thread were the method was originally proposed. It also has links to the relevant PRs and tracking issues.