serdedotnet / serde

Serde.NET is a C# port of the popular Serde serialization library for Rust
https://serdedotnet.github.io/
BSD 3-Clause "New" or "Revised" License
142 stars 5 forks source link

Friction connecting serializers to ISerialize<T> #179

Open AlgorithmsAreCool opened 2 months ago

AlgorithmsAreCool commented 2 months ago

Hello and thank you for this great library. I have a prototype Cbor Serializer that is built on Serde and I've been trying to keep up with the new API changes.

I'm trying to understand the idiomatic way to use a custom serializer.

[GenerateSerialize, GenerateDeserialize]
public record Foo(...);

public class CborSerializer : ISerializer {  ... }

void Main()
{
    var foo = new Foo();
    var serializer = new CborSerializer(...);

    //...?
}

The tension is on how I'm supposed to pass foo to the serializer. The source generator for Foo uses an explicit interface implementation for the Serialize method, which forces me to cast it to use it directly. But if foo is a struct then i actually need to use a guarded generic call, which leads me towards an extension method for ISerializer or a method directly on CborSerializerclass.

But that class has a bunch of interface methods that are supposed to be implementation details. Looking at the included JsonSerializer, it hides these methods by making them explicit implementations also. Is this the expected pattern? It kinda feels like there should be an in-box way of linking these two concepts.

agocke commented 2 months ago

Great questions. Let me try to answer and see if it helps. I’m not sure I fully understand your question.

The basic approach for organizing serializers is to have a public static Serialize<T>(T t) where T : ISerialize<T>. That should handle the simple case where the type itself implements the interface.

But sometimes you need to use a wrapper type. The suggestion there is to have a separate overload for the impl type. So void Serialize<T, U>(T t, U impl) where U : ISerialize<T> => impl.Serialize(t, new CborSerializer;. Note that the U instance here doesn’t matter in terms of state. The ISerialize method takes T as one of the arguments, so you could even use the default(U) value of a struct as the argument to the U parameter.

Does this help?

The basic idea around moving to ISerialize<T> is that it can avoid storing an instance of the target type. In some cases that can improve performance. There’s also a potential C# language feature called extensions that might do all this impl stuff automatically and the stateless implementation might be better there.

AlgorithmsAreCool commented 2 months ago

Hmm, ok. So, there is no API that defines the serializer's entry point. Each serializer must define their own ad-hoc method to accept ISerialize<T> objects (and possible wrapper type). This isn't too much friction; I was just trying to see if I missed anything.

As an aside, what you call wrappers, this is similar to what the Orleans Serializer calls Surrogates, right? A helper that allows us to customize serialization behavior for types that we don't own/control.

(also is there a discord or other place for this kind of discussion?)

agocke commented 2 months ago

Hmm, ok. So, there is no API that defines the serializer's entry point. Each serializer must define their own ad-hoc method to accept ISerialize objects (and possible wrapper type). This isn't too much friction; I was just trying to see if I missed anything.

Right. I'm not strictly opposed to defining some shared API surface, but this is the kind of thing that I could see different formats wanting to define for themselves. For instance, my JSON serializer doesn't currently support it, but I think an overload for pretty printing would make sense.

As an aside, what you call wrappers, this is similar to what the Orleans Serializer calls Surrogates, right?

I think so. There are a lot of names for basically the same thing: wrappers, surrogates, proxies, witness types, etc. The academic name is "witness types" but I find that isn't well-known outside of Haskell users. "Wrappers" made sense when they actually did wrap the type. Now that both ISerialize and IDeserialize are generic and don't carry any state, wrappers doesn't really make sense.

I was thinking of renaming everything to "proxy" but that has a long history of uses in .NET and I don't know if it would make sense to everyone. Would you prefer "surrogate" to "proxy"?

(also is there a discord or other place for this kind of discussion?)

At the moment, no. Maybe I could make a channel in the C# Discord (https://discord.gg/csharp)

AlgorithmsAreCool commented 2 months ago

I was thinking of renaming everything to "proxy" but that has a long history of uses in .NET and I don't know if it would make sense to everyone. Would you prefer "surrogate" to "proxy"?

🤷‍♀️. As long as the concept is there so that i can "enroll" foreign types into Serde, then i'm very very happy. I do think that the term surrogate is good in general for this concept. I think witness is a little more abstract.