knowledge/csharp.md
Samuel Bouchet e9f8de4b90 first commit
2026-04-23 16:58:57 +02:00

18 KiB

csharp.md — Idiomes et conventions C#

Ce fichier rassemble mes pratiques C# transverses, indépendantes de Unity (qui a son propre fichier unity.md). Il couvre aussi ce qui est spécifique à .NET : serialisation MessagePack, async/await, organisation de solution.

Version et configuration

  • C# 9 minimum sur tous mes projets actuels, .NET 6+ côté serveur, netstandard 2.1 pour la couche partagée (compatibilité Unity Mono).

  • Nullable reference types activés partout. C'est non négociable :

    • Côté .NET (serveur, tests, shared hors Unity) via le .csproj :
      <PropertyGroup>
          <Nullable>enable</Nullable>
      </PropertyGroup>
      
    • Côté Unity, via un csc.rsp à côté de chaque .asmdef :
      -nullable
      
    • Pour les dossiers qui intègrent du code tiers non nullable-compliant, un Directory.Build.props local désactive proprement :
      <Project>
        <PropertyGroup>
          <Nullable>disabled</Nullable>
        </PropertyGroup>
      </Project>
      
    • Pour les fichiers isolés : #nullable disable en première ligne.
  • Solution multi-projets : chaque concern a son projet (Server/, Shared/, Tests/, Client Unity). La couche partagée est compilée deux fois (une par Unity Mono, une par .NET Core) pour garantir la compatibilité.

Conventions de nommage

Les règles sont strictes et visuelles pour qu'on lise la visibilité d'un coup d'œil.

  • Publique : CamelCased sans underscore. Ça vaut pour les classes, méthodes, propriétés, et champs publics.
  • Privé : _camelCased avec underscore en préfixe. Signal fort : ne pas toucher de l'extérieur, interne à la classe.
  • Interfaces : préfixe I (IGameEvent, INetworkMessage, IUpdatable<T>).
  • Génériques : T, TKey, TValue, TSelf — clarté plutôt qu'originalité.
  • Constantes : PascalCase aussi, pas de SCREAMING_SNAKE.
  • Booléens : formes IsXxx, HasXxx, CanXxx, ShouldXxx. Jamais de double négation.
  • Events (au sens .NET) : OnXxx pour les callbacks déclenchés.

Exemple typique :

public abstract class ConnectedBehaviour : MonoBehaviour {
    protected GameManager GameManager { get; /* ... */ }
    private GameManager? _gameManager = null;
    private bool _isSetup;
    internal CancellationTokenSource? _resetSource;
}

Le contraste visuel entre GameManager (propriété publique) et _gameManager (champ backing privé) rend la lecture sans ambiguïté.

Nullable : comment je m'en sers

Le nullable est un système d'intentions. Je m'en sers pour communiquer avec le futur lecteur (moi compris) :

  • T? : cette référence peut légitimement être nulle, tu dois gérer le cas.
  • T : cette référence ne devrait jamais être nulle, si elle l'est, c'est un bug.
  • null! : je te promets que cette valeur sera initialisée avant usage (typiquement par l'inspecteur Unity ou un framework DI).
  • x! : j'assume, à cet endroit précis, que x n'est pas null — par exemple après un await UniTask.WaitUntil(() => x != null, ...).
  • #nullable disable : uniquement pour les fichiers tiers ou legacy incompatibles.

Je ne mets jamais un ! pour faire taire un warning sans raison valable. Chaque ! est un contrat : si plus tard il se révèle faux, c'est moi qui aurai menti au compilateur.

Pattern classique pour les références Unity injectées :

[Required]
public TextMeshProUGUI StageText = null!;

null! parce que l'attribut [Required] (Odin/Artificetoolkit) + la validation éditeur garantissent que le champ est affecté avant l'exécution. C'est une promesse documentée, pas un contournement.

Interfaces et abstractions

Une interface existe pour une raison précise. Je distingue :

  • Interfaces de contrat — pour décrire ce qu'un type sait faire, utilisées pour le polymorphisme :
    public interface IGameEvent {
        Guid GetId();
        void Apply(GameState gameState, EventBus? sideEffectManager);
        List<ChangeEntry> ApplyWithChangelist(GameState gameState, EventBus? eventBus);
    }
    
  • Interfaces de testabilité / inversion — pour pouvoir injecter un mock. Mais seulement si le besoin existe ; pas par principe.
  • Interfaces marker + Union — pour le versioning via MessagePack (voir plus bas).

Ce que je refuse : l'interface d'une seule implémentation créée "par précaution". Si je trouve IThing avec uniquement Thing, je supprime l'interface. Le refactor inverse est facile, l'inverse pas.

Génériques et contraintes

J'utilise les génériques largement quand ils apportent de la clarté et du type-safety :

public abstract class ConnectedBehaviour<T> : ConnectedBehaviour {
    public T? Data { get; private set; }
    public void Set(T data) { ... }
    protected abstract void OnSet(GameState state, T element);
}

public interface IUpdatable<in T> {
    void UpdateFrom(T source);
}

public void BroadcastBut<T>(Func<T, bool> shouldSkip, T message)
    where T : INetworkMessage { ... }

Contraintes : where T : INetworkMessage, where T : class, where T : struct, new(). Je déclare les contraintes les plus fortes possibles — plus le compilateur en sait, moins on a de bugs.

Immutabilité

Immutable par défaut quand c'est raisonnable :

  • readonly sur les champs instanciés une fois en constructor.
  • init sur les propriétés (C# 9+) quand on veut permettre l'initialisation par objet-initializer sans setter public.
  • record pour les value objects qui méritent equality par valeur.
  • struct pour les petites données purement valeur (coordonnées, timings, IDs).
public struct BeatTiming {
    public readonly int Beat;
    public readonly float Offset;
    public BeatTiming(int beat, float offset) { Beat = beat; Offset = offset; }
}

Attention : une collection readonly n'empêche pas ses éléments de muter, elle empêche juste le conteneur d'être réaffecté. Quand c'est important, je passe par IReadOnlyList<T> en exposition publique.

Async / await

Règles stables :

  • UniTask côté Unity et côté partagé qui tourne en Unity (zéro-allocation, PlayerLoop-aware).
  • Task côté serveur .NET pur.
  • Toujours passer un CancellationToken quand la tâche peut survivre à un lifecycle (GameObject, connexion, requête HTTP). Pas par pureté, par pragmatisme : les tâches orphelines causent des leaks.
  • Forget() pour les fire-and-forget UniTask explicites (typiquement un Setup() appelé depuis OnEnable). Je ne laisse pas un async void traîner.
  • SuppressCancellationThrow() quand je ne veux pas qu'une cancellation remonte en exception.
  • Pas de .Result ni .Wait() — jamais. Deadlock garanti un jour.
protected virtual void OnEnable() {
    Setup().Forget();
}

private async UniTask Setup() {
    if (!_isSetup) {
        if (GameManager == null) {
            await UniTask.WaitUntil(
                () => GameManager != null,
                PlayerLoopTiming.Update,
                gameObject.GetCancellationTokenOnDestroy());
        }
        _isSetup = true;
        OnSetup(GameManager!.State);
    }
}

Le pattern "utility async throttled" que je réutilise souvent :

public static Action CreateThrottled(Action action, UniTask delayTask) {
    bool throttling = false;
    async void ThrottledEventHandler() {
        if (throttling) return;
        action();
        throttling = true;
        await delayTask.SuppressCancellationThrow();
        throttling = false;
    }
    return ThrottledEventHandler;
}

Sérialisation : MessagePack + Union

Pour tout ce qui doit persister ou transiter (saves, réseau), j'utilise MessagePack for C# :

[MessagePackObject]
public class CharacterMoveGameEvent : GameEvent {
    [Key(0)] public int Id;
    [Key(1)] public ushort CharacterShortId;
    [Key(2)] public Vector3 Position;

    [SerializationConstructor]
    public CharacterMoveGameEvent(int id, ushort characterShortId, Vector3 position) {
        Id = id;
        CharacterShortId = characterShortId;
        Position = position;
    }
}

Notes importantes :

  • [MessagePackObject(true)] pour un mapping automatique par nom, [MessagePackObject] + [Key(N)] explicite pour les types qui vont vraiment en réseau ou en save (contrôle de schema, stabilité).
  • [SerializationConstructor] pour lever l'ambiguïté quand plusieurs constructeurs existent.
  • AOT : côté Unity IL2CPP, il faut générer les formatters via mpc :
    mpc -i D:\projets\TheRuneMaker\Assets -o "Assets/Client/Scripts/MessagePackGenerated.cs"
    
    À refaire systématiquement après ajout d'un nouveau type serialisé.
  • Versioning par Union : jamais de breaking change silencieux sur un format sérialisé.
    [Union(0, typeof(GameState))]
    [Union(1, typeof(GameStateV2))]
    public interface IGameState {
        byte[] Serialize();
    }
    
    Si un champ doit changer de type ou de nom, je crée un V2 et je laisse le V0/V1 chargeable. Les saves ne se cassent pas, le serveur peut accepter plusieurs versions simultanément.

LINQ et collections

J'utilise LINQ largement pour l'expressivité déclarative :

var queue = new Queue<RuneSlotState>(
    Edges.Keys.Where(node => inDegree[node] == 0)
);

Règles :

  • LINQ est bon pour la lisibilité, pas pour la performance hot-path. Dans une boucle de tick à 120 BPM, je profile et j'inline si besoin.
  • .ToList() explicite quand je matérialise — une énumération différée consommée deux fois, c'est deux exécutions.
  • Dans les hot-paths, ZLinq ou itérations manuelles (for plutôt que foreach) pour éviter les allocations.

Collections préférées :

  • List<T> pour les séquences mutables ordonnées.
  • Dictionary<TKey, TValue> pour les lookups.
  • ConcurrentDictionary<TKey, TValue> et ConcurrentQueue<T> pour les échanges inter-thread (typiquement serveur ↔ réseau).
  • IReadOnlyList<T> / IReadOnlyDictionary<TKey, TValue> en exposition publique quand je veux empêcher la mutation externe.
  • HashSet<T> pour les appartenances rapides.

Gestion d'erreurs

  • Exception pour les vraies erreurs — état corrompu, invariant violé, input invalide côté API publique. Je préfère faire péter tôt et bruyamment.
  • Exceptions custom typées quand je veux permettre un catch spécifique : ApplicationException, types dédiés au domaine.
  • Pas d'exceptions pour le contrôle de flux — un cas "normal mais alternatif" est un retour explicite (bool TryXxx(out T result), T?, tuple (bool ok, T value), ou un type Result dédié si le projet l'introduit).
  • Assertions en éditeur : AssertApplicationConditions(gameState) que je n'appelle qu'en #if UNITY_EDITOR pour ne pas payer le coût en prod.
  • Log d'erreur = message actionnable : contexte, valeurs attendues vs observées, pas juste "error".
public void ApplyEvent(Action<GameState, EventBus?> apply, EventBus? eventBus) {
    lock (_lockObject) {
        if (_isApplyingEvent)
            throw new ApplicationException(
                "Nested ApplyEvent detected — events must not be applied from within another event's Apply.");
        _isApplyingEvent = true;
        try { apply(this, eventBus); OnEventApplied(eventBus); }
        finally { _isApplyingEvent = false; }
    }
}

Commentaires et documentation

  • XMLdoc sur les APIs publiques (///). Paramètres, valeur de retour, remarques pertinentes. Pas besoin de tout documenter si le nom suffit, mais dès qu'il y a une subtilité d'usage, je documente.
  • Commentaires en prose pour les logiques métier complexes — un paragraphe au-dessus de la méthode ou de la classe explique le pourquoi.
  • Pas de paraphrase : un commentaire qui dit "incremente i" au-dessus de i++ est du bruit. Le commentaire sert à dire ce que le code ne peut pas dire.
  • HTML dans les XMLdoc pour les descriptions riches (listes, paragraphes) destinées à l'outillage ou aux tooltips in-game :
    /// <summary>
    /// <b>Arcane de feu</b>
    /// <para>Mécanique de flammes, basée sur l'utilisation de stacks...</para>
    /// </summary>
    public static readonly ArcaneDef Fire = new("Fire");
    
  • TODO, FIXME, HACK explicites et signés — et reliés à un ticket si possible.

Organisation de code

  • Un fichier = un type public, même nom. Les types imbriqués ou privés peuvent partager le fichier de leur parent.
  • namespace qui reflète la structure de dossier, sans sur-ingéniérie. Runemaker.Engine, Runemaker.Client, Shared.Net.Messages.
  • using triés et minimaux — l'IDE le fait, je laisse faire Rider avec ses .DotSettings versionnés.
  • Méthodes courtes — quand une méthode dépasse ~40 lignes ou a plus de 2 niveaux d'indentation profonde, je me demande si elle doit être split.
  • Régions #region uniquement pour grouper par thème dans les classes massives où ça aide vraiment (tests). Pas pour cacher du code qu'on devrait déplacer.

Patterns C# modernes que j'utilise

  • Expression-bodied members pour les one-liners : public Guid GetId() => _id;
  • Pattern matching pour la lisibilité :
    var combatEntries = changelist.Where(e =>
        e is CombatStepEntry or CombatTargetEntry or CombatActionEntry
    ).ToList();
    
  • switch expression plutôt que des if-else en cascade.
  • nameof() à la place des strings littéraux pour les messages d'erreur et le binding.
  • is null / is not null plutôt que == null (évite les surcharges d'opérateur surprenantes).
  • Target-typed new() (C# 9) quand le type est évident à gauche : Dictionary<string, int> scores = new();
  • record pour les DTOs et value objects — equality par valeur gratuite, immutabilité par défaut.

IDE et tooling

  • Rider (JetBrains) comme IDE principal, versioné via les fichiers .DotSettings :
    • TheRuneMaker.sln.DotSettings / .csproj.DotSettings à la racine du projet
    • Ces fichiers encodent les conventions de format, de nommage, de style, les analyses désactivées, etc.
    • Les conventions d'équipe vivent dans ces fichiers, pas dans une page Confluence oubliée.
  • Hot reload côté serveur, Edit-mode reload + Unity Hot Reload côté client quand possible.
  • dotnet CLI pour les tâches répétitives :
    dotnet build
    dotnet test
    dotnet test --filter "FullyQualifiedName~CombatTests"
    dotnet test --filter "FullyQualifiedName~CombatTests.TestBasicCombat_HeroVsTwoEnemies"
    dotnet ef migrations add InitialCreate
    dotnet ef database update
    

Tests et mocks : Moq côté Unity / C#

Quand je dois mocker une dépendance (par exemple un accès PlayerPrefs, un wrapper d'I/O, un service réseau), j'utilise Moq avec Castle.Core. Le setup côté Unity :

  • DLLs dans Plugins/ ou via NuGetForUnity : Moq.dll, Castle.Core.dll, System.Diagnostics.EventLog.dll.
  • Toutes ces DLLs taguées Editor-only dans l'inspecteur — elles n'ont rien à faire dans un build runtime.
  • Le .asmdef de tests référence ces DLLs via precompiledReferences.

Le pattern clé : je ne teste jamais directement contre l'API Unity (PlayerPrefs, Time, Application.persistentDataPath). Je passe par une interface abstraite injectée dans le constructeur :

public interface IPlayerPrefs {
    void SetString(string key, string value);
    int GetInt(string key);
    bool HasKey(string key);
    void Save();
}

public class PersistantState {
    private readonly IPlayerPrefs _playerPrefs;
    public PersistantState(IPlayerPrefs playerPrefs) {
        _playerPrefs = playerPrefs;
    }
    // ...
}

Le test injecte un Mock<IPlayerPrefs> et vérifie les interactions (pas les side-effects) — c'est du behavioral testing :

[Test]
public void SetLevelScore_SavesToDisk() {
    var mock = new Mock<IPlayerPrefs>();
    var state = new PersistantState(mock.Object);

    state.SetLevelScore("level1", 100);

    mock.Verify(x => x.SetString(It.IsAny<string>(), It.IsAny<string>()), Times.Once);
    mock.Verify(x => x.Save(), Times.Once);
}

Bénéfice : les tests tournent en dotnet test en quelques millisecondes, sans ouvrir Unity, sans PlayerPrefs réels, sans fichiers à nettoyer. L'article détaillé est sur samuel-bouchet.fr/posts/2024-10-25-unity-test-mocks.

EntityFramework (côté serveur)

Quand un serveur a une DB, je passe par EF Core avec migrations :

  • Installation des tools : dotnet tool install --global dotnet-ef
  • Créer une migration : dotnet ef migrations add <MigrationName>
  • Appliquer : dotnet ef database update
  • Reset local propre : dotnet ef database drop && dotnet ef migrations add InitialCreate && dotnet ef database update

Les migrations sont des fichiers versionnés dans le repo, pas des scripts manuels. Chaque changement de schéma = une migration nommée explicitement.

Anti-patterns que je refuse

  • .Result et .Wait() sur des Task — deadlock garanti un jour.
  • catch (Exception e) { } — un catch silencieux est un bug qu'on cache.
  • ! en cascade pour étouffer les warnings nullables sans réfléchir.
  • Les propriétés auto-implémentées quand un champ public suffit (verbosité gratuite).
  • Les DTOs avec un setter public sur tout quand un constructor + init ferait l'affaire.
  • Les méthodes statiques qui cachent un état global mutable.
  • Les interfaces d'une seule implémentation créées "au cas où".
  • Les throw new NotImplementedException() qu'on oublie de remplacer.
  • Le var quand le type n'est pas évident à la lecture — dans ce cas je le tape explicitement.