7

I wanted to know the best practice(s) for ensuring scalability while passing arguments. Say, in version 1.0 of an application, the foo function looks like this

void foo(int i)

Now, in version 1.1, the function has to be changed to

void foo(int i, bool enablethis=true)

Version 1.2 forces me to change the function to

void foo(int i, std::string whatever, bool enablethis=true)

What is the best approach to handle such changes? What I can think of is having a class that will provide the input parameters.

class InputParameters{
   public:
   int i;
   std::string whatever;
   bool enablethis;
};

And pass it to foo

void foo(InputParameters& params)

This way I don't have to change the function signature or the (legacy) calling functions if I want to add more input parameters.

I'll add one more question. Is this considered a decent approach?

11
  • 9
    "This way I don't have to change the function signature or the legacy function calls if I want to add more input parameters" - no, now you have to change the construction of the InputParameters instance. Unless this an opaque value created and provided by your own application, you have just moved the problem instead of solving it.
    – Bergi
    Commented Dec 26, 2022 at 22:06
  • @Bergi, and that's why interface is preferred?
    – awakened
    Commented Dec 27, 2022 at 3:32
  • 1
    No, it is not preferred. Interface compatibility is just a different problem - better in some aspects, worse in others. Changing the interface is still an incompatibility that requires careful consideration, and effort if you want to prevent a breaking change.
    – Bergi
    Commented Dec 27, 2022 at 7:06
  • 1
    @awakened Sorry, I wasn't using C++ terminology. I mean that one still needs to code that creates (constructs) the class/struct value to pass it to the function. If a new version of your library adds a new required field, that code breaks. Same problem as adding a parameter to a function that breaks calls.
    – Bergi
    Commented Dec 29, 2022 at 15:42
  • 1
    @awakened The main problem is that the premise of the question is that the new version is not backwards compatible, period. It requires (for some reason) some new information it can only get from the caller. If that information wasn't really required, or it could be gotten from somewhere else, then you could have a default value/overloaded function/etc. But since the information is required and must come from the caller they are required to make some change to give you that information regardless of how it's formatted. Commented Dec 30, 2022 at 15:05

9 Answers 9

18

Scalability is not the appropriate term for this problem. Your concern is backwards compatibility.

In systems that need to remain compatible with existing clients your approach may be used but the single object argument would be passed as either a base class type or an interface type or, in case of C, a variable length struct that includes the length or version number. The latter is pretty old school but so is C.

For web applications it is common to use key-value pairs in the URL. The most effective way to deal with this highly depends on the technology.

11
  • 2
    @awakened Not a general one. Scalability refers to making your system "bigger" in one or more ways, like faster, servicing more users, getting your product distributed to a wider region, whatever. Because you can scale in a number of dimensions there is no generic solution. Even within one dimension it would depend on your product. Commented Dec 26, 2022 at 7:47
  • 2
    ... and the programming language used in this question is obviously C++, not C.
    – Doc Brown
    Commented Dec 26, 2022 at 11:18
  • 2
    @awakened: your comment above shows should first learn what the term scalability means before asking further questions how to achieve it.
    – Doc Brown
    Commented Dec 26, 2022 at 11:21
  • 2
    @awakened that's not scalability. That's backwards-compatibility.
    – T. Sar
    Commented Dec 27, 2022 at 14:50
  • 1
    Some highly dynamic approaches like key value pairs are mostly fit for communication between processes/services where you can't assume they'll all get upgraded at the same time but that level of flexibility makes the code way to complex if used for simple functions that are just called in the same process
    – Jimmy T.
    Commented Dec 28, 2022 at 10:59
11

Your question gives a confused impression to me, because I am not sure which actual problem you are trying to solve.

You showed us

//version 1
void foo(int i)

// version 1.1
void foo(int i, bool enablethis=true)

Assumed the behaviour of foo(x,true) is the same as the behaviour of version 1.0 of foo(x), there no issue, the code behaves backwards compatible and no caller has to be changed.

Then you added

Version 1.2 forces me to change the function to

void foo(int i, std::string whatever, bool enablethis=true)

Taking this literally, this could break backwards compatibility, because any caller now has to provide a string parameter whatever. If this is an issue or not depends a lot on the real context, especially how much control you have over the calling code, and what semantics the parameter whatever introduces. Some possible cases:

  1. You have the code which calls foo fully under your control, and you want all places where foo is called to add a sensible value for whatever in a deliberate manner. Then go ahead, the compiler will to tell you where you might have forgotten to extend a call to foo, and the signature change is not an issue, quite the opposite.

  2. You would prefer the existing callers to be untouched, maybe because you don't have all the calling code under your control, or because changing all the calls would imply too much testing effort. Then you could change the order of arguments and provide a default value for whatever.

     foo(int i, bool enablethis=true, std::string whatever="")
    

    This will allow you not to think about the callers foo(x,false), which can remain untouched.

Now, how does this compare to the idea of using a helper class InputParameters? In fact, this does not gain anything in regards to backwards compatibility. If your callers have to initialize an object of type InputParameters before calling foo, extending it with another non-optional attribute whatever will just shift the need for change from the call to foo to the caller's initialization code in front of the call to foo. And in case whatever can be made optional by providing a default value in the constructor of InputParameters, it does not make a huge difference to the variant #2 I scetched above. Assumed you add proper constructors to InputParameters, the calling side would look something like this:

       // instead of foo(i)
       InputParameters p(i);
       foo(p);

       // instead of foo(i,enablethis);
       InputParameters p(i,enablethis);
       foo(p);

       // instead of foo(i,enablethis, whatever);
       InputParameters p(i,enablethis,whatever);
       foo(p);

So when does it really make sense to bundle several input parameters of a function into a new class or data structure? A growing number of arguments can be indeed a code smell, but the real issue here is neither backwards compatibility nor scalability, but readability and maintainability. And for this, it is important not to bundle mechanically every parameter in some class with a nonsensical name like InputParameters. That would cause the opposite of what you are trying to achieve, since whenever one now will try to call foo, they first look at the signature, see that there is some black box InputParameters there, and have to lookup that class will require and which members it contains.

Instead, group several parameters together in one helper class if you can give them a sensible, common name, some superordinate term which tells readers what the class represents. This is also called "creating an abstraction". If you cannot come up with a sensible name for a group of parameters, because there is no logical grouping, you better leave them separated.

Of course, there may be more driving factors here: another motivation for creating a class InputParameters (hopefully with a clearer name) could be that it makes it possible to add some code here, for example, default initialization code for some of the members which were too complex to be provided in the signature of foo. Or, you know that the caller may want to separate calling from initialization. Or, as others have mentioned, InputParameters is just some base class in a class hierarchy of different kinds of input parameters.

So in short, bundling input parameters of a function into a new class can make sense, but not for the reasons you probably had in mind when asking this question, and not just "in general" - it depends all on the specific context.

9
  • 2
    "the code behaves backwards compatible".. well that depends. It is source compatible, but not binary compatible - which one you care about makes a big difference in complexity and is essential to decide in the beginning. If you simply replace v1 with v2 and let a v1 caller call into the v2 library it will be undefined behavior.
    – Voo
    Commented Dec 26, 2022 at 21:55
  • @Voo: I did not discuss this since extending a class InputParameters afterwards does not really change this, the code will also be not binary compatible. ABI compatibility in C++ is a separate problem, which leaves the ground on which this question is based on.
    – Doc Brown
    Commented Dec 26, 2022 at 23:51
  • (1/3) I do realise that I have unnecessarily made my example a little blurry. To be more specific, I am trying to solve a problem that arises because an existing function needs more and more input arguments as time progresses (due to new requirements from client side; not scalability?). And yes, the calling code is not under my control. I want to make the already existing calls to the function foo untouched but the newer calls make use of the new arguments added.
    – awakened
    Commented Dec 27, 2022 at 3:23
  • (2/3) I can make the example simpler by changing void foo(int i) to void foo(int i, bool enablethis) (get rid of the optional argument), and then to void foo(int i, std::string whatever, bool enablethis). class InputParameters has an appropriate name but since I put up a generic example here, I stick with the name InputParameters.
    – awakened
    Commented Dec 27, 2022 at 3:23
  • 2
    @awakened: the callers will have to recompile their code, whatever route you go. And my answer still stands: you can add more optional parameters to foo, where the existing calls don't have to be touched, or you can add more and more members to InputParameters with some default initialization for new members in the constructor - that will also allow existing calls not to be touched - technically, this is both equivalent for this purpose. If you need an extra class or not must be based on a different criteria than "existing callers shall not change".
    – Doc Brown
    Commented Dec 27, 2022 at 8:55
5

Adding a class doesn't actually solve much. The client now sees a consistent function to call, but the class keeps changing instead. So they still have to rewrite their code.

A better solution is to add new interfaces and keep the old ones for compatibility. Or plan ahead and don't keep changing things with every version.

6
  • "...but the class keeps changing instead. So they still have to rewrite their code." I am sorry, but I don't understand this. version 1.0 class InputParameters{ public: int i; }; client side code: void callfoo() { InputParameters args; args.i=42; foo(args); } version 1.1 class InputParameters{ public: int i; bool enablethis; }; client side code: void callfoo() { InputParameters args; args.i=42; foo(args); } void callfoonewer() { InputParameters args; args.i=42; args.enablethis=false; foo(args); }
    – awakened
    Commented Dec 27, 2022 at 11:00
  • Client doesn't have to rewrite void callfoo() { InputParameters args; args.i=42; foo(args); } in version 1.1 I am sorry about the formatting.
    – awakened
    Commented Dec 27, 2022 at 11:04
  • @awakened, you will have to make sure your class has a default constructor, and that the unset values are always appropriate for every version of the software.
    – Simon B
    Commented Dec 27, 2022 at 17:35
  • to be clear, (assuming I always use POD for my class that passes the input arguments), the client side don't have to rewrite their code with the approach that I suggested. Is that right? Also, can you please provide an example for your statement - "add new interfaces and keep the old ones for compatibility." Are you suggesting to create an Adapter class?
    – awakened
    Commented Dec 29, 2022 at 15:21
  • @awakened If your languare supports function overloading, then you can create new functions with the same name, but more parameters. Keep the old ones for compatibility. The user doesn't care how you choose to implement that behind the scenes. If you can't overload, give the new function a new name. Microsoft adds "Ex" to the end of function names, e.g. CopyFile (the original), CopyFileA (ASCII version), CopyFileW (wide character version), CopyFileExA (new version, ASCII) and CopyFileExW (new version, wide characters).
    – Simon B
    Commented Dec 29, 2022 at 15:46
4

Well in my experience you can seldom change a function signature drastically without updating all places it is called from.

But if such a change is not going to break your code, I would add a new foo() entry point with the new function signature. Then you can refactor the old entry point to define the default argument values then call the new function.

A special case arises when your language does not support same-named functions with different argument lists. An example is the Windows system libraries (DLLs). This lead to pairs of functions named foo() and fooExt() when new argument lists were required.

4
  • Thanks for your reply. What if the calling functions are at client side? I don't want my clients to keep changing their code for any change in my code.
    – awakened
    Commented Dec 26, 2022 at 5:05
  • What is the problem? If the clients call the old function they won't see any change.
    – kiwiron
    Commented Dec 26, 2022 at 5:13
  • Say, I need to change the algorithm in the function (I realise using dependency injection would have been the correct approach), then I would need to make the change in all the overloaded functions.
    – awakened
    Commented Dec 26, 2022 at 5:24
  • The change needs to be made only in one place. See the second paragraph of my answer.
    – kiwiron
    Commented Dec 26, 2022 at 7:36
3

Deprecation.

If you are updating a method such that it requires a new parameter and there is no sensible default which preserves old behavior then anyone who wants to use the new version must update their code to provide the new parameter. There's just no avoiding it, it has to come from somewhere and you're already saying you can't provide it, so the people calling your code must provide it. (As other answers are saying, it doesn't matter how you arrange this--if it's coming as a normal parameter, or in a parameter object, or whatever, putting a wrapper won't materialize an otherwise unknown value out of thin air.)

But yes, this is quite inconvenient to have to update stuff without warning every time you update a library, so instead you can just let them keep using the old version for some time. Provide the new version and the old version in parallel, mark the old version as deprecated so people know it's going away (and hopefully when), and stretch out the window that people have to update their existing code while you keep moving forward.

1

What is the best approach to handle such changes?

I'm surprised that no-one mentioned using semantic versioning so far:

Given a version number MAJOR.MINOR.PATCH, increment the:

  • MAJOR version when you make incompatible API changes

  • MINOR version when you add functionality in a backwards compatible manner

  • PATCH version when you make backwards compatible bug fixes

In the case of breaking backward compatibility change the major version number.

In your case, changing void foo(int i) to void foo(int i, std::string whatever, bool enablethis=true) would not be 1.2 using semantic versioning, but rather 2.0.0 (assuming void foo(int i) was introduced in 1.x.x).

This also (among other things) makes you more cautious introducing backwards incompatible changes since it will bump the major version every time you do so.

Semantic versioning has been around for quite some time, and its concept is pretty easy to understand plus its language agnostic.

Your idea of passing the parameters via a class only pushes the problem to another part of your API.

Your (API) users should be able to identify (quickly) API changes using the version number before digging through your documentation / code. Semantic versioning has pretty much become an industry standard (at least, in the web technology space).

I highly encourage you to use that versioning scheme.

3
  • What if I don't want to break the backward compatibility? From your answer, what I could take was a way to let client side know of major changes that may break backward compatibility.
    – awakened
    Commented Dec 27, 2022 at 13:11
  • 1
    @awakened Yes, I'm aware that I did not get into the coding part. However, I think API versioning and implementation are tightly coupled together. Use sensible version numbers and use an implementation technique that is as backwards compatible as you need it to be. As said (not only by me, by the way) using a class only pushes the problem to a different part of your API. So it's really not worth getting into that (for my answer, at least) as others have already given good advice on how you could deal with that :)
    – Marco
    Commented Dec 27, 2022 at 16:43
  • 1
    My answer should only be seen as an augmentation to the other answers already given.
    – Marco
    Commented Dec 27, 2022 at 16:43
1

Backwards compatibility is ultimately about preserving the same outputs given the same inputs across time. That's it in a nutshell although a tad simplified, but it's a good thing to remember when it's a concern. It's not too unlike unit and integration testing although you might have to delve into ABI issues and lots of details relating to compilers and vendor implementations depending on what you're doing.

If your foo example is exported across binary module boundaries in some way -- like through a dylib/shared lib export or a table of function pointers (virtual functions have ABI issues across compilers and changes, even appending virtual functions to the bottom, the last time I checked) -- then any change to its signature or return type breaks ABI. You can't touch your interfaces if backwards binary compatibility is a concern. There are ways to add new functions on the side that people can call instead but the original functions need to maintain their same signature and return type to preserve backwards binary compatibility.

If source compatibility is your only concern which would require your users to compile their code against the latest version to be usable with the latest version, then you broke it in your 1.2 example:

void foo(int i, std::string whatever, bool enablethis=true)

whatever needs to be an optional parameter with a default value if you want to at least preserve backwards source compatibility as you'll obviously get build errors in all the older code now that it requires 2 arguments instead of 1.

For decent approaches and best practices, it ultimately depends on your needs. It's easy to overkill a versioning solution that results in boatloads of boilerplate for new versions as well as overkill it with a solution that tries to avoid it. I'd ultimately just try to keep the general concept in mind that backwards compatibility is about preserving the same outputs given the same outputs, and try not to get overly fancy with your solution for it. There are often solutions that crop up if you run into the need to change something like a function signature that cannot possibly be replaced with an alternative addition on the side, but I wouldn't try to engineer all of them in advance. At the same time, you don't want to paint yourself in a corner if backwards compatibility is a big concern. It's a balancing act.

0

Bundling input parameters in a class dont give you backward compatibility. Yes, its an issue of backward compatibility, not scalability.

Your code is backward compatible when your clients - users of your APIs - dont have to do anything to be compatible to new version of your code.

Use Method Overloading

If your language permits it, great. If it dont then use psuedo-method-overloading by having new function with different name but same functionality (and different number of arguments). Note that in documentation.

Still keep the old function with same signature. Change its inner code to call the new function with default or null values for new arguments.

Use Interface

This may be an overkill but here goes:

Bind arguments, old ones in one class, say A; new ones in another class, say B. Have an interface X. Have both A and B implement X.

Ofcourse this works only at time when you are designing the old version. The first approach - Use Method Overloading - work even after your old APIs are already released.

Have your old function use an instance of the interface, not of any concrete class. Problem solved. You never have to change signature of this function for changing number or types of arguments.

When you release new version change internal of the function to somehow check that what concrete object it is (which class). Then if it is an old class's object then create the new class's object using values in old class's object for all common properties between old and new, and put in default or null values for new properties.

To find what concrete class's object is passed, depending on your language you can use reflection, "undefined" value for non-existing properties etc. Depending on your language you can also change your old class's object to new class's object by adding properties on the fly.

2
  • Thanks. Doesn't checking the type of concrete class break the Liskov Substitution Principle?
    – awakened
    Commented Dec 27, 2022 at 13:44
  • @awakened In this case, no, the LSP do not break. You use parameter of interface type, so an object of any class that implements the interface can be passed in as argument. Infact the LSP is followed in spirit and letter, both. Interworkings of your function is flexible as far as LSP is concerned. If you force user to send some other info as well, such as a type argument or size argument then LSP is violated, though still only in spirit, not a clean shot anyway. Its because you would force extra action on user depending on object's type so its not clean, straight substitution.
    – Atif
    Commented Dec 27, 2022 at 14:04
0

A common method is to have one argument which is a dictionary. Now as long as your function examines everything in the dictionary. You can pass in whatever you like.

An example is creating a font object in MacOS/iOS: a font can have a name, a point size, a weight, a Color, and then lots of attributes that are needed if you want to do proper typesetting. It can be based on an existing font, it can be based on a purpose, and so on. All that is handled easily by a dictionary.

PS. Some of the counter arguments in comments only apply if your code is completely untested. That’s what unit tests are for.

5
  • 1
    Dictionaries usually come with the cost of reduced static analysis. I would not recommend to use them.
    – Marco
    Commented Dec 27, 2022 at 10:51
  • @marco-a, can you please explain what do you mean by static analysis? Is it something like what Microsoft's Visual Studio uses Intellisense for (like looking for references to a function)?
    – awakened
    Commented Dec 27, 2022 at 13:14
  • 3
    @awakened Static analysis can include a lot of things, but at its most basic, certainly includes compile-time type checking. If you call a function with explicit parameters and they're the wrong type, the compiler will complain. With a dictionary, your code will blow up on execution. Users tend to dislike such behavior... Commented Dec 27, 2022 at 15:50
  • What @JoãoMendes said, looking for references to a function is only one thing static analysis can do for you. However, the type checking is much more useful because it prevents errors before they even have a chance of occurring. With dictionaries this is much more difficult to implement, as you would have to have the compiler understand what kinds of dictionaries are valid to pass and which are not. Since this is tagged c++ I would highly refrain from using such an API. C/C++ offer extensive type safety, use it.
    – Marco
    Commented Dec 27, 2022 at 16:39
  • Guys, I’ve never done that without runtime checking. And I’ve often done it when the dictionary came from a server where the compiler has no say. Plus you can suggest a better method to apple if you like.
    – gnasher729
    Commented Dec 28, 2022 at 9:55

Not the answer you're looking for? Browse other questions tagged or ask your own question.