Writing better comments in C# and C++
↑At a glance
Section titled “At a glance”A code comment explains context that the code does not communicate well on its own. It may document a design constraint, an external requirement, an unusual workaround or the contract of a public API. A comment does not make incorrect code correct, and comments that repeat every instruction can make the actual logic harder to see.
Three forms of comments in C# and C++
Section titled “Three forms of comments in C# and C++”| Form | C# | C++ | Common use |
|---|---|---|---|
| Single-line | // explanation |
// explanation |
A short rationale or reminder |
| Multiline / block | /* explanation */ |
/* explanation */ |
A longer local explanation |
| Documentation | XML such as /// <summary>...</summary> |
Doxygen comments such as /// @brief ... |
Public API documentation and IDE help |
Switch between the C# and C++ tabs throughout the page. Statement snippets assume the surrounding method supplies variables such as score and serverLeaseMilliseconds; class/function examples include their own declarations. The C++ examples use C++20.
// This is a single-line comment.int retries = 3; // Prefer a short inline comment only when it adds context.
/* The remote service can briefly return stale results. We retry only for that known, temporary condition. */int retryDelayMilliseconds = 250;
/// <summary>Represents a player's visible display name.</summary>public sealed class PlayerName{ public string Value { get; } public PlayerName(string value) => Value = value;}#include <string>#include <utility>
// This is a single-line comment.int retries = 3;
/* The remote service can briefly return stale results. We retry only for that known, temporary condition. */int retryDelayMilliseconds = 250;
/// @brief Represents a player's visible display name.class PlayerName final{public: explicit PlayerName(std::string value) : value_(std::move(value)) {} const std::string& Value() const { return value_; }
private: std::string value_;};In both languages, // runs to the end of the line and /* ... */ spans until its closing marker; block comments cannot be nested. Comments do not execute. C# gives /// XML documentation meaning when generating compiler documentation; in C++, /// is still a line comment and a tool such as Doxygen interprets its documentation conventions separately. See Doxygen’s documentation-block guide.
Explain why, not what
Section titled “Explain why, not what”Bad comments narrate obvious syntax:
// Add one to the score.score++;// Check if the score is above 100.if (score > 100) { /* ... */ }// Add one to the score.score++;// Check if the score is above 100.if (score > 100) { /* ... */ }Better comments record a meaningful reason:
// Scores over 100 are carried into the next round by design;// cap only the value shown on the progress bar.int displayedScore = Math.Min(score, 100);#include <algorithm>
// Scores over 100 are carried into the next round by design;// cap only the value shown on the progress bar.int displayedScore = std::min(score, 100);A good test: If the comment were deleted, would a future reader lose information that cannot be recovered easily from the code? If not, use a better name or remove the comment.
Documentation comments for public APIs
Section titled “Documentation comments for public APIs”Document purpose, inputs, results and exceptions next to the API declaration. The C# tab uses XML documentation; the C++ tab uses Doxygen commands. These document comparable contracts with different collection types: C# rejects a null or empty list; C++ accepts a valid std::span and rejects an empty span. Both find the highest value without modifying the input, including when all scores are negative.
For C#, summary, param, returns and exception describe the contract; see and paramref refer to code elements. The compiler checks some references and parameter names. For C++, Doxygen processes @brief, @param, @return and @throws; the C++ compiler does not verify the prose or enforce documented exceptions.
using System;using System.Collections.Generic;
public static class ScoreCalculator{ /// <summary>Finds the highest score in a non-empty sequence.</summary> /// <param name="scores">Scores to compare. Must contain at least one value.</param> /// <returns>The largest supplied score.</returns> /// <exception cref="ArgumentNullException"> /// Thrown when <paramref name="scores" /> is null. /// </exception> /// <exception cref="ArgumentException"> /// Thrown when <paramref name="scores" /> is empty. /// </exception> /// <remarks>This method does not modify the supplied collection.</remarks> /// <seealso cref="Math.Max(int, int)" /> public static int Highest(IReadOnlyList<int> scores) { ArgumentNullException.ThrowIfNull(scores); if (scores.Count == 0) throw new ArgumentException("At least one score is required.", nameof(scores));
int highest = scores[0]; foreach (int score in scores) highest = Math.Max(highest, score); return highest; }}/// @file score_calculator.cpp#include <algorithm>#include <span>#include <stdexcept>
/// @brief Finds the highest score in a non-empty sequence./// @param scores Scores to compare. Must contain at least one value./// @return The largest supplied score./// @throws std::invalid_argument If scores is empty./// @note This function does not modify the supplied collection.int Highest(std::span<const int> scores){ if (scores.empty()) throw std::invalid_argument("At least one score is required");
int highest = scores.front(); for (int score : scores) highest = std::max(highest, score); return highest;}Common documentation tags
Section titled “Common documentation tags”| C# XML | C++ / Doxygen | Purpose |
|---|---|---|
<summary> |
@brief |
Short purpose, often visible in IDE tooltips |
<param name="..."> |
@param name |
Meaning and restrictions of a parameter |
<returns> |
@return |
Meaning of the returned value |
<remarks> |
@note / @details |
Additional usage notes or constraints |
<exception cref="..."> |
@throws ExceptionType |
Conditions under which an exception is thrown |
<see cref="..."/> / <seealso cref="..."/> |
@ref / @see |
References to other API members |
<typeparam name="..."> |
@tparam name |
Description of a generic/template parameter |
<example> |
@code ... @endcode |
An inline usage example |
Generate documentation for each language
Section titled “Generate documentation for each language”Use the C# setting inside your existing .NET project file. For C++, save the shown API example as score_calculator.cpp beside Doxyfile. Doxygen is a separate tool that must be installed; the C++ compiler alone does not generate these HTML pages.
<PropertyGroup> <GenerateDocumentationFile>true</GenerateDocumentationFile></PropertyGroup>With the .NET SDK installed, run dotnet build Example.csproj from the project directory. The example’s ThrowIfNull API requires .NET 6 or later. Inspect the generated XML beside the assembly and any compiler documentation warnings.
PROJECT_NAME = "Score API"INPUT = score_calculator.cppOUTPUT_DIRECTORY = api-docsGENERATE_HTML = YESGENERATE_LATEX = NOHAVE_DOT = NOEXTRACT_ALL = NOWARN_IF_UNDOCUMENTED = YESWARN_IF_DOC_ERROR = YESWith Doxygen installed and on PATH, run doxygen Doxyfile from this directory. Open api-docs/html/index.html and inspect the documented function and warnings. For a real project, adjust INPUT and enable RECURSIVE = YES when scanning nested source folders.
These commands work in PowerShell or Git Bash on Windows 10/11 and Bash on Linux when the corresponding SDK/tool is installed and on PATH. Configuration controls generated files; it does not add missing tools to your machine. See the Doxygen configuration reference.
TODO, FIXME and BUG comments
Section titled “TODO, FIXME and BUG comments”These markers are team conventions, not C# or C++ keywords. Agree on their meaning so readers know whether a comment describes planned work, a confirmed defect or a temporary compromise. //TODO is valid comment syntax, but prefer a consistent, readable form such as // TODO: ....
| Marker | Use it when | Include in the comment | Remove it when |
|---|---|---|---|
| TODO | A specific piece of planned work is unfinished | Action, reason or scope, and the condition/date/release that makes it necessary | The work and its checks are complete, or the team explicitly cancels it |
| BUG | You have identified incorrect behavior | Trigger, actual versus expected behavior, and a linked defect | The defect is fixed and a regression test verifies the expected behavior |
| FIXME | A known workaround or implementation compromise needs replacement | Why it exists, the intended replacement and a verifiable removal condition | The replacement is ready and the workaround can safely be removed |
A BUG may also need a temporary workaround; track the defect and the workaround’s removal separately when their completion conditions differ. Priority belongs in the issue tracker. A harmless TODO is not automatically less urgent than a FIXME, and adding BUG does not make unsafe code acceptable to release.
Where to place and how to write a marker
Section titled “Where to place and how to write a marker”Put the comment immediately above the relevant method, statement or small block, using the same indentation as the code. Put a test reminder beside the method under test or in the relevant test file according to team convention. Avoid a disconnected list at the top of a large file.
Write an action or observable problem, not just // FIXME: ugly. Add an issue ID for work that needs an owner, priority or coordination. The IDs below are fictional; replace them with real project references. Keep the marker and description on the first line so simple searches can find them.
// FIXME: Replace the v1 fallback after migration and end of v1 support (SAVE-185).// Keep it while supported saves can still contain only legacyName.// FIXME: Replace the v1 fallback after migration and end of v1 support (SAVE-185).// Keep it while supported saves can still contain only legacyName.Use the C# / C++ tabs for the same marker-placement examples in each language. C# methods belong inside a class; the C++20 examples are independent free functions with their required headers. The BUG examples deliberately contain faulty behavior to demonstrate where to document an identified defect. These standard-language snippets use no OS-specific APIs and can be used with suitable C# or C++20 toolchains on Windows 10, Windows 11 and Linux.
TODO: describe unfinished work
Section titled “TODO: describe unfinished work”Example 1: a missing edge-case test. The method already returns zero for an empty array, but coverage is missing. Place the reminder above the method or beside the related tests.
// TODO: Add a test that an empty inventory returns 0 items (INV-142).public static int CountItems(int[] itemIds){ return itemIds.Length;}#include <cstddef>#include <span>
// TODO: Add a test that an empty inventory returns 0 items (INV-142).std::size_t CountItems(std::span<const int> itemIds){ return itemIds.size();}Handle it: add a regression test using an empty array, verify it passes, then remove the TODO in the same change. Do not delete the test after removing the reminder. If the test exposes a real failure, record that defect as a BUG too.
Example 2: functionality required by a future feature. Formatting is currently invariant by design; localized formatting is planned before a language selector ships.
// TODO: Format using the selected UI culture before the language selector ships (UI-216).public static string FormatItemCount(int count){ return count.ToString(System.Globalization.CultureInfo.InvariantCulture);}#include <string>
// TODO: Format using the selected UI culture before the language selector ships (UI-216).std::string FormatItemCount(int count){ return std::to_string(count);}Handle it: link the UI issue, implement the agreed culture-aware formatting and test the supported cultures. Remove the TODO when that requirement is met. If the selector already ships and the formatting violates its contract, treat it as a defect rather than describing it as optional future work.
BUG: record incorrect behavior and a reproduction
Section titled “BUG: record incorrect behavior and a reproduction”Example 1: a legacy score is silently corrupted. Place the marker directly above the conversion that causes the defect.
public static int ReadScore(long savedScore){ // BUG: 2147483648 becomes negative; reject out-of-range scores instead (SAVE-184). return unchecked((int)savedScore);}#include <cstdint>
std::int32_t ReadScore(std::int64_t savedScore){ // BUG: 2147483648 becomes negative; reject out-of-range scores instead (SAVE-184). return static_cast<std::int32_t>(savedScore);}Handle it: reproduce with 2147483648L in C# or 2147483648LL in C++, add a failing regression test, then validate the allowed range and implement the agreed rejection behavior. Test valid boundaries and values outside them. Remove the BUG when the corrected implementation and tests land together. If real saves are affected, track data repair and release priority in the issue as well.
The C++ example uses std::int32_t and std::int64_t to make the widths explicit. Its out-of-range conversion follows C++20 integral-conversion rules; do not assume the same result from an arbitrary int width or an older language mode. See the C++ integral-conversion reference.
Example 2: an empty inventory crashes a summary. Include both the trigger and the expected result.
public static decimal AveragePrice(decimal totalPrice, int itemCount){ // BUG: itemCount == 0 throws; the empty-inventory contract requires 0 (INV-143). return totalPrice / itemCount;}#include <stdexcept>
double AveragePrice(double totalPrice, int itemCount){ // BUG: itemCount == 0 throws; the empty-inventory contract requires 0 (INV-143). if (itemCount == 0) throw std::domain_error("Empty inventory"); return totalPrice / itemCount;}Language difference: C# uses decimal, whose zero division throws. C++ has no matching built-in decimal type here, so its example uses double and explicitly throws for an empty inventory. That throw is the documented defect because the contract requires zero; it avoids relying on floating-point zero division or undefined integer zero division. Choose an appropriate money representation for a real pricing system.
Handle it: write a test for AveragePrice(0m, 0) in C# or AveragePrice(0.0, 0) in C++, implement the specified empty case, and test a normal non-empty case. Remove the marker once both pass. Do not simply rename BUG to TODO while leaving the crash in place.
FIXME: explain a temporary compromise and its exit condition
Section titled “FIXME: explain a temporary compromise and its exit condition”Example 1: a migration fallback is still necessary. This code currently behaves as intended for supported old saves. The outstanding work is removing the compatibility path when it is no longer needed.
public static string ReadDisplayName(string displayName, string legacyName){ // FIXME: Remove the legacyName fallback after migration and end of v1 support (SAVE-185). // Supported v1 saves may still have no displayName. return string.IsNullOrWhiteSpace(displayName) ? legacyName : displayName;}#include <string>#include <string_view>
std::string ReadDisplayName(std::string_view displayName, std::string_view legacyName){ // FIXME: Remove the legacyName fallback after migration and end of v1 support (SAVE-185). // Supported v1 saves may still have no displayName. const bool missing = displayName.find_first_not_of(" \t\n\r\f\v") == std::string_view::npos; return std::string(missing ? legacyName : displayName);}Language difference: the C++ example accepts valid string views and treats an empty name or the listed ASCII whitespace characters as missing. C# also accepts null and recognizes a wider Unicode whitespace set through IsNullOrWhiteSpace. If your saves require identical Unicode behavior in both languages, use a shared text-normalization contract and suitable Unicode support rather than assuming these checks are identical.
Handle it: keep the fallback and compatibility tests while v1 is supported. Once the migration is verified and the product no longer supports v1 saves, remove the fallback, update the supported-format tests and remove the FIXME together. A merged migration PR alone is not proof that this condition has been met.
Example 2: an upstream service sends a nonstandard locale. The application temporarily adapts a known provider response.
public static string NormalizeLocale(string locale){ // FIXME: Remove this alias after every supported provider version sends sv-SE (LOC-231). // Older provider responses use sv_SE, which our locale lookup rejects. return locale == "sv_SE" ? "sv-SE" : locale;}#include <string>#include <string_view>
std::string NormalizeLocale(std::string_view locale){ // FIXME: Remove this alias after every supported provider version sends sv-SE (LOC-231). // Older provider responses use sv_SE, which our locale lookup rejects. return std::string(locale == "sv_SE" ? "sv-SE" : locale);}Handle it: link the provider defect, verify the corrected response in deployed supported versions, then remove the alias and its FIXME while retaining tests for the supported input contract. If old provider versions must remain supported, the adapter may be permanent compatibility code: replace the FIXME with an ordinary explanation rather than promising removal that cannot happen.
Track, review and finish the work
Section titled “Track, review and finish the work”- Capture: choose the marker, place it beside the affected code and state the action or defect precisely.
- Track: create/link an issue when the task affects a release, multiple people or deferred work. Assign owner, priority and completion criteria there.
- Find: use Rider’s TODO tool window or search the solution for the markers.
- Resolve: change the implementation or tests, verify the issue’s completion/removal condition, and review related call sites.
- Clean up: remove completed markers in the same PR/MR, close the issue when all its criteria are satisfied and keep tests and lasting rationale.
On Windows 10, Windows 11 and Linux, use Settings > Editor > TODO to inspect recognition. If FIXME is missing, add a pattern such as \b(?<TAG>FIXME):.*, save it, then verify // FIXME: ... in View > Tool Windows > TODO. Keep BUG: uppercase for Rider’s case-sensitive default pattern; keybindings depend on your keymap. See JetBrains’ TODO-pattern documentation.
The same idea in C# and C++
Section titled “The same idea in C# and C++”Both languages use // and /* ... */. C# supports XML documentation via ///; in C++, tools such as Doxygen can process documentation comments with their own conventions.
// Refresh before the server lease expires.const int RefreshSeconds = 45;// Refresh before the server lease expires.constexpr int refresh_seconds = 45;Comments versus bookmarks
Section titled “Comments versus bookmarks”Use a Rider bookmark when you simply want to return to a source location while debugging. Bookmarks provide navigation without adding source-controlled prose. Use a comment for information that should remain with the implementation; use C# XML or C++ Doxygen documentation for callers; use an issue for work with an owner and priority.
Common mistakes
Section titled “Common mistakes”- Narrating every line. Prefer clear names and small methods.
- Commenting out obsolete code. Git already keeps history; delete dead implementations rather than leaving huge commented blocks.
- Outdated explanations. Review comments whenever the behavior or constraints change.
- Misleading API contracts. A comment promising a non-null value does not enforce it: the implementation and tests must agree.
- Exposing confidential data. Never put credentials, customer information or internal infrastructure secrets into source comments.
- Using comments instead of validation.
// must be positivedoes not prevent negative inputs.
A useful review checklist
Section titled “A useful review checklist”- Read the method name and types without comments. Is the intent already obvious?
- Keep comments that document requirements, invariants, important tradeoffs or non-obvious behavior.
- Verify that C# XML or C++ Doxygen comments match the actual parameters, returns and thrown exceptions.
- Remove obsolete TODOs and commented-out code.
- Update tests and comments together when the behavior changes.
Try it yourself
Section titled “Try it yourself”Rewrite this so it explains the reason for the chosen value, not the arithmetic:
// Divide time by 2.int timeoutMilliseconds = serverLeaseMilliseconds / 2;// Divide time by 2.int timeoutMilliseconds = serverLeaseMilliseconds / 2;Reveal a possible answer
The comment should explain the margin for network delays. Only use this explanation if it matches the real requirement.
// Refresh well before the remote lease expires, leaving room for network delays.int timeoutMilliseconds = serverLeaseMilliseconds / 2;// Refresh well before the remote lease expires, leaving room for network delays.int timeoutMilliseconds = serverLeaseMilliseconds / 2;Further reading
Section titled “Further reading”- Microsoft: Recommended XML documentation tags
- Doxygen: Documentation blocks
- Doxygen: Documentation commands
- SubMain: C# comments guide
- GeeksforGeeks: Comments in C#
- Rider: Bookmarks and mnemonics
