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.

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