Trying to combine functional style (immutable objects) and flexibility of property setters. For the sake of example, let’s say we have a soil types table with two attributes: Color and Name. I am looking for a way to alternate Names, but not Color. Here is how I solved it:
// retrieving: all objects are immutable
SoilTypes types = SoilTypes.Default;
ISoilType clay1 = types.Clay;
ISoilType clay2 = types[3];
// derive an alternated immutable copy
SoilTypes altTypes = types
.With(tt =>
{
// tt.SensitiveFines.Color is still read only
tt.SensitiveFines.Name = "Very sensitive fines!";
tt[2].Name = "Purely Organic soil!";
});
// retrieving: everything is immutable
ISoilType sensitiveFines = altTypes.SensitiveFines;
Where this interface is immutable:
public interface ISoilType
{
Color Color { get; }
string Name { get; }
}
And this class is mutable:
public class SoilType : ISoilType
{
public static implicit operator SoilType((Color Color, string Name) tuple) =>
new SoilType(tuple.Color, tuple.Name);
internal SoilType(ISoilType source)
: this(source.Color, source.Name)
{
}
internal SoilType(Color color, string name)
{
Color = color;
Name = name;
}
public Color Color { get; }
public string Name { get; set; }
}
And this non-generic class is immutable:
public class SoilTypes : SoilTypes<ISoilType>
{
public static SoilTypes Default = new SoilTypes(
(White, "Undefined"),
(Red, "Sensitive Fines"),
(Green, "Organic Soil"),
(Blue, "Clay"),
(Orange, "Silty Clay"));
public SoilTypes(params SoilType[] types)
: base(types)
{
}
public SoilTypes With(Action<SoilTypes<SoilType>> update)
{
var copy = this
.Select(t => new SoilType(t))
.ToArray();
update(new SoilTypes<SoilType>(copy));
return new SoilTypes(copy);
}
}
while this generic base used in both situations:
public class SoilTypes<TType> : ReadOnlyCollection<TType>
where TType : ISoilType
{
internal SoilTypes(TType[] types)
: base(types)
{
}
public TType Undefined => this[0];
public TType SensitiveFines => this[1];
public TType OrganicSoil => this[2];
public TType Clay => this[3];
public TType SiltyClay => this[4];
}
3 Answers 3
I'm afraid this is not fully immutable becasue I am able to change the Name
with a simple cast:
altTypes.Dump();
((SoilType)altTypes.SensitiveFines).Name = "foo";
altTypes.Dump();
The underlying data type is still SoilType
so the interface does not protect the data from being overriden.
Consider a user that writes a function like this one because he doesn't like interfaces :-)
public static void foo(SoilType bar)
{
bar.Name = "new name";
}
and calls it
foo((SoilType)altTypes.SensitiveFines);
altTypes.Dump();
Name
changed. Unfortunatelly I have no idea how to prevent it yet.
-
\$\begingroup\$ I would say that it is the same trick like using
IReadOnlyList<T>
as a field type for a mutable collection. It is not bullet proof, but good enough to deliver the point of immutability. Good business logic code has no casting operators at all :) \$\endgroup\$Dmitry Nogin– Dmitry Nogin2017年04月23日 17:53:46 +00:00Commented Apr 23, 2017 at 17:53 -
\$\begingroup\$ @DmitryNogin true, that's why it's better to call
AsReadOnly
and not just rely on the interface. It's hard to prevent the data from people who doesn't know this rule and try to hack into everything :-] \$\endgroup\$t3chb0t– t3chb0t2017年04月23日 18:03:05 +00:00Commented Apr 23, 2017 at 18:03 -
\$\begingroup\$ Frankly speaking, I just usually need a tip for myself to understand from the API shape what a hell it was about many years ago... :) Agree, you are right - some kind of mutable
AltSoilType
(inherited fromSoilType
) builds a more understandable dichotomy. Thanks! \$\endgroup\$Dmitry Nogin– Dmitry Nogin2017年04月23日 18:19:30 +00:00Commented Apr 23, 2017 at 18:19 -
\$\begingroup\$ C# scares me out on everyday basis - one can not extend a get-only property with a setter after inheriting. Common, it is a total shame. \$\endgroup\$Dmitry Nogin– Dmitry Nogin2017年04月23日 18:53:02 +00:00Commented Apr 23, 2017 at 18:53
-
1\$\begingroup\$ @DmitryNogin This might have something to do with the fact that a getter-only property gets backing field that cannot be accessed from code, see Is it possible to access backing fields behind auto-implemented properties? \$\endgroup\$t3chb0t– t3chb0t2017年04月23日 19:02:54 +00:00Commented Apr 23, 2017 at 19:02
Guess, I must start with a disclaimer again -- after multiple rereadings, still unsure what exactly the code tries to achieve.
The big confusion (of mine)
There's one thing I really don't like about SoilTypes<TType>
, namely the ad hoc-ish mapping to collection entries by index.
The consumer of the class will have to know that implementation detail, right?
Don't have a C# compiler in front of me at the moment so I could play with things.
Is there a way to keep public static SoilTypes Default = ...
and the public TType Undefined => this[0];
as close together as possible (meaning, in the same class)?
Not sure if it is achievable.
On naming the lambda parameters
As a minor thing, I'd note that tt
is a bit confusing. Bet, you know we can write (@type => ...
.
Readability
I know that .With(...)
fluent syntax is very well known, I haven't really seen that working with a collection (non-scalar) object, though.
In other words, while you're not inventing anything new with this idiom, it's still a bit unintuitive to me.
Please disregard this comment if you find it a subjective thing. :)
P.S. Good question, just like many others that you post on CR!
-
\$\begingroup\$ 1) Yep, you will need a VS2017 for this :) It is just to ensure that property assignments operations are syntactically correct in a special context only + implementing prototype inheritance between immutable objects, which are safe to share and reuse. 2) My business logic requires access to records by name and index. 3) Nope, there is no way to put population and shortcuts at the same class. 4) Lambda parameter represents temporary mutable container, so it could probably be
@types
oralt
. \$\endgroup\$Dmitry Nogin– Dmitry Nogin2017年04月22日 18:32:08 +00:00Commented Apr 22, 2017 at 18:32 -
\$\begingroup\$ @DmitryNogin I wish my comment was more helpful \$\endgroup\$Igor Soloydenko– Igor Soloydenko2017年04月22日 18:34:43 +00:00Commented Apr 22, 2017 at 18:34
I have defined mutable/immutable dichotomy in a cleaner way according @t3chb0t answer:
public class SoilType
{
internal SoilType(Color color, string name)
{
Color = color;
Name = name;
}
public Color Color { get; }
public string Name { get; protected set; }
internal AltSoilType Mutable =>
new AltSoilType(this);
}
And
public class AltSoilType : SoilType
{
internal AltSoilType(SoilType source)
: base(source.Color, source.Name)
{
}
public new string Name
{
get { return base.Name; }
set { base.Name = value; }
}
internal SoilType Immutable =>
new SoilType(Color, Name);
}
-
\$\begingroup\$ This should be bullet-proof and I share your C# feeling sometimes ;-P like here where it's possible to make the setter
protected
but notvirtual
(I tried this previously while experimenting with it) \$\endgroup\$t3chb0t– t3chb0t2017年04月23日 20:31:44 +00:00Commented Apr 23, 2017 at 20:31
Explore related questions
See similar questions with these tags.
// tt.SensitiveFines.Color is still read only
-- could you elaborate what you mean/wht is Color "read only"? I don't see how the Color attribute is different from the Name in the code provided. \$\endgroup\$SoilType
class does not have a setter forColor
. \$\endgroup\$Name
in a semi-mutable-immutable fashion? Btw. theSoilTypes
should beSoilTypeCollection
. Collections do not have plural names ;-] \$\endgroup\$SoilTypes.Default.Clay.Name = "Dirty thing"
but do allow to writeSoilTypes.Default.With(alt => alt.Clay.Name = "Dirty thing")
to derive and overrideSoilTypes
content, so we combine immutability with the syntactical efficiency of property assignments. My API is way wider then 5x2 table, so I do need it. P.S. I feel guilty aboutSoilTypes
name, but it is how domain experts reference it - it is not just a technical artifact (container) - it is actually a business object. \$\endgroup\$