Avoiding "Box::new(...) as Box<Trait>" repetition

So suppose I want to write to either io::stdout or a File depending on command line arguments. The sanest way I've found to do this is

use std::path::Path;
use std::fs::File;
use std::io;
use std::io::{Write, BufWriter};

fn write_output(dest: Option<&Path>, data: &MyData) -> io::Result<()> {
    let stdout = io::stdout(); // because dropcheck
    let ofp = BufWriter::new(match dest {
        None => Box::new(stdout.lock()) as Box<Write>,
        Some(path) => Box::new(File::create(path)?) as Box<Write>
    }
    serde_json::to_writer_pretty(ofp, data)?; // for instance
    Ok(())
}

I don't like having to write Box::new(...) as Box<Write> in each arm of the match. It's not so bad here, but I can imagine needing to do something similar with much larger alternations. Is there any way to write the boxing and partial type erasure only once for the entire match? Or, even better, not at all?

Ultimately you have to give the BufWriter something of the same type regardless of which case it is. The Box is one way to do this; perhaps you could get it to work using references (if A implements Write, then so does &mut A), or you could make an enum.

The enum version would avoid the heap allocation, so it's probably the most efficient version, but I'm not sure it's worth it unless you're doing this more than once.

You can get rid of at least the as casts:

let ofp: Box<dyn Write> = match dest {
    None => Box::new(stdout.lock()),
    Some(path) => Box::new(File::create(path)?),
};
let ofp = BufWriter::new(ofp);

It's not clear to me precisely how this works, given the way that coercions work, but I'm pretty certain I've used this in the past. (my best guess is that the compiler checks ahead to see if the match is located in a coercion site before it type-checks the match body; if so, every branch is coerced)

If you want to get rid of the Box::new(...)s, you'll need a macro.

4 Likes

The type checking of match scrutinee { pat_i => body_i } proceeds by (in order, uninteresting details elided):

  1. An expected type m_ty is provided.

  2. Inferring the type of the scrutinee to s_ty:

    Γ ⊢ scrutinee ⟹ s_ty

  3. Checking the patterns pat_i against s_ty:

    Γ ⊢ s_ty ⟸ pat_i

  4. Setting up a type to which coercions are applied, m_ty_c with m_ty as the first coercion.

  5. Checking and inferring body_i against m_ty into body_i_ty.
    Then applying a coercion to m_ty_c with body_i_ty.

  6. The final type is m_ty_c (the common super type of all the arms).

The expected type m_ty is passed down by the let binding since it is written out explicitly; the initalizer expression is checked against that expecation and the new type init_ty is store and thereafter the pattern is type checked against init_ty.

The relevant functions in the compiler are:

Rust follows a bidirectional type checking discipline; for the basics of that, see http://davidchristiansen.dk/tutorials/bidirectional.pdf.

6 Likes

Thanks for the suggestion; this is probably what I'm going to go with in my program.

I wondered if maybe I could just use a bare &dyn Write instead of boxing — it makes sense that we need virtual dispatch here, but a heap allocation should be avoidable. It almost works:

use std::path::Path;
use std::fs::File;
use std::io::{self, Write, BufWriter};

pub fn write_output(dest: Option<&Path>) -> io::Result<()> {
    let stdout = io::stdout(); // because dropcheck
    let ofp: &dyn Write = match dest {
        None => &stdout.lock(),
        Some(path) => &(File::create(path)?)
    };
    let ofp = BufWriter::new(ofp);
    writeln!(ofp, "hello world");
    Ok(())
}

There are three errors but they're basically all saying the same thing, so I'll just quote the first one:

$ rustc --crate-type rlib test.rs
error[E0277]: the trait bound `&dyn std::io::Write: std::io::Write` 
    is not satisfied
  --> test.rs:11:15
   |
11 |     let ofp = BufWriter::new(ofp);
   |               ^^^^^^^^^^^^^^ the trait `std::io::Write` 
   |        is not implemented for `&dyn std::io::Write`
   |
   = note: required by `std::io::BufWriter::<W>::new`

... so apparently we have a blanket impl T for Box<dyn T> but not impl T for &dyn T? That seems peculiar, but maybe there's a reason it has to be that way ...?

Write needs a mutable reference, Box owns its content so no issue there.

1 Like

It's possible to use dynamic dispatch without boxing. You usually end up leaning on the drop checker a bit more. Here's an example:

pub fn write_output(dest: Option<&Path>) -> io::Result<()> {
    let stdout; // because dropcheck
    let mut lock;
    let mut file;
    let ofp: &mut dyn Write = match dest {
        None => {
            stdout = io::stdout();
            lock = stdout.lock();
            &mut lock
        }
        Some(path) => {
            file = File::create(path)?;
            &mut file
        }
    };
    let mut ofp = BufWriter::new(ofp);
    writeln!(ofp, "hello world")?;
    Ok(())
}

I'm using deferred initialization for stdout, lock and file, because stdout and lock only need to be initialized in one branch and file only needs to be initialized in the other. The borrow checker is able to realize that ofp is correctly initialized either way, and the drop checker keeps track of which values actually exist so they can be dropped.

3 Likes

Ah nice! I tried to get it to work with references, but didn't get it to work. I didn't think about the deferred initialization.

@trentj Well, now I feel silly — it didn't even occur to me to try &mut dyn Write. Or declaring variables without initializing them, for that matter. You can probably tell I'm still new to this language. :blush: Thanks for the help!