Skip to content

Instantly share code, notes, and snippets.

@arturaz
Last active December 2, 2022 19:51
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save arturaz/3f18d80ee9172f7699c6b7341ca2da96 to your computer and use it in GitHub Desktop.
Save arturaz/3f18d80ee9172f7699c6b7341ca2da96 to your computer and use it in GitHub Desktop.
Practical demonstration of config diffing with higher kinded types in C#
using System;
using JetBrains.Annotations;
namespace FPCSharpUnity.core.functional.higher_kinds;
/// <summary>
/// As <see cref="Func{A,B}"/>, but transforms the functor W1[_] to W2[_], instead of transforming the value.
/// </summary>
[PublicAPI] public interface FuncK<W1, W2> {
HigherKind<W2, A> apply<A>(HigherKind<W1, A> value);
}
/// <summary>
/// As <see cref="Func{A,B,C}"/>, but transforms the functor W1[_] and W2[_] to WR[_], instead of transforming the
/// values.
/// </summary>
[PublicAPI] public interface FuncK<W1, W2, WR> {
HigherKind<WR, A> apply<A>(HigherKind<W1, A> value1, HigherKind<W2, A> value2);
}
using System;
using JetBrains.Annotations;
using FPCSharpUnity.core.concurrent;
using FPCSharpUnity.core.exts;
namespace FPCSharpUnity.core.functional.higher_kinds;
[PublicAPI] public interface Functor<Witness> {
HigherKind<Witness, B> map<A, B>(HigherKind<Witness, A> data, Func<A, B> mapper);
}
[PublicAPI] public class Functors :
Functor<Id.W>, Functor<Option.W>
{
public static readonly Functors i = new Functors();
protected Functors() {}
public HigherKind<Id.W, B> map<A, B>(HigherKind<Id.W, A> data, Func<A, B> mapper) =>
Id.a(mapper(data.narrowK().a));
public HigherKind<Option.W, B> map<A, B>(HigherKind<Option.W, A> data, Func<A, B> mapper) =>
data.narrowK().map(mapper);
}
using GenerationAttributes;
using JetBrains.Annotations;
namespace FPCSharpUnity.core.functional.higher_kinds;
[PublicAPI] public static partial class Id {
public struct W {}
public static Id<A> narrowK<A>(this HigherKind<W, A> hkt) => (Id<A>) hkt;
}
/// <summary>Id monad is a way to lift a value into a monad when dealing with higher-kinded code.</summary>
[PublicAPI, Record(ConstructorFlags.Apply)]
public readonly partial struct Id<A> : HigherKind<Id.W, A> {
public readonly A a;
public static implicit operator A(Id<A> id) => id.a;
public static implicit operator Id<A>(A a) => new Id<A>(a);
}
using System;
using FPCSharpUnity.core.config;
using FPCSharpUnity.core.exts;
using FPCSharpUnity.core.functional;
using FPCSharpUnity.core.functional.higher_kinds;
using FPCSharpUnity.core.json;
using FPCSharpUnity.core.test_framework;
using FPCSharpUnity.core.utils;
using GenerationAttributes;
using NUnit.Framework;
namespace HKTConfigDiff;
/// <summary>
/// Some configuration for the game, parametrized with a higher-kinded type,
/// such as <see cref="Id{A}"/> or <see cref="Option{A}"/>.
/// </summary>
[Record(ConstructorFlags.Apply | ConstructorFlags.Withers)]
public partial class GameConfig<W> {
public readonly HigherKind<W, int> hitPoints;
public readonly HigherKind<W, string> name;
}
public static class TestDiffing {
[Test]
public static void test() {
var config = GameConfig.a(hitPoints: Id.a(100), name: Id.a("Archer"));
var newConfig = config.withHitPoints(Id.a(200));
newConfig.shouldEqual(GameConfig.a(hitPoints: Id.a(200), name: Id.a("Archer")));
var configDiff = config.zip(newConfig, new GameConfig.DiffFuncK());
configDiff.shouldEqual(GameConfig.a(
hitPoints: Some.a(200), name: Option<string>.None
));
var diffAppliedConfig = config.zip(configDiff, new GameConfig.DiffApplyFuncK());
diffAppliedConfig.shouldEqual(GameConfig.a(
hitPoints: Id.a(200), name: Id.a("Archer")
));
diffAppliedConfig.shouldEqual(newConfig);
}
}
public static partial class GameConfigExts {
/// <summary>
/// Given two <see cref="GameConfig{W}"/> instances and a mapper produces a result
/// which joins the two instances somehow.
/// </summary>
public static GameConfig<W2> zip<W, W1, W2>(
this GameConfig<W> cfg, GameConfig<W1> cfg1, FuncK<W, W1, W2> mapper
) => GameConfig.a(
hitPoints: mapper.apply(cfg.hitPoints, cfg1.hitPoints),
name: mapper.apply(cfg.name, cfg1.name)
);
}
public static partial class GameConfig {
/// <summary>
/// Takes an older and newer version of a value and returns `Some` if they differ,
/// `None` if they are equal.
/// </summary>
public class DiffFuncK : FuncK<Id.W, Id.W, Option.W> {
public HigherKind<Option.W, A> apply<A>(
HigherKind<Id.W, A> value1, HigherKind<Id.W, A> value2
) {
var eq = System.Collections.Generic.EqualityComparer<A>.Default;
A val1 = value1.narrowK().a;
A val2 = value2.narrowK().a;
return eq.Equals(val1, val2) ? Option<A>.None : Some.a(val2);
}
}
/// <summary>
/// Takes a value and maybe an updated value and returns the updated value if it's
/// present or old value otherwise.
/// </summary>
public class DiffApplyFuncK : FuncK<Id.W, Option.W, Id.W> {
public HigherKind<Id.W, A> apply<A>(
HigherKind<Id.W, A> value1, HigherKind<Option.W, A> value2
) =>
// ReSharper disable once ConvertClosureToMethodGroup - doesn't compile
// otherwise.
value2.narrowK().fold(ifNone: value1, ifSome: a => Id.a(a));
}
}
#region JSON example
public static class TestJSON {
[Test]
public static void jsonSerialization() {
// Parse the full configuration from JSON.
GameConfig<Id.W> config = Config.parseJsonObject(
@"{""hitPoints"":100,""name"":""Archer""}", GameConfig.parser
).rightOrThrow;
// Serialize the full configuration to JSON.
var fullConfigJson = config.toJson(
Functors.i,
valueToJson: (name, jsonValueIdHkt) => {
Id<JsonValue> jsonValueId = jsonValueIdHkt.narrowK();
JsonValue jsonValue = jsonValueId.a;
return JsonObject.a(KV.a(name, jsonValue));
}
);
fullConfigJson.asVal.serialize().shouldEqual(
@"{""hitPoints"":100,""name"":""Archer""}"
);
// Parse the config difference from JSON.
var configDiff = Config.parseJsonObject(
@"{""hitPoints"":200}", GameConfig.diffParser
).rightOrThrow;
configDiff.shouldEqual(GameConfig.a(
hitPoints: Some.a(200), name: Option<string>.None
));
// Serialize the config difference to JSON.
var configDiffJson = configDiff.toJson(
Functors.i,
valueToJson: (name, value) => {
Option<JsonValue> maybeJsonValue = value.narrowK();
return maybeJsonValue.fold(
ifNone: JsonObject.empty,
ifSome: jsonValue => JsonObject.a(KV.a(name, jsonValue))
);
}
);
configDiffJson.asVal.serialize().shouldEqual(@"{""hitPoints"":200}");
}
}
public static partial class GameConfigExts {
public static JsonObject toJson<W>(
this GameConfig<W> cfg,
Functor<W> functor,
Func<string, HigherKind<W, JsonValue>, JsonObject> valueToJson
) =>
valueToJson("hitPoints", functor.map(cfg.hitPoints, hp => JsonValue.num(hp)))
+ valueToJson("name", functor.map(cfg.name, JsonValue.str));
}
public static partial class GameConfig {
public static readonly Config.Parser<JsonValue, GameConfig<Id.W>> parser =
Config.configParser.flatMapTry((_, cfg) => a(
hitPoints: Id.a(cfg.getInt("hitPoints")),
name: Id.a(cfg.getString("name"))
));
public static readonly Config.Parser<JsonValue, GameConfig<Option.W>> diffParser =
Config.configParser.flatMapTry((_, cfg) => a(
hitPoints: cfg.optInt("hitPoints"),
name: cfg.optString("name")
));
}
#endregion
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment