Skip to content

Writing better comments in C# and C++

↑

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.

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.

C# | Comments.cs
// 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;
}

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.

Bad comments narrate obvious syntax:

C# | Avoid.cs
// Add one to the score.
score++;
// Check if the score is above 100.
if (score > 100) { /* ... */ }

Better comments record a meaningful reason:

C# | ScoreService.cs
// 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);

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.

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.

C# | ScoreCalculator.cs
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;
}
}
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

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.

Example.csproj: add inside Project
<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.

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.

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.

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.

C# | A searchable, actionable marker
// 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.

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.

C# | InventoryService.cs: missing test
// TODO: Add a test that an empty inventory returns 0 items (INV-142).
public static int CountItems(int[] itemIds)
{
return itemIds.Length;
}

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.

C# | InventoryLabels.cs: planned localization
// 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);
}

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.

C# | SaveReader.cs: deliberately faulty conversion
public static int ReadScore(long savedScore)
{
// BUG: 2147483648 becomes negative; reject out-of-range scores instead (SAVE-184).
return unchecked((int)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.

C# | InventorySummary.cs: deliberately faulty empty case
public static decimal AveragePrice(decimal totalPrice, int itemCount)
{
// BUG: itemCount == 0 throws; the empty-inventory contract requires 0 (INV-143).
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.

C# | SaveMigration.cs: temporary compatibility
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;
}

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.

C# | LocaleAdapter.cs: provider workaround
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;
}

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.

  1. Capture: choose the marker, place it beside the affected code and state the action or defect precisely.
  2. Track: create/link an issue when the task affects a release, multiple people or deferred work. Assign owner, priority and completion criteria there.
  3. Find: use Rider’s TODO tool window or search the solution for the markers.
  4. Resolve: change the implementation or tests, verify the issue’s completion/removal condition, and review related call sites.
  5. 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.

Both languages use // and /* ... */. C# supports XML documentation via ///; in C++, tools such as Doxygen can process documentation comments with their own conventions.

C# | LeaseSettings.cs
// Refresh before the server lease expires.
const int RefreshSeconds = 45;

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.

  • 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 positive does not prevent negative inputs.
  1. Read the method name and types without comments. Is the intent already obvious?
  2. Keep comments that document requirements, invariants, important tradeoffs or non-obvious behavior.
  3. Verify that C# XML or C++ Doxygen comments match the actual parameters, returns and thrown exceptions.
  4. Remove obsolete TODOs and commented-out code.
  5. Update tests and comments together when the behavior changes.

Rewrite this so it explains the reason for the chosen value, not the arithmetic:

C# | Practice.cs
// 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.

C# | Practice.cs: possible answer
// Refresh well before the remote lease expires, leaving room for network delays.
int timeoutMilliseconds = serverLeaseMilliseconds / 2;