Instead you'd return an object, and make it the callers responsibility to close it. Destruct all three in the close call.
It may not fit your mental model, and that's fine. But let's not pretend it's a serious hurdle.
No, it's not a psychological problem. This is real complexity, and it's one of the most serious and widespread issues in software development.
The key issue is that we often have rules that say: Whenever A happens then B must happen as well. It is absolutely critical that rules like this are expressed in exactly one place and that enforcement is opt-out not opt-in.
Everything else is error prone and creates significant mental burden.
Go doesn't have generics so you can't really do this, but in the case of zig, your function would be parametrized on the type, so you would issue it a struct to be shared by the tests that has the common initialization upfront, make_ functions that return the pre-initialized stuff, followed by running the function, followed by no-op deinits, followed by whatever forensics you'd like to run later.
This is missing the point. The point of a destructor is that it runs automatically. .deinit() is the same as the three cleanup_*()s in my example and thus has the same problems.
>but in the case of zig, your function would be parametrized on the type, so you would issue it a struct to be shared by the tests that has the common initialization upfront
The third example in my post shows why this is inadequate.
Have you tried doing what you propose in zig? Ultimately, the only difference between a .deinit() and a destructor is that one is explicit and the other is not. There are cases where you can easily make a mistake for both an explicit destructor and an implicit destructor.
it does in zig:
fn create_mocks() !struct {bar: Bar, baz: Baz, quux: Quux} {
var bar = try make_bar();
errdefer cleanup_bar(bar);
var baz = try make_baz();
errdefer cleanup_baz(baz);
var quux = try make_quux();
errdefer cleanup_quux(quux);
return .{
.bar = bar,
.baz = quux,
.quux = quux,
};
} func make_foo() *Foo {
return &Foo {
bar: make_bar(),
baz: make_baz(),
quux: make_quux(),
}
}
func cleanup_foo(foo *Foo) {
cleanup_bar(foo.bar)
cleanup_baz(foo.baz)
cleanup_quux(foo.quux)
}
// elsewhere
foo := make_foo()
defer cleanup_foo(foo)
// [rest of logic goes here]Poor man's:
func make_foo_with_mock_bar() *Foo {
return &Foo {
bar: make_mock_bar(),
baz: make_baz(),
quux: make_quux(),
}
}
Explicit injection: func make_foo(bar: Bar, baz: Baz, quux: Quux) *Foo {
return &Foo {
bar: bar,
baz: baz,
quux: quux,
}
}
Or use some DI frameworkEven if you have reservations about where verbosity lies, I don't see how this affects the cleanup side.
So if bar, baz, quux aren't intrinsically related as a unit and might be mixed and matched arbitrarily, you just forego the whole ceremony and call each thing or the respective mock individually (the argument being that if you're making multiple different permutations, you're not repeating yourself per se; e.g. mocks don't need to be cleaned up in the first place)
There's also something to be said about tests needing several arbitrary mocks. Normally, I'd consider that a smell. I've actually seen the pattern of making a kitchen sink foo for scenarios like that, but with nils/noops for unused things (can't say I like that approach though).
(https://smartlogic.io/podcast/elixir-wizards/s6e6-matthias-l...) if you're curious
With the three mocks I gave, there are 2^3 - 1 = 7 possibilities of which mocks a particular test needs. Of these, three are degenerate cases of a single mock each which would be served by the individual make_* already, so that leaves four cases of make_foo* that you'd need to implement.
With more than three mocks, this number grows rapidly.
func newMocks(
) (
foo Foo, bar Bar, baz Baz,
cleanup func(),
)
and making the caller responsible for calling the cleanup function, possibly in a defer. To me, this seems like a good trade off between making a little more work for the caller and keeping things explicit and under the caller's control.You could just make a function that creates all the mocks needed for all tests, but you don't like that. So the only option left is to specify which mocks you need for each test... which is exactly what the original code is doing.
You're running into a wall because you're trying to abstract code that's already maximally abstracted.
One upside of destructors is that adding destructor cleanup to a type isn't necessarily a breaking change. (In Rust terms, as long as the type isn't "Copy".) But in languages with defer or with/using statements, it is.
Another upside of destructors is that the cleaned-up state can be mostly hidden from other code. But when cleanup is a regular function, it's easier to access an object after cleanup, and you have to reason more about what happens in that case. (A Rust example here might be "What happens if you use a Box after it's freed?" The answer is that you just can't do that, even though Box doesn't have a freed/null state.)
But I get your point: with destructors you are free to change the ownership responsibilities, because the link between a variable and its releasing is implicit and carried over by the runtime, instead of explicitly being cared for by the code.
With the C++ destructor RAII pattern I can use it at any lexical scope I need it at, which is super handy.
it is way too easy to critique an approach that led to a problem that hasn't happened to you, and is described to you by someone else.
Your initial pretense was that you always make 3 objects, so move it into a function.
When people show how to accomplish that, you state 'well what about my test that doesn't need all three?'
Why are you testing things your code doesn't do?
You've phrased this as if I said this after I'd been given a solution for the initial problem. In reality, I mentioned this in the original post itself, but it seems many people didn't read it.
It's almost like I have prior real-world experience of this problem, where I already went down the path of making common functions for all the creation and cleanup respectively, only to find that it doesn't help when all the functions don't create the same subset of things.
>Why are you testing things your code doesn't do?
I have no idea how you came to this conclusion.
I've been a Go mentor for years, and see how people take concepts they are used to and try to shoehorn them in, and complain they are ugly.
In this case, when I first saw your examples, my thought was 'youre doing it wrong'. But not wrong as in incorrect, but now how the language was designed.
If you truly care we can go through scenarios and I can tell you how I'd approach it. But in my past experience, when people find a paradigm they like, they tend to not listen.
I think people have a bias against destructors because of C++, but they really are pretty nice. Speaking as a guy that uses C instead of C++, destructors are the one thing I would add to C.
It's understandable why a language trying to be close to C in spirit does not have them.
It does make things much easier.
And you can call close like: file.close().await.unwrap(); https://github.com/tokio-rs/tokio-uring/pull/17/files#diff-b...
Say you have this (pseudocode):
Now imagine you have multiple functions like this that all create bar, baz and quux's. Say these are unit tests and the bar-baz-quux are mocks or whatever.So you decide to DRY by moving the creation to a common function.
... except this doesn't work any more, because the cleanup happens within create_mocks and create_mocks ends up returning cleaned-up values.You could fix this by switching to a callback approach:
... but now you have to make the caller use a callback that necessarily returns void and not any other type. (This is mostly a golang problem, since languages with generics can be generic on the return type of the callback.) It also means you can't easily do things like set variables in an outer scope easily, depending on how the language deals with closure captures.Worse, if not all tests use the same mocks, you actually end up with multiple of these functions:
All these problems are avoided by having destructors.