Problem overview
An app tells its users about news by email or by SMS. The code that sends the news shouldn't care which kind each user picked. It only needs to know that each notifier can send a message. That promise is an interface, and in Rust an interface is a trait.
You write:
- the trait
Notifier, with one method,send, which returns the text that would be sent; EmailNotifier, whosesendreturns"To ", the address,": "and the whole message;SmsNotifier, whosesendreturns"SMS ", the phone number,": "and only the first 20 characters of the message;notify_all, which takes a list of notifiers of any kind and returns the texts they send, in the same order.
For an email notifier with the address ana@mail.com and an SMS notifier with the phone 0712345678, the message "Meeting at 5" gives To ana@mail.com: Meeting at 5 and SMS 0712345678: Meeting at 5. The message has 12 characters, so the SMS isn't cut.
Approach: program against the trait
notify_all calls send on each notifier and never asks which struct it holds. The two structs have different fields, so the list holds Box<dyn Notifier> values: each box points to any type that implements the trait, and a call through the box runs that type's own send. A third kind of notifier, say a push message, would work with notify_all without any change to it.
Step by step
- Declare
sendin the trait. It takes&selfandmessage: &str, returns aString, and ends with a semicolon in place of a body. Each struct must write asendwith exactly this shape. - Give
EmailNotifieranaddressfield of typeString. The struct owns its text, so the field is aStringand not a&str. - Write
EmailNotifier::new. It takes a&strand storesaddress.to_string(). - Write the email's
sendwithformat!, which builds a newStringfrom a template. - Repeat for
SmsNotifier: aphonefield and anewthat makes it aString. - Write the SMS's
send. The message is cut with the slice&message[..end], whereendis the smaller ofmessage.len()and 20, found withmin. A message of 20 characters or fewer is sent whole. - Write
notify_all. Create an emptyVec, loop over the notifiers, push the result of eachsendand return the vector.
The solution in Rust
trait Notifier {
fn send(&self, message: &str) -> String;
}
struct EmailNotifier {
address: String,
}
impl EmailNotifier {
fn new(address: &str) -> EmailNotifier {
EmailNotifier { address: address.to_string() }
}
}
impl Notifier for EmailNotifier {
fn send(&self, message: &str) -> String {
format!("To {}: {}", self.address, message)
}
}
struct SmsNotifier {
phone: String,
}
impl SmsNotifier {
fn new(phone: &str) -> SmsNotifier {
SmsNotifier { phone: phone.to_string() }
}
}
impl Notifier for SmsNotifier {
fn send(&self, message: &str) -> String {
format!("SMS {}: {}", self.phone, &message[..message.len().min(20)])
}
}
impl Solution {
fn notify_all(notifiers: &Vec<Box<dyn Notifier>>, message: &str) -> Vec<String> {
let mut texts = Vec::new();
for notifier in notifiers {
texts.push(notifier.send(message));
}
texts
}
}
Using it with two notifiers:
struct Solution;
fn main() {
let notifiers: Vec<Box<dyn Notifier>> = vec![
Box::new(EmailNotifier::new("ana@mail.com")),
Box::new(SmsNotifier::new("0712345678")),
];
for text in Solution::notify_all(¬ifiers, "Meeting at 5") {
println!("{}", text);
}
}
To ana@mail.com: Meeting at 5
SMS 0712345678: Meeting at 5
(The struct Solution; line is only there so this file builds on its own: the lesson provides Solution.)
Rust details worth knowing
- Slices and
min. A slice can't end past the end of the string, and&message[..20]on a shorter message panics. Takingmessage.len().min(20)keeps the end inside the string. - Bytes, not characters.
len()counts bytes and slicing uses byte positions. For the plain letters, digits and punctuation in this lesson, one character is one byte. A letter such asétakes two bytes, and cutting through the middle of one panics with an error likebyte index 20 is not a char boundary; it is inside 'é' (bytes 19..21). - The empty list. With no notifiers, the loop doesn't run and the empty vector goes back.
- Same shape. Every
sendmust match the trait's signature, including&selfand the&strparameter.
Both structs store their destination as an owned String even though new receives a &str. A borrowed &str would tie the notifier to the text it was built from, and the struct could not simply live in a box for as long as the list does. Copying the text once, in new, avoids that.
Edge cases and common mistakes
- Exactly 20 characters.
"Exactly twenty chars"is sent whole, and with a!added it is cut back to those 20 characters. Both messages go through an SMS notifier asSMS 111: Exactly twenty chars. - A long message.
"Your order has shipped today"has 28 characters. The SMS keepsYour order has shipp, while the email keeps all of it. - Checking the type. Don't write code in
notify_allthat tests for an email or an SMS. Callsendand let the box dispatch. - Order. The texts come back in the order of the notifiers.
Adding a third kind of notifier
Because notify_all only calls send, a new notifier type works with it unchanged. A push notifier, written in a file of your own, is a struct and one impl block:
struct PushNotifier {
device: String,
}
impl Notifier for PushNotifier {
fn send(&self, message: &str) -> String {
format!("Push {}: {}", self.device, message)
}
}
Put next to an EmailNotifier::new("ana@mail.com") in the same list and sent the message "Hello", it gives:
To ana@mail.com: Hello
Push phone-1: Hello
Nothing in notify_all had to change. That is the benefit of programming against an interface: the sending code depends on the promise (send), and each notifier decides what the promise means for it.
The Box is needed because the two structs have different sizes. It stores the value on the heap and keeps a pointer of a fixed size in the vector, with the trait's methods found at run time.
Testing
With an SMS notifier for 5550100 followed by an email notifier for bob@work.org, and the message "Your order has shipped today":
SMS 5550100: Your order has shipp
To bob@work.org: Your order has shipped today
With no notifiers at all, notify_all returns a vector of length 0.
