# csharp.md — Idiomes et conventions C# Ce fichier rassemble mes pratiques C# transverses, indépendantes de Unity (qui a son propre fichier [`unity.md`](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` : ```xml enable ``` - 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 : ```xml disabled ``` - 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`). - **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 : ```csharp 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 : ```csharp [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 : ```csharp public interface IGameEvent { Guid GetId(); void Apply(GameState gameState, EventBus? sideEffectManager); List 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 : ```csharp public abstract class ConnectedBehaviour : ConnectedBehaviour { public T? Data { get; private set; } public void Set(T data) { ... } protected abstract void OnSet(GameState state, T element); } public interface IUpdatable { void UpdateFrom(T source); } public void BroadcastBut(Func 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). ```csharp 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` 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. ```csharp 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 : ```csharp 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#** : ```csharp [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` : ```bash 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é. ```csharp [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 : ```csharp var queue = new Queue( 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` pour les séquences mutables ordonnées. - `Dictionary` pour les lookups. - `ConcurrentDictionary` et `ConcurrentQueue` pour les échanges inter-thread (typiquement serveur ↔ réseau). - `IReadOnlyList` / `IReadOnlyDictionary` en exposition publique quand je veux empêcher la mutation externe. - `HashSet` 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"`. ```csharp public void ApplyEvent(Action 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 : ```csharp /// /// Arcane de feu /// Mécanique de flammes, basée sur l'utilisation de stacks... /// 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é : ```csharp 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 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 : ```bash 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 : ```csharp 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` et **vérifie les interactions** (pas les side-effects) — c'est du behavioral testing : ```csharp [Test] public void SetLevelScore_SavesToDisk() { var mock = new Mock(); var state = new PersistantState(mock.Object); state.SetLevelScore("level1", 100); mock.Verify(x => x.SetString(It.IsAny(), It.IsAny()), 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](https://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 ` - 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.