[back]
Public constants across assemblies and default parameter values in C#
I knew about "evil" public constants and default method parameters on public interfaces for a while, but I like to verify things for myself. Having recently delved into CLR details, I rememberd this and wrote a small demonstration program.

The reason why something like:
public const int MY_CONST = 1337;

is evil is because any time a different (consumer) assembly which references this constant is compiled, the constant value at that time is baked into the compiled consumer assembly.

Assume we start with a simple assembly called Provider. It contains one class, with a public constant inside:
namespace Provider {


public static class Constants {

public const int MY_CONST = 1337;
}
}

We then write us a nice Consumer assembly, which references the constant from Provider:
using System;


namespace Consumer {

internal sealed class MainClass {

static void Main( string[] args ) {

int t = Provider.Constants.MY_CONST;
Console.WriteLine( t );
Console.ReadLine();
}
}
}

We now compile Consumer and take a look at the IL disassembly for the Main method inside Consumer.MainClass:
.method private hidebysig static void  Main(string[] args) cil managed

{
.entrypoint
// Code size 19 (0x13)
.maxstack 1
.locals init ([0] int32 t)
IL_0000: ldc.i4 0x539
IL_0005: stloc.0
IL_0006: ldloc.0
IL_0007: call void [mscorlib]System.Console::WriteLine(int32)
IL_000c: call string [mscorlib]System.Console::ReadLine()
IL_0011: pop
IL_0012: ret
} // end of method MainClass::Main

Note how there is no mention of the Provider.Constants class or its MY_CONST field. The value of 1337 is simply copied into the IL of the Consumer assembly in the following instruction (0x539 is hex for 1337):
  IL_0000:  ldc.i4     0x539

Clearly, since the types in the Provider assembly are not accessed, changes to the constant will not be known to the Consumer assembly unless Consumer is re-compiled.

Here is a similar example with default method parameter values. We'll create the following two types in our Provider assembly:
namespace Provider {


public interface IMyType {

void DoSomething( int someParam = 8 );
}

public class MyType : IMyType {

void IMyType.DoSomething( int someParam ) {
}
}
}

Our consumer method looks like this:
namespace Consumer {


internal sealed class MainClass {

static void Main( string[] args ) {

Provider.IMyType t = new Provider.MyType();
t.DoSomething();
}
}
}

After compiling our Consumer, we find its IL to be:
.method private hidebysig static void  Main(string[] args) cil managed

{
.entrypoint
// Code size 14 (0xe)
.maxstack 2
.locals init ([0] class [Provider]Provider.IMyType t)
IL_0000: newobj instance void [Provider]Provider.MyType::.ctor()
IL_0005: stloc.0
IL_0006: ldloc.0
IL_0007: ldc.i4.8
IL_0008: callvirt instance void [Provider]Provider.IMyType::DoSomething(int32)
IL_000d: ret
} // end of method MainClass::Main

We now inspect the line
  IL_0007:  ldc.i4.8

which translates to "Push 4-byte integer value 8 onto stack". This means that in preparation for the call to IMyType.DoSomething, the parameter value 8 is specified.

Again, we see that the value has been copied into the Consumer assembly, which means that if the default value changes in the Provider, the Consumer assembly won't know about it until Consumer is re-compiled.

A possible solution for public constants


In terms of sharing values between assemblies, one can easily change usages of public constants to public static readonly fields with little additional run-time overhead, as such:
namespace Provider {


public static class Constants {

public static readonly int MY_CONST = 1337;
}
}

After re-compiling the Consumer, its IL now shows:
.method private hidebysig static void  Main(string[] args) cil managed

{
.entrypoint
// Code size 19 (0x13)
.maxstack 1
.locals init ([0] int32 valueFromProviderAssembly)
IL_0000: ldsfld int32 [Provider]Provider.Constants::MY_CONST
IL_0005: stloc.0
IL_0006: ldloc.0
IL_0007: call void [mscorlib]System.Console::WriteLine(int32)
IL_000c: call string [mscorlib]System.Console::ReadLine()
IL_0011: pop
IL_0012: ret
} // end of method MainClass::Main

We can clearly see how the value of the MY_CONST field is read at run-time via
  IL_0000:  ldsfld     int32 [Provider]Provider.Constants::MY_CONST

which means that even if the value changes inside the Provider assembly, the new value will be read properly by the Consumer.

A possible solution for default parameter values


I prefer rigour over convenience, so I'm not too keen on the idea of default parameter values. I consider such a contract to be weaker than when everything is fully specified. Therefore, if the consumer finds the value 8 acceptable, then they should have to pass it in explicitly. If the consumer just shouldn't care about what the default value is, then a parameterless overload should exist to support this.