Is it better to have one or few parameters that supply more than
enough information for the intended process or should you have more
specific parameters that provide just enough information?
I wanted to kind of echo
Telastyn here and suggest there's a balancing act to be had, but focus more on the interface design perspective. In general I'd suggest leaning heavily towards the former whenever you can (with "whenever you can" being based on the confidence in the stability of your designs with respect to how much information they need to receive).
As a caveat, I've worked in frantic environments where, within a couple of months after you release a design to the team, already thousands of lines of code will be using it throughout the codebase, and written by multiple authors. The design is effectively cemented in stone at that stage, and to change it in response to a change in user-end requirements would cause unacceptable cascading changes which would make the rest of the team want to run you over by a coordinated bus. Yet, simultaneously, we were working with clients who always changed their minds and would brainstorm new design ideas in the middle of the process. Some of what I focus on might be overkill outside of environments where interface design so quickly becomes the most unforgiving process.
I'll try to share my mess of thoughts here.
Instead of focusing on coupling so much here, I want to put a spotlight on just the conceptual amount of information we pass through an interface, and also return from an interface.
Do we pass just enough information for a function to do its thing? That would be absolutely ideal, provided you know exactly what the right amount of information is. Do we pass way too much? That could end up making the interface quite difficult to test, possibly couple it to some unstable monolithic type, and potentially obscure side effects.
To me just enough would be absolutely ideal, but there are occasionally times where the message passing involved between interfaces is actually the most difficult part of the interface to stabilize. What the interface should do at the broad level might be obvious and unchanging, exactly how much information it needs to do it might actually change over time.
Flexible Parameters: Regular Expressions
An example I want to use to focus more on "information" than data types is regular expressions. As a note, I hate regular expressions. The syntax gives me a headache, and I always have to look things up there. Nevertheless, it's worth noting the stability of regex interface designs, like so:
// Search for just about anything you can imagine in a string.
string regex_search(string str, string pattern);
It's actually quite incredible to me that even though we've had all kinds of regex standards competing with each other from simple, basic, extended, that this above design can remain stable even if it continues to expand to support new regex standards.
In this case,
string pattern is actually modeling a super bundle of information, capable of communicating a practically-infinite amount of information through a variable-sized string. It is largely because of this super bundle of information that this
regex_search function, and the callers of the function, can remain stable (unchanging) in spite of evolving and expanding regex standards.
Where this is a bit different is that
pattern would be used in full by
regex_search, no matter how much information it contains. The entire contents of the string would be relevant for the search, and that's owed due to the ease and flexibility of a
string interface to merely add as much information as you need at the site in which the call is made. A string can contain infinite information, but the nature of its interface and representation makes it so it doesn't have to contain more information than necessary.
Nevertheless, this quality is something I've rarely seen mentioned, of flexible parameter types, and it's a noteworthy quality to interface designers seeking greater stability in their designs.
Imagine, instead, if
string pattern above was actually
IRegEx pattern, with
IRegEx modeling SRE (now deprecated), with functions rather than strings to modeling each individual element of a pattern. Such an interface would likely find difficulties remaining stable in the face of new standards, having to decide between outright trying to evolve and grow (possibly becoming a monolithic design with a lot of warts), or new interfaces and possibly deprecation (and therefore a new
regex_search_ex kind of function in addition).
This is not a suggestion to reduce the richness of our designs to ones that accept things like strings all over the place. Nevertheless, the stability that results here is a very interesting quality to note. Interface design is always a tightrope balancing act, weighing the pros and cons of one decision over another. It becomes easier if we start to become more aware of the possibilities, and the precise pros and cons of each.
Typically we don't think so much about interface-level coupling to plain old data types or standard types like
int. The above
regex_search function might be described as "decoupled", completely independent. Yet an obscure way to look at it is that it is still coupled to the interface design of a
string. The reason we can generally omit such discussions, and in our dependency diagrams, is simply because
string is so incredibly stable. It's unlikely to ever change in design for as long as the language is around, as such a change could potentially break almost all codebases ever written against the language.
As a result, we can often reach for a
string parameter here, an
int there as parameter types, without worrying about
int changing and breaking the implementations of both caller and callee.
However, when you reach for a user-defined type like
ControllerContext, then stability starts to become a concern. Will
ControllerContext's design likely change? If the design changes, would it simply grow and therefore not affect existing dependencies to it, or might there be temptations to change some existing parts of its public interface change in ways that might lead to cascading breakages? It's all a balancing act.
On the flip side, if you use this design:
void MyFunction(IPrincipal user, string destination);
... will you have sufficient information always? Is the design for the existing parts used of
IPrincipal going to remain stable?
It's difficult to answer these questions perfectly, and thus design is difficult. It's also not worth asking so much if such designs are not widely-used, in which case we might waste more time trying to too hard to come up with the right design choice upfront when it would have been cheaper to just make a mistake and change the design later without much of a cascade in changes.
Yet a big goal here is stability, always. We don't want to design interfaces that have to change, we don't want to accept parameter types that eventually offer too little information in a way that needs to expand and forces us to change these designs.
One thing to consider is side effects. Are there side effects (changes made) to
void MyFunction(ControllerContext context);
... or will
MyFunction simply read from
context? If there are side effects involved, these kinds of bundled types tend to be a lot worse, since they don't simply provide more information than the function needs, but they also supply more state than the function will actually modify.
As a result, there can be incredible confusion when passing such bundles as to exactly what the function has modified inside the bundle, and bundled information of this sort can quickly carry far more cons if it is not read-only.
Yet the same kind of concerns carry to
void MyFunction(IPrincipal user, string destination);
Obscure side effects are a very common spawning point for bugs, so we generally want to make side effects clear, and strive to design functions that only cause, at most, one logical side effect. That might affect how you name your functions, document them, and possibly even the parameter types being passed. It's also worth striving for immutability when possible.
If you ever have bundled states that could be split into a read-only part and a mutable part, for example, the split may help a whole lot instead of just mixing "input and output" parameters in one bundle.
Bundles can make testing more difficult, depending on how easy they are to construct on the fly. Strings are pretty easy to construct on the fly to contain just the right amount of information.
ControllerContext may not be so easy, and its interface might want to expose non-homogeneous data in a way that contains far more functionality than needed just at the interface level.
The ease at which you can construct such bundles on the fly with the necessary information will affect the ease at which you can test your interfaces against a wide range of relevant parameters. An example of where a bundled design might really complicate testing is if it can only be constructed from a config file.
One of the ways to achieve some sense of stability in your design but not veer too far into the danger of passing way more information than needed, and all the cons associated, is to degeneralize your bundles and make them site-specific.
For example, instead of
ControllerContext used for
IQuestion, maybe you should use
QuestionContext which is only used by
IQuestion. That mitigates the temptation to bundle way more information than needed.
This is often useful if you want to transfer potential instability elsewhere, if
QuestionContext ends up being easier to change than
IQuestion. An example is if you need to extend or change the underlying data representation of parameters passed to
IQuestion, but not in a way that affects all code written thus far that uses it, only the implementation of
QuestionContext itself. In general, a lot of interface design boils down to that. We want to transfer potential instability to places that are easier to change.
ABI and TMI
This might be slightly outside the context of this question, but when ABI (application binary interface) is a concern, too much information (TMI) can be an essential mechanism to reduce the odds of backwards compatibility breakages.
In these cases, interface stability becomes more important than ever before, because an interface change may not simply break compatibility with existing source code, but with existing binaries for products (ex: third party commercial plugins) outside of your team's control.
In such cases, a function like this:
void (*some_function)(int x, int y);
... may very well benefit from having some extra information available to input and output that it doesn't currently benefit from having:
int (*some_function)(int x, int y, void* unused);
It's really ugly but both the return type and
unused may save our butts some day in preventing a backwards compatibility ABI change that would have otherwise been necessary, by having shaped all existing client code using
some_function to pass in a null for
unused which may become a valid, but still optional parameter.
A similar idea might carry for
struct used in C, if it's not opaque to the client. Having some unused fields might save the day, some day, and prevent a need for deprecation or breakages. Likewise, some fields formerly used may evolve to become unused.
This kind of practice is most useful when future expansions are towards optional routes, to allow breathing room for new options (options, as in optional) from breaking existing interfaces, widely-used.
So anyway, these are some things to take into consideration when trying to figure out exactly how much information you need when designing the parameters and return types of an interface. The ideal design is always going to be one that just receives and returns exactly the right amount of information required. Yet a less ideal solution which accepts or returns more than required might leave some additional breathing room for changes. Stability is one of the greatest goals, and achieving stability in the face of a world of changing requirements can be very difficult unless we transfer instability elsewhere to places that are easier to change.