Best practices for struct members needing initialization?

I have bumped several times already into this situation. I have an object I want to create, and then start later via some trait interface function. For example:

pub struct Server {
  listener: Option<TcpListener>,
}

impl Server {
   fn create(addr: Url) -> Self {
      listener: None,
   }
}

impl Service for Server {
   fn start(&self) -> Result(()) {
     // get the actual socket(s) and get ready to receive requests
   }
}

In this design, I don't want to start the server at the time of its creation (because I want it to be following the Service trait), so I have the start function. This requires the TcpListener to be behind an Option, or I won't be able to create the instance.

Is this the idiomatic approach? I am thinking specifically of the creation and start separation, which requires the Option. Are there better ways to design this in Rust? Or am I on the right track? I hope the question makes sense to others.

I believe the more idiomatic approach is to start the service when it is created, which avoids using an Option and unwrapping it in multiple places.

Are you using a trait like this because you want to use trait objects (dyn SomeTrait) like in your last post?

1 Like

I'm not sure, but maybe you're looking for "builder pattern" where you have one type that configures all the possibilities, which you eventually turn into another type.

A couple examples from the standard libary:

4 Likes

The idea is to have a set of services which share a common interface (start, stop, status), and which then are managed from a central place (and/or via an API). I have got this design from my previous work with golang.

It might well be that I end up with the same issue as with the previous post, having a bunch of dyn Trait, as the interfaces can't actually be stored in a vector or so...?

Under what conditions would you actually want the Server to exist but be stopped? That's the part that seems strange and is imposing a complication. I'm sure there are uses for it, but most servers I've worked with don't need such a condition.

But if you do want it, one potentially clean choice is to separate stoppedness from your server implementation: put Option around your Server struct instead of inside it (possibly with another struct wrapping the Option, if necessary). That way, the implementation does not have to check whether each of its fields is Some whenever it does anything.

3 Likes

I understand, as I remember this being common in Java also. I think it is a pattern that is easy to code initially (in those languages), but is actually kind of messy because the dual state of the server (stopped and started) is unnecessary and can be error prone.

because the dual state of the server (stopped and started) is unnecessary and can be error prone.

Why is that though? Isn't it natural for a server to (for example), have to do some initializations, and then start it? Or more often than not, have a controlled shutdown because the user hits CtrlC or whatever condition applies, and then requires a series of steps before it cleanly shuts down (e.g. disconnect DB, save state safely, signal disconnection to peers, and other stuff) - where a Stop method is useful, as one would listen for CtrlC events, then call Stop on each Service resp. Server?

What is the alternative then? What is the non-messy way?

I like the Option around the server instead of the other way around, but I need to think about it.

Under what conditions would you actually want the Server to exist but be stopped?

Rather than such a condition, the idea is to have a standard interface which to call on all different service implementations (e.g. DB service, network service, other state-related stuff) so that a controlled, safe and consistent shutdown is possible.

The idea is to separate the info needed to create/start the service from the running service itself. A ServerConfig type could hold the former and the Server type could hold the latter. The Server could be non-existent (not stored in any struct or variable) until it is started, or could be in an Option field.

This is why there was a suggestion to use a builder. The builder is in the role of the ServerConfig.

2 Likes

This part is me repeating what others said using different words.

Some form of RAII is an alternative. Represent different server states as different types, instead of different values. Then there's no such thing as a stopped or non-started Server. So the Server doesn't have to check, "am I in the running state?"[1] on every running-server operation.

This part is just a guess in combination with your last topic, feel free to ignore it.

You may be trying to over-abstract and/or program non-Rust in Rust. You don't have to fit everything into the same bucket (trait), or make some sort of hierarchy/lattice where every pair of types has a common supertrait. And ServerFactoryFactorys aren't a common Rust design either.

Perhaps the self-imposed "everything has the same interface" requirement is getting in the way of some good designs.


  1. "is listener Some?" ↩︎

1 Like

Why not? Can your caller use an Option<Server> instead?

The problem with an Option inside the server is that everything on the Server now needs to decide what they do on None, and I'd generally much rather leave that to the caller to decide -- make the if let Some(server) = blah { server.whatever() } be the caller's problem instead, rather than making everyone who "knows" they have a started server deal with all the "well it might not be started yet" questions.

2 Likes

Well I guess that's a common issue when coming from a different language...
The real question then becomes, how to not do non-Rust, and program "idiomatically"? And I guess the correct answer to that is - practice.

1 Like

Indeed, there is no unified, simple answer to “how do I write a Rust program instead of a [other language]-in-Rust program”. There could be an entire book (or books, one per other language) on the subject; I don't know if anyone’s written such things.


Given your desire to manage multiple services and starting and stopping them together, here's one way to solve it well with common traits, sketched out (this code runs but doesn't do anything useful):

use std::net::SocketAddr;
use std::net::TcpListener;

/// abstracts over different services *before* they start
trait ServiceConfig {
    fn start(self: Box<Self>) -> Result<Box<dyn Service>, Error>;
}

/// abstracts over different services *after* they start
trait Service {
    fn stop(self: Box<Self>) -> Result<(), Error>;
}

type Error = Box<dyn std::error::Error + Send + Sync>;

// --------------------------------------------------------------

struct HttpBuilder {
    addr: SocketAddr,
}

struct HttpServer {
    listener: TcpListener,
}

impl HttpBuilder {
    fn serve(self) -> Result<HttpServer, Error> {
        // In a real server, the TcpListener would be handed over to a thread
        // or async task which accepts connections, not returned here.
        Ok(HttpServer {
            listener: TcpListener::bind(self.addr)?,
        })
    }
}

impl ServiceConfig for HttpBuilder {
    fn start(self: Box<Self>) -> Result<Box<dyn Service>, Error> {
        // This bit of code just adapts the concrete types in
        // `HttpBuilder::serve()` to the generic boxed form.
        Ok(Box::new(self.serve()?))
    }
}

impl Service for HttpServer {
    fn stop(self: Box<Self>) -> Result<(), Error> {
        Ok(())
    }
}

// --------------------------------------------------------------

fn main() -> Result<(), Error> {
    // Pretend these are two different types -- it works the same.
    let not_started: Vec<Box<dyn ServiceConfig>> = vec![
        Box::new(HttpBuilder {
            addr: "127.0.0.1:8000".parse().unwrap(),
        }),
        Box::new(HttpBuilder {
            addr: "127.0.0.1:8001".parse().unwrap(),
        }),
    ];

    // Start all services
    let started: Result<Vec<Box<dyn Service>>, Error> = not_started
        .into_iter()
        .map(|service| service.start())
        .collect();
    eprintln!("Services started.");
    // Check for errors (crudely, without identifying which service,
    // but we could fix that above in the map()).
    let started = started?;

    // wait ...

    // Stop all services
    let errors: Vec<Error> = started
        .into_iter()
        .filter_map(|service| service.stop().err())
        .collect();

    if errors.is_empty() {
        eprintln!("Services stopped.");
    } else {
        // Report shutdown errors non-fatally
        eprintln!("{} services had errors while stopping.", errors.len());
    }

    Ok(())
}

Some will object to this code. As @quinedot put it, “ ServerFactoryFactory s aren't a common Rust design either”, and that's true. You should not write this code in order to write good code. You should write this code to achieve your goal of common startup and shutdown.

Or, if you have only two or three services, then throw out the traits and just call each service’s unique startup and shutdown functions in your main() (or other top-level function). The important part isn't the traits, it's the typestate: a HttpServer or a Box<Service> doesn't exist until the service starts, and it doesn't exist after the service stops. That's how you avoid Options. I wrote the traits just to demonstrate that using typestate doesn't stop you from abstracting with traits, if you use Box in the right places.

5 Likes

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.