Proposal: Typed Aliases/Wrapper Structs #1695
Replies: 59 comments 4 replies
-
Seems like a single-value record. What's different? Would we want to allow wrapping multiple values? public enum Suit { Diamonds, Hearts, Clubs, Spades }
public enum Face { Two, Three, Four, Five, Six, Seven, Eight, Nine, Ten, Jack, Queen, King, Ace }
public struct PlayingCard : (Suit, Face); |
Beta Was this translation helpful? Give feedback.
-
I'll answer after re-reading, one difference is built-in comparison/conversion with the underlying type. Would this have an advantage over extending records to provide that boilerplate? |
Beta Was this translation helpful? Give feedback.
-
I assume the conversion/comparison type would be a tuple if there are multiple values. One direction of the conversion would be covered by a deconstructor (mentioned in #1204). |
Beta Was this translation helpful? Give feedback.
-
More transparency with the underlying type which really wouldn't be possible with general purpose records so easily. For example, I think it'd be useful to compare a
I think that wrapping a tuple would work well. Perhaps the C# compiler could also emit a proper deconstruct method as well. There might be a lot of overlap between this and named tuples. |
Beta Was this translation helpful? Give feedback.
-
Why not have teh wrapper type implement all the interface of the wrapped type? |
Beta Was this translation helpful? Give feedback.
-
Not a fan of the null-behavior not propagating into the wrapper. A null-string throws on things like .ToString. I would expect the wrapper to throw as well. |
Beta Was this translation helpful? Give feedback.
-
I feel like i would far prefer either:
And i think i far prefer '1'. |
Beta Was this translation helpful? Give feedback.
-
That's an interesting idea. For cases where the wrapped type implements a generic interface where the wrapped type is a generic type argument would it emit an implementation for both the wrapped type and the wrapper type? Or should interfaces like Similarly would it make sense to propagate versions of any comparison operators? I think it would be useful to be able to compare a wrapper directly to an instance of the wrapped type: CustomerId custId = ...;
if (custId == "12345") { ... }
That's fair.
I was trying to describe 2 in a roundabout way. I thought it might make it easier to use the wrapper type in situations excepting the wrapped type as such a conversion should be type safe, but I can see arguments both ways here. I'll mention both approaches as an open question. |
Beta Was this translation helpful? Give feedback.
-
What if it was possible that these types didn't exist as far as the runtime was concerned? I'm imagining C# would have some sort of "Aliased" types alongside Value types and Reference types. Within C# it would work like some pretend syntax: public alias CustomerId : string;
public alias AnotherId : string; compiles as: [UnderlyingType(typeof(string))]
public sealed class CustomerId {}
[UnderlyingType(typeof(string))]
public sealed class AnotherId {} in usages: // 1. allow implicit casts to the type:
public CustomerId Foo() => default;
// 2. explicit cast required here:
public string Bar(CustomerId id) => (string)id;
// 3. no conversion exists between two alias types that share an underlying type:
public bool Baz(CustomerId id, AnotherId id2) => id == id2;
// 4. but you could cast and have the compiler figure out what you wanted to do:
public bool BazWorks(CustomerId id, AnotherId id2) => (string)id == id2;
// works now because implicit string -> AnotherId is valid
// and operator==(AnotherId, AnotherId) works by definition of alias types
// since the underlying type is string and it is defined there
// lowers to:
[Alias(typeof(CustomerId))] public string Foo() => default;
public string Bar([Alias(typeof(CustomerId))]string id) => id;
//nothing for 3rd because of compile error
public bool BazWorks([Alias(typeof(CustomerId))] id, [Alias(typeof(AnotherId))] id2) => id == id2; Not sure if an identity conversion makes it through the compiler, I suppose it doesn't really matter if those exist in the lowered form or not. The aliased type would behave as if all members on the underlying type with parameters or return values matching the underlying type were instead the alias type. An implicit conversion would be defined from the underlying type to the alias type in namespaces where the alias type was in scope. An explicit conversion from the alias type to the underlying type would also be defined. This might be a bit annoying with extension methods explicitly defined on underlying types but meh... |
Beta Was this translation helpful? Give feedback.
-
The JIT is already pretty decent at optimizing away single-field structs used as wrappers. I'd rather have proper nominal types that can be used as proper nominal types in signatures and everywhere else than something that is erased by the compiler and faked by the runtime. |
Beta Was this translation helpful? Give feedback.
-
I'm a very big fan of so-called semantic types and I'd love to see C# make it easier to declare these (and thus encourage adoption). However, I'm not convinced that this proposal is a good way to achieve what's needed.
This is something I like - it gives me control over what's exposed. For example, I could create an
I've implemented implicit casts on a number of semantic types in non-trivial codebases - and the result was significant pain. If a I toyed for a while with having a
I agree that it's useful - but in the business domains I know, an The key is that decisions around the choice of functionality exposed by the semantic type have to originate from the semantics of the type, not accidental details of the implementation. In the late 1990's, I worked on a system that used a semantic type for vehicle identification numbers (aka VINs). That system broke when new vehicles being imported from Japan showed up with an I expect that if the underlying functionality of an int had been automagically exposed on the VIN datatype, releasing the hotfix would have required a lot more work. |
Beta Was this translation helpful? Give feedback.
-
I agree. The goal isn't to necessarily make the wrapper type behave exactly as the underlying type. It's to make it easier to treat them as single cases based on the underlying value. That in of itself is a decent amount of boilerplate, pretty much everything I've described except maybe for implementations of I don't want this to behave like inheritance. If As for the other concerns, I agree with a number of them and I've been toying around with ways to approach them. I think that it should be possible to include a body for the wrapper type and to add members. I think it's important to be able to define a custom constructor which can enforce validation. I think it would be useful to be able to specify an And I'm certainly open to further ideas. Having seen these kinds of types used commonly in F# and Scala I find myself really wanting them. And I've worked on C#/Java projects that have tried to emulate them by manually creating these types which leads to a lot of bloated and repeated boilerplate. |
Beta Was this translation helpful? Give feedback.
-
My vote would be not to do any of this, it has been my experience that there is always additional constraints on such types (for example often string alias types compare case insensitive or can only be ascii or integer keys can only be positive or specific negative values). I was just thinking that there is some precedent for an erased type already in the language. In fact I think a specification for this type could be seen as a generalization of both nullable reference types and Implementing specific interfaces automatically is bad because eventually we probably want to implement other interfaces and that makes two different versions of the compiler not compatible with each other if we did. Which means we would be forever left with the specific interfaces we specified in the first version. That is sorta an issue I have with all of these codegen heavy proposals (records, data classes, this). |
Beta Was this translation helpful? Give feedback.
-
For the sake of comparison, here's what F# generates: type CustomerId = CustomerId of string [Serializable]
[StructLayout(LayoutKind.Auto, CharSet = CharSet.Auto)]
[DebuggerDisplay("{__DebugDisplay(),nq}")]
[CompilationMapping(SourceConstructFlags.SumType)]
public sealed class CustomerId : IEquatable<CustomerId>, IStructuralEquatable, IComparable<CustomerId>, IComparable, IStructuralComparable
{
internal readonly string item;
public int Tag { get; }
public string Item { get; }
public static CustomerId NewCustomerId(string item);
internal CustomerId(string item);
internal object __DebugDisplay();
public override string ToString();
public sealed override int CompareTo(CustomerId obj);
public sealed override int CompareTo(object obj);
public sealed override int CompareTo(object obj, IComparer comp);
public sealed override int GetHashCode(IEqualityComparer comp);
public sealed override int GetHashCode();
public sealed override bool Equals(object obj, IEqualityComparer comp);
public sealed override bool Equals(CustomerId obj);
public sealed override bool Equals(object obj);
} |
Beta Was this translation helpful? Give feedback.
-
The interfaces involved in situations like this are 13+ years old and the gold standard in .NET for defining equality. It's not been an issue for F#, where this kind of type is idiomatic. |
Beta Was this translation helpful? Give feedback.
-
I also think that those wrapper structs are very close to single value records. public struct CustomerId(string Value) {} I see that the (explicit) casting support and public struct CustomerId(string _) {} or public struct CustomerId(explicit string Value) {} the latter could maybe even be extended, to add explicit cast support to multi value records. I don't know if that's useful, though. |
Beta Was this translation helpful? Give feedback.
-
Nice, I forgot about that issue. I love my comment. Even then I was pretty sure that source generators weren't going to be unshelved anytime soon.
I agree. The reason I decided to bring this issue now is that recent proposals from the LDM have cast some doubt (in my mind) regarding the future of "records". The closest approximation seems to be an extension of and migration from tuples which appears to include extra baggage. As a proponent of DUs and single-value unions for the purpose of type-safe domain wrappers I wanted to capture that here. |
Beta Was this translation helpful? Give feedback.
-
@juffindell
Looking at F# it seems to avoid a bit of this mess by excluding quite a bit of functionality described here. Single case unions don't have any concept of validation and comparisons are done using the default equality comparer. You can access the underlying value directly and do your own comparisons against it. But that means that the concerns of how equality is determined is on the consumer, not the type itself. Maybe that's all fine. Maybe this type is trying to do too much. Afterall, |
Beta Was this translation helpful? Give feedback.
-
That's true, although I can't stand that behavior myself and wish that C# enums were more like Java ones 😄 I think even without validation, newtype wrappers would still be worth having, especially if the syntax for defining one is sufficiently low-ceremony. Most of the things I'd make wrappers for don't have a natural at-construction-time validation anyway so it probably wouldn't be too big of a loss. I think custom equality is a bit harder to let go of; I expect a lot of string wrappers would want case-insensitive equality instead of normal equality and it would be nice to not have to manually remember that at every call site:
rather than:
That might be too much of a can of worms to open though, and I guess it isn't too much of a hardship to write the newtype the old-fashioned way if you want custom equality. |
Beta Was this translation helpful? Give feedback.
-
FWIW, when writing custom wrapper types around string, I avoid the problems caused by
This gives a nice external API to consumers and allows me to centralise any case sensitivity decision internally, avoiding the subtle bugs that occur when uses are inconsistent. |
Beta Was this translation helpful? Give feedback.
-
@theunrepentantgeek Yep, I can imagine this for many cases. Goes back to @eyalsk's comment regarding customization (which is also similar to records). |
Beta Was this translation helpful? Give feedback.
-
The primitive types have special runtime supports for speed, so struct wrapper will cause performance degradation in some case. The first case which I conjure is dictionary key. I tried the following benchmark. using System;
using System.Collections.Generic;
using System.Linq;
using BenchmarkDotNet.Attributes;
namespace MyBenchmark
{
public class StructHashCodeTest
{
public static IEnumerable<int> KeySource => Enumerable.Range(0, 0xfffff);
public static Dictionary<int, int> IntKeyedDic =
KeySource.ToDictionary(x => x, x => x);
public static Dictionary<SimpleWrapper, int> SimpleWrapperKeyedDic =
KeySource.ToDictionary(x => new SimpleWrapper(x), x => x);
public static Dictionary<StandardWrapper, int> StandardWrapperKeyedDic =
KeySource.ToDictionary(x => new StandardWrapper(x), x => x);
[Benchmark]
public int IntKeyed()
{
var retval = 0;
foreach(var key in IntKeyedDic.Keys)
retval += IntKeyedDic[key];
return retval;
}
[Benchmark]
public int SimpleIntWrapperKeyed()
{
var retval = 0;
foreach(var key in SimpleWrapperKeyedDic.Keys)
retval += SimpleWrapperKeyedDic[key];
return retval;
}
[Benchmark]
public int StandardIntWrapperKeyed()
{
var retval = 0;
foreach(var key in StandardWrapperKeyedDic.Keys)
retval += StandardWrapperKeyedDic[key];
return retval;
}
[Benchmark]
public int IntRecastedKeyed()
{
var retval = 0;
foreach(var key in SimpleWrapperKeyedDic.Keys)
retval += IntKeyedDic[key.Value];
return retval;
}
}
public readonly struct SimpleWrapper
{
public readonly int Value;
public SimpleWrapper(int value) => Value = value;
}
public readonly struct StandardWrapper
{
public readonly int Value;
public StandardWrapper(int value) => Value = value;
public override bool Equals(object obj)
=> obj is StandardWrapper other && Value == other.Value;
public override int GetHashCode() => Value.GetHashCode();
public override string ToString() => Value.ToString();
}
} The following is the results.
Let alone that struct implicit Is this an acceptable cost? |
Beta Was this translation helpful? Give feedback.
-
The |
Beta Was this translation helpful? Give feedback.
-
The more I think about this the more I think that it doesn't make sense for the wrapper to throw. The reason is that if the wrapper is a string GetString<T>(T value) => value?.ToString();
string s = null;
var id = (CustomerId)s;
GetString(id); // throws NullReferenceException And given that the wrapper is a struct it's not possible for the language to prevent it from wrapping How do people who write struct wrappers deal with this kind of situation today? Or do they manage it on a case-by-case basis and perhaps trust that they never let a wrapper get into this state? |
Beta Was this translation helpful? Give feedback.
-
@HaloFour I either manage it so that zeroed fields represent a valid state, or I throw at each point as early as possible. |
Beta Was this translation helpful? Give feedback.
-
How does this feature compare to the following feature: I am linking to a specific comment in #259 because in that comment, a special
In the referenced proposal, there would be no |
Beta Was this translation helpful? Give feedback.
-
The goals are the same but the implementation is certainly different. The type erasure of that proposal will inevitably lead to problems where that behavior cannot be enforced by anything. Downlevel compilers and other languages will continue to see that "CustomerId" as an |
Beta Was this translation helpful? Give feedback.
-
Hi guys. I'm researching the possibility of using a wrapped primitive for IDs in my ECS, so performance is very critical. I'm doing some benchmarks here and while I could reproduce the results @glassgrass posted, that is easily 'fixable' by simply implementing What's a bit strange though is that the hash of an int should be the int itself I think, so there's no point in also comparing by equality in this case, and that's an optimization I'd expect to happen under the hood for us. I'm now looking for other cases where the wrapper might not be optimized out of the box to see if they're also 'fixable'. If you guys have any ideas of situations where there could be a difference in performance please let me know. @HaloFour I pinged you on Gitter about this but deleted my wall of text by accident and couldn't find it in the browser memory :P |
Beta Was this translation helpful? Give feedback.
-
My biggest pet peeve in dotnet code" Dictionary<string, string> dict; Okay... so... what is this doing? |
Beta Was this translation helpful? Give feedback.
-
Here's a real-world example of such a thing: [Serializable]
[JsonConverter(typeof(Converter))]
[Record(Features.OperatorEquals | Features.Equality)]
public sealed partial class NadexUserName {
public string Value { get; }
public NadexUserName(string value) {
if (string.IsNullOrWhiteSpace(value)) throw new ArgumentNullException(nameof(value));
Value = value.ToUpperInvariant();
if (Value.StartsWith("DEMO-"))
Value = Value.Replace("DEMO-", "DEMO");
}
public override string ToString()
=> Value;
public static NadexUserName FromString(string value)
=> string.IsNullOrWhiteSpace(value) ? null : new NadexUserName(value);
public static explicit operator string(NadexUserName x)
=> x?.ToString();
public static implicit operator NadexUserName(string x)
=> FromString(x);
public class Converter : JsonConverter {
public override bool CanConvert(Type objectType)
=> objectType == typeof(NadexUserName);
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) {
writer.WriteValue((value as NadexUserName)?.ToString());
}
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) {
return FromString((string)reader.Value);
}
}
} |
Beta Was this translation helpful? Give feedback.
-
In any particular domain it's pretty common to have bits of data that are represented by simple/primitive types such as
int
orstring
. Unfortunately storing this domain data in these data types also loses that type information and type safety. This can result in accidentally passing a customer ID in place of an order ID.One common approach to solving this problem is to declare a wrapper so that you can safely encapsulate the value in a type-safe container. This nicely addresses the issue of accidentally passing the wrong value. But the problem with this approach is that you immediately lose the ability to compare the equality of the wrapper type or use as a key within writing a lot of additional boilerplate.
So make it easier to write these wrappers I propose a way to declare a "typed alias" which will generate all of the necessary boilerplate. This boilerplate would include:
readonly
field.IEquatable<T>
for the wrapper typeIStructuralEquatable
Here are some examples:
The developer may add their own members into the body of the wrapper type:
If any of the generated members conflict with user-written members, the user written members take precedence and those generated members are skipped. Through these two mechanisms the developer can customize the behavior of the wrapper type.
And yes, this could probably easily be accomplished via source generators.
Beta Was this translation helpful? Give feedback.
All reactions