So, you want to create a value type? Easy, just use the [<Struct>] attribute to mark your type. Oh, but you want a non-default constructor. Well that is okay too. Ah, but you want an immutable type that can handle the default constructor? Now that is interesting…
If you read the CLR specification then you will find out that value types implicitly support an all-zero value. This carries over into all .NET languages and you can see references to it in the F# specification as well. If you want an immutable value type that can handle this fact, then you may need to detect when a zero value construction has taken place.
Before going further, I should point out that there are some alternatives available. If you are working in a pure F# environment, then you do not need to worry about this detail in general as your code should not allow it to occur. Also, you can use the discriminated union, enumeration and record types as alternatives. However, if these types do not meet your needs (for example, you do not want the namespace polluting behaviour of a record type) then a custom value type with what I will term “controlled zero semantics” is what you need.
Zero value construction
In my case, I wanted a value type that had a single datum, an F# immutable Map. Here is the basic code:
#light [<Struct>] type Model(newData : Map<string, int>) = member s.model = newData
However, there is an automatic implicit constructor on this type and it is accessible from F# (and other .NET languages). Here is what happens in the F# interpreter:
> let m = Model();;
val m : Model = FSI_0002+Model
> m;;
val it : Model = FSI_0002+Model {model = null;}
Detecting the zero construction
Now you might think that you can just compare model to null but you cannot:
> if m.model = null then System.Console.WriteLine("model is null");;
if m.model = null then System.Console.WriteLine("model is null");;
-------------^^^^
stdin(8,14): error FS0043: The type 'Map<string,int>' does not have 'null' as a proper value
There is a solution, however. Most value types can also be boxed. This is the process of wrapping the value into an object instance. Note, value types are not automatically object instances and some cannot be converted. This may be a surprise to those of you that have not looked at the CLR specification or previously needed to know the distinction. However, most value types can be boxed and this will address the current need. F# provides a pervasive box for the purpose:
box : ‘a -> obj
Since null is a proper value for the type obj a comparison is valid, for example:
> if (box m.model) = null then
System.Console.WriteLine("model is null");;
model is null
val it : unit = ()
Managing zero value semantics
Detecting a zero value construction is not generally sufficient because value types must be able to be compared. If it is possible that your type can return a semantic zero value (generally from modification of a non-zero value), then it is important that the value is returned as a true zero value representation.
Let me make this clearer. In my case, my default map has a single item in it. This is the semantic zero value that I want to use. As my type is immutable, I return a new instance of Model whenever the data is changed. Suppose that I fist add a new element and then delete that element. The simple (and incorrect) process would be as follows:
- let m = Model() [True zero value Model]
- let m2 = m.AddNode() [Detect true zero and convert to semantic zero, then add new node; result has two nodes]
- let m3 = m.RemoveNode() [Not true zero, just remove node; has one node and is semantic zero
- (m = m3) [false, content differs]
In fact, the situation is slightly worse. Here is what happens if you compare an empty Map with a null Map value:
> let m1 = Model()
let m2 = Map<string,int>.Empty
if (m1.model = m2) then
System.Console.WriteLine("values are equal")
else
System.Console.WriteLine("values differ");;
values differ
val m1 : Model = FSI_0002+Model
val m2 : Map<string,int>
The solution is to convert any value that is semantically zero into the zero value representation. Here is an example adapted from my code:
#light
type IE = System.Collections.IEnumerable
type IEgen<'T> = System.Collections.Generic.IEnumerable<'T>
type KVP<'K,'V> = System.Collections.Generic.KeyValuePair<'K,'V>
[<Struct>]
type Model(newModelData : Map<string,int>) =
static member private zero_value_data =
Map<string,int>.Empty
|> Map.add "Initial Value" 0
// model may be null
member private s.model = newModelData
// Value types have implicit zero initialisation (in CLR)
// This is a trick to test for a null initialisation
member private s.is_empty_model = (box s.model) = null
// Need to equate an empty model with a zero value
static member Empty with get() = new Model()
// Cannot use Map members if Map is null
static member private make_non_null_data (m:Model) =
if m.is_empty_model then
(true, Model.zero_value_data)
else
(false, m.model)
// Create a concrete Model representation if needed
static member private make_non_null (m:Model) =
let (empty, newData) = m |> Model.make_non_null_data
if empty then
new Model(newData)
else
m
// Accessors for the above
member private s.full_model_data
with get() = s |> Model.make_non_null_data |> snd
member private s.full_model
with get() = s |> Model.make_non_null
// transition back to zero value if only contains root
static member private equate_empty_value (m:Model) =
if (m.model = Model.zero_value_data) then
Model()
else
m
member s.add key value =
Model( s.full_model_data
|> Map.add key value)
|> Model.equate_empty_value
member s.remove key =
Model( s.full_model_data
|> Map.remove key)
|> Model.equate_empty_value
interface IE with
member s.GetEnumerator() =
(s.full_model_data
:>IE).GetEnumerator()
interface IEgen<KVP<string,int>> with
member s.GetEnumerator() =
(s.full_model_data
:>IEgen<KVP<string,int>>).GetEnumerator()
I have added IEnumerable support to allow direct, visual comparison of data. Here is some code to test zero value semantics:
let test() =
let m1 = Model()
let m2 = m1.add "new value" 1
let m3 = m2.remove "new value"
printf "m1 = %A\n" (m1 |> Seq.to_list)
printf "m2 = %A\n" (m2 |> Seq.to_list)
printf "m3 = %A\n" (m3 |> Seq.to_list)
if m1 = m2 then
printf "Error: m1 = m2 : bad inequality semantics\n"
else
printf "Correct: m1 <> m2 : inequality semantics\n"
if m2 = m3 then
printf "Error: m2 = m3 : bad inequality semantics\n"
else
printf "Correct: m2 <> m3 : inequality semantics\n"
if m1 = m3 then
printf "Correct: m1 = m3 : zero value semantics\n"
else
printf "Error: m1 <> m3 : bad zero value semantics\n"
;;
do test();;
And the output from the F# interpreter:
m1 = [[Initial Value, 0]] m2 = [[Initial Value, 0]; [new value, 1]] m3 = [[Initial Value, 0]] Correct: m1 <> m2 : inequality semantics Correct: m2 <> m3 : inequality semantics Correct: m1 = m3 : zero value semantics val it : unit = ()
Conclusion
It is possible to create value types that can accommodate the CLR’s zero-value construction, but if the overhead of doing so is great then you should use a class type. Nevertheless, there are cases, especially when a custom type is primarily wrapping another type, when providing controlled zero value semantics may be a good solution.
As always, feedback is welcome.