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.propslocal désactive proprement :<Project> <PropertyGroup> <Nullable>disabled</Nullable> </PropertyGroup> </Project> - Pour les fichiers isolés :
#nullable disableen première ligne.
- Côté .NET (serveur, tests, shared hors Unity) via le
-
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 :
CamelCasedsans underscore. Ça vaut pour les classes, méthodes, propriétés, et champs publics. - Privé :
_camelCasedavec 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 :
PascalCaseaussi, pas deSCREAMING_SNAKE. - Booléens : formes
IsXxx,HasXxx,CanXxx,ShouldXxx. Jamais de double négation. - Events (au sens
.NET) :OnXxxpour 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, quexn'est pas null — par exemple après unawait 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 :
readonlysur les champs instanciés une fois en constructor.initsur les propriétés (C# 9+) quand on veut permettre l'initialisation par objet-initializer sans setter public.recordpour les value objects qui méritent equality par valeur.structpour 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
CancellationTokenquand 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 unSetup()appelé depuisOnEnable). Je ne laisse pas unasync voidtraîner.SuppressCancellationThrow()quand je ne veux pas qu'une cancellation remonte en exception.- Pas de
.Resultni.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:
À refaire systématiquement après ajout d'un nouveau type serialisé.mpc -i D:\projets\TheRuneMaker\Assets -o "Assets/Client/Scripts/MessagePackGenerated.cs" - Versioning par Union : jamais de breaking change silencieux sur un format sérialisé.
Si un champ doit changer de type ou de nom, je crée un[Union(0, typeof(GameState))] [Union(1, typeof(GameStateV2))] public interface IGameState { byte[] Serialize(); }V2et je laisse leV0/V1chargeable. 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 (
forplutôt queforeach) 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>etConcurrentQueue<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
Exceptionpour 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_EDITORpour 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,HACKexplicites 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.
namespacequi reflète la structure de dossier, sans sur-ingéniérie.Runemaker.Engine,Runemaker.Client,Shared.Net.Messages.usingtriés et minimaux — l'IDE le fait, je laisse faire Rider avec ses.DotSettingsversionné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
#regionuniquement 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 expressionplutô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 nullplutô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(); recordpour 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.
dotnetCLI 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
.asmdefde tests référence ces DLLs viaprecompiledReferences.
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
.Resultet.Wait()sur desTask— 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
varquand le type n'est pas évident à la lecture — dans ce cas je le tape explicitement.