Freigeben über


Optional argument corner cases, part four

(This is the fourth and final part of a series on the corner cases of optional arguments in C# 4; part three is here.)

Last time we discussed how some people think that an optional argument generates a bunch of overloads that call each other. People also sometimes incorrectly think that

void M(string format, bool b = false)
{
Console.WriteLine(format, b);
}

is actually a syntactic sugar for something morally like:

void M(string format, bool? b)
{
bool realB = b ?? false;
Console.WriteLine(format, realB);
}

and then the call site

M("{0}");

is rewritten as

M("{0}", null};

That is, they believe that the default value is somehow "baked in" to the callee.

In fact, the default value is baked in to the caller; the code on the callee side is untouched and the caller becomes

M("{0}", false);

A consequence of this fact is that if you change the default value of a library method without recompiling the callers of that library, the callers don't change their behaviour just because the default changed. If you ship a new version of method "M" that changes the default to "true" it doesn't matter to those callers. Until a caller of M with one argument is recompiled it will always pass "false".

That could be a good thing. Changing a default from "false" to "true" is a breaking change, and one could argue that existing callers *should* be insulated from that breaking change.

This is a fairly serious versioning issue, and one of the main reasons why we pushed back for so long on adding default arguments to C#. The lesson here is to think carefully about the scenario with the long term in mind. If you suspect that you will be changing a default value and you want the callers to pick up the change without recompilation, don't use a default value in the argument list; make two overloads, where the one with fewer parameters calls the other.

(This is the fourth and final part of a series on the corner cases of optional arguments in C# 4; part three is here.)

Comments

  • Anonymous
    May 19, 2011
    It looks like the covenience has its price. I'm wondering why I haven't heard of these corner cases in C++ before...

  • Anonymous
    May 19, 2011
    Eric, I'm not sure if this is a corner case, but, if the default values are baked in to the caller, why default declarations such as public void SomeMethod(ISomeInterface thing = new SomeClass()) are not allowed? Why default values have to be a constant? Thanks

  • Anonymous
    May 19, 2011
    @never: Probably because in the C++ compile model, you usually end up recompiling the caller.  Most default arguments are probably used on inlined functions anyway, which rather blurs the distinction between whether the default got baked in to the caller or callee, because the whole callee got baked in.

  • Anonymous
    May 19, 2011
    Also for most people, who are not writing compilers, the defaults usually make sense, and they rarely even need to know the specifics. How many times do you rebuild your libraries, but not executable, in your visual studio solution? By default it re-compiles every dependant, and you never run into this.

  • Anonymous
    May 19, 2011
    @never  To further Ben Voight's comments, nearly all of these corner cases apply equally to C++, the interface-related ones being the exception (due to the fact that C++ has no formal concept of interfaces).

  • Anonymous
    May 19, 2011
    @Allan I think this is because the value is baked in the caller. If you allowed a non constant value, then a new value need to be created by each client before calling the function, and that could violate the principle of least surprise.

  • Anonymous
    May 19, 2011
    @Allan: because the default value needs to be baked into an attribute on the callee method declaration, so that the compiler can see it when the caller builds.  Thus all of the constant-ness requirements that apply to custom attributes also apply to default values.

  • Anonymous
    May 19, 2011
    The comment has been removed

  • Anonymous
    May 19, 2011
    The comment has been removed

  • Anonymous
    May 19, 2011
    The same versioning issue apperas as in case of constants, I guess.

  • Anonymous
    May 19, 2011
    I can't even begin to imagine the thought processes of someone who thinks the compiler rewrites the callee. Of course,it might be that I lack imagination, but I've always thought the compiler would simply push the defaults as required when it compiled the caller code. This makes it pretty obvious that if you use a default, change the default but don't recompile calling code then you'll be passing the default values as they were at the time the caller was compiled. shrug

  • Anonymous
    May 19, 2011
    @Rob: IIRC, F#'s version of optional parameters changes to something like Eric's second version. Though in that case you explicitly write the "x = obj ?? new SomeObj()" code. But it doesn't feel like a huge stretch to just go from that to a model where (int x = 4) is transformed to (int?x) { if (x==null)x=4 }.

  • Anonymous
    May 19, 2011
    "default values are backed into the types on which they are defined as const values with synthetic names. " Er, you do know what happens to const values if you change them and don't recompile, right?

  • Anonymous
    May 19, 2011
    Ooops yes that was a thinko, I meant to say static readonly...

  • Anonymous
    May 19, 2011
    Though actually thinking about it I like the translation into Option<T> like behaviour, so long as Option was done as a struct instead.

  • Anonymous
    May 19, 2011
    Two thoughts:

  1.  Is the caller-rewrite behavior the same as VB.Net's behavior?  IOW, if I version a VB.Net class then call it from C# or vice versa, do I get consistent behavior?
  2. @Rob Manderson: With all respect, I think you didn't think quite enough.  At compile time for the callEE, it could easily have been transformed into the nested overloads model.  And then when the callER is compiled the appropriate generated callEE overload is selected.  And then also at run time when the compile-time type / run-time type issue is resolved.  As Eric pointed out in an earlier post, there are challenges with this approach.  But there's nothing which makes it logically impossible, or which makes "bake the callEEs default into the callER" inevitable.  Eric's decision to write 4 posts about this is IMO evidence the whole situation  is non-obvious.
  • Anonymous
    May 22, 2011
    @Shuggy: The problem with your scheme is that it means that programmer loses control over the public surface of the class. Now any language targeting CLR that does not implement that default-arg scheme (which would be all existing ones) suddenly sees a lot of weird public static members in other classes. Adding generated private members is fair game for the compiler. Public ones, not so much.

  • Anonymous
    May 22, 2011
    @Mark: Yes, the behavior is the same in VB. The only catch is that C# permits some changes to default values which are breaking to VB (on source level, of course, not binary). Specifically, in VB, if the base class changes the default argument for one of its virtual methods, then any VB overrides will stop compiling, whereas in C# they will compile just fine.