I'm having some trouble working out how to get my lifetimes right for some mocking utils I'm trying to build.
However whatever I do I seem to run into errors with lifetimes. I'm feeling like this should be possible, and that I'm just failing to see something very obvious, and I'm hoping someone can point me in the right direction.
The idea of the code is that given a trait like
pub trait MyTrait {
fn foo(&self, arg:u32) -> u32,
fn bar(&self, arg:&SomeType) -> AnotherType,
}
I should be able to write something like
// Mock of the MyTrait trait.
//
// Annoying 'a lifetime needed since bar_calls has a reference type.
#[derive(Default)]
pub MockMyTrait<'a> {
foo_calls: MockFunctionHelper<u32,u32>,
bar_calls: MockFunctionHelper<&'a SomeType, AnotherType>,
}
impl <'a> MyTrait for MockMyTrait<'a> {
fn foo(&self, arg: u32) -> u32 {
self.foo_calls.handle_call(arg)
}
fn bar(&self, arg: &SomeType) -> AnotherType {
self.bar_calls.handle_call(arg)
}
}
#[test]
fn example_test() {
let mock = MockMyTrait::default();
mock.foo_calls.add((5,1));
assert_eq!(mock.foo(5), 1);
}
// And something similar for bar.
With the plan being that the MockFunctionHelper can then do argument matching and tracking by just hunting for a match in a Vec<Box<dyn MockFunction<A,B>>
.
Here's a complete example that fails with:
error: lifetime may not live long enough
--> src/lib.rs:73:9
|
67 | impl<'a> KitchenSink for MockKitchenSink<'a> {
| -- lifetime `'a` defined here
...
72 | fn convert_custom_ref_to_u32(&self, arg: &CustomStruct) -> u32 {
| - let's call the lifetime of this reference `'1`
73 | self.convert_custom_ref_to_u32_calls.handle_call(arg)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ argument requires that `'1` must outlive `'a`
/// Represents the expected function calls the mock will recieve,
/// for a function A -> B.
struct MockHelper<A, B> {
fns: Vec<Box<dyn MockFunction<A, B>>>,
}
impl<A, B> Default for MockHelper<A, B> {
fn default() -> Self {
Self { fns: vec![] }
}
}
impl<A, B> MockHelper<A, B> {
/// Try calling each of the listed functions until
/// one succeeds.
fn handle_call(&self, arg: A) -> B
where
A: Copy,
{
for f in &self.fns {
match f.try_call(arg) {
Some(v) => return v,
None => continue,
}
}
panic!("No matching call")
}
fn add_call<T: MockFunction<A, B>>(&mut self, call: T)
where
T: 'static,
{
self.fns.push(Box::new(call))
}
}
/// Trait to represent an expected function call
/// of the form A -> B
pub trait MockFunction<A, B> {
fn try_call(&self, arg: A) -> Option<B>;
}
/// Simple argument/response pair
impl MockFunction<u32, u32> for (u32, u32) {
fn try_call(&self, arg: u32) -> Option<u32> {
if arg == self.0 {
Some(self.1)
} else {
None
}
}
}
struct CustomStruct {}
pub trait KitchenSink {
fn convert_u32_to_u32(&self, arg: u32) -> u32;
fn convert_custom_ref_to_u32(&self, arg: &CustomStruct) -> u32;
}
#[derive(Default)]
pub struct MockKitchenSink<'a> {
convert_u32_to_u32_calls: MockHelper<u32, u32>,
convert_custom_ref_to_u32_calls: MockHelper<&'a CustomStruct, u32>,
}
impl<'a> KitchenSink for MockKitchenSink<'a> {
fn convert_u32_to_u32(&self, arg: u32) -> u32 {
self.convert_u32_to_u32_calls.handle_call(arg)
}
fn convert_custom_ref_to_u32(&self, arg: &CustomStruct) -> u32 {
self.convert_custom_ref_to_u32_calls.handle_call(arg)
}
}
pub fn main() {}
#[cfg(test)]
pub mod tests {
use crate::{CustomStruct, KitchenSink as _, MockKitchenSink};
#[test]
fn kitchen_sink_generic() {
let mut mock: MockKitchenSink = MockKitchenSink::default();
mock.convert_u32_to_u32_calls.add_call((1, 4));
let arg = CustomStruct {};
assert_eq!(mock.convert_u32_to_u32(1), 4);
assert_eq!(mock.convert_custom_ref_to_u32(&arg), 5);
}
}
A link to the example code in the rust playground is:
The reason this fails, as the compiler says, is that the lifetimes don't match.
fn convert_custom_ref_to_u32(&self, arg: &CustomStruct) -> u32 {
self.convert_custom_ref_to_u32_calls.handle_call(arg)
}
In this function, the duration of the borrow arg
holds is limited to the time this function is executing. However, by passing it to self.convert_custom_ref_to_u32_calls.handle_call
it needs to live at least as long as 'a
, which is a lifetime provided before this function is invoked. Because of that, there is no possible way that the lifetime of the arg
borrow can be at least as long as 'a
.
Let's revisit the definition of MockHelper
:
struct MockHelper<A, B> {
fns: Vec<Box<dyn MockFunction<A, B>>>,
}
When you give generic arguments to a struct, what you're saying is that that instantiation must meet the lifetime requirements contained in those arguments. What this means is that when you say MockHelper<&'a CustomStruct, u32>
, you're effectively saying that MockHelper
contains a borrow with the lifetime 'a
. Now, we know that it actually doesn't contain an A
at all so this isn't actually true, but it doesn't matter -- this is what we're telling the compiler. This is a clue that something is wrong with how these types are defined; they're not conveying the correct information about what is being borrowed and for how long.
What we need is a way to say "this MockHelper
instantiation can work with references to CustomStruct
of any lifetime, not some specific lifetime." This feels like it could be adjacent to HRTBs (for<'a> &'a CustomStruct
), and indeed, they would be useful here, if we could actually use them (HRTBs apply to trait bounds, not concrete types).
Note that this all applies to MockFunction
as well!
Why don't we have this problem when we use the Fn*
trait families? Because impl Fn(&T)
is actually syntactic sugar for impl for<'a> Fn(&'a T)
! This is why in many cases you can avoid specifying the lifetimes of reference arguments in Fn*
traits; they're inferred to be any possible lifetime.
How can we get that same functionality here?
Well, one option is to just lean on the Fn
traits. Technically you can make this work with your own function trait as well by using HRTB syntax, but there isn't a whole lot to be gained here over just using Fn
.
struct MockHelper<F> {
fns: Vec<F>,
}
impl<F> Default for MockHelper<F> {
fn default() -> Self {
Self { fns: vec![] }
}
}
impl<F> MockHelper<F> {
/// Try calling each of the listed functions until
/// one succeeds.
fn handle_call<A, B>(&self, arg: A) -> B
where
A: Copy,
F: Fn(A) -> Option<B>,
{
for f in &self.fns {
match f(arg) {
Some(v) => return v,
None => continue,
}
}
panic!("No matching call")
}
fn add_call(&mut self, call: F) {
self.fns.push(call)
}
}
fn mock_function_u32(a: u32, b: u32) -> impl Fn(u32) -> Option<u32> {
return move |arg| (arg == a).then_some(b);
}
struct CustomStruct {}
pub trait KitchenSink {
fn convert_u32_to_u32(&self, arg: u32) -> u32;
fn convert_custom_ref_to_u32(&self, arg: &CustomStruct) -> u32;
}
#[derive(Default)]
pub struct MockKitchenSink {
convert_u32_to_u32_calls: MockHelper<Box<dyn Fn(u32) -> Option<u32>>>,
convert_custom_ref_to_u32_calls: MockHelper<Box<dyn Fn(&CustomStruct) -> Option<u32>>>,
}
impl KitchenSink for MockKitchenSink {
fn convert_u32_to_u32(&self, arg: u32) -> u32 {
self.convert_u32_to_u32_calls.handle_call(arg)
}
fn convert_custom_ref_to_u32(&self, arg: &CustomStruct) -> u32 {
self.convert_custom_ref_to_u32_calls.handle_call(arg)
}
}
pub fn main() {}
#[cfg(test)]
pub mod tests {
use crate::playground::mock_function_u32;
use super::{CustomStruct, KitchenSink as _, MockKitchenSink};
#[test]
fn kitchen_sink_generic() {
let mut mock: MockKitchenSink = MockKitchenSink::default();
mock.convert_u32_to_u32_calls
.add_call(Box::new(mock_function_u32(1, 4)));
let arg = CustomStruct {};
assert_eq!(mock.convert_u32_to_u32(1), 4);
assert_eq!(mock.convert_custom_ref_to_u32(&arg), 5);
}
}
Note that by saying MockHelper<Box<dyn Fn(&CustomStruct) -> Option<u32>>>
instead of MockHelper<&'a CustomStruct, u32>
we have resolved the problem: the Fn
will accept a borrow of any lifetime, and we no longer need the 'a
lifetime on MockKitchenSink
, which correctly conveys that it holds no borrows.