Перейти к основному содержимому

5.05. Рекомендации по разработке на C#

Разработчику Архитектору

Рекомендации по разработке на C#

Так, представим, что вы уже изучили основы языка. Как правильно разрабатывать на C#?

Общие принципы написания кода

Качественный код обладает тремя ключевыми свойствами: читаемостью, надежностью и поддерживаемостью. Читаемость достигается через осмысленные имена, последовательное форматирование и логическую структуру. Надежность обеспечивается проверкой входных данных, корректной обработкой ошибок и защитой от неожиданных состояний. Поддерживаемость строится на модульности, соблюдении принципов проектирования и минимальной связанности компонентов.

Каждый элемент кода должен выражать свою семантику напрямую. Имя переменной, метода или класса рассказывает о назначении без дополнительных пояснений. Форматирование кода следует единому стилю в пределах проекта. Повторяющиеся фрагменты кода выносятся в отдельные методы или классы в соответствии с принципом DRY (Don't Repeat Yourself). Каждый класс и метод решает одну конкретную задачу.

Требования по именованию

Нотации для элементов языка

Различные элементы языка C# используют определенные стили написания:

Элемент языкаНотацияПример
Класс, структураPascalCaseAppDomain
ИнтерфейсPascalCaseIBusinessService
Перечисление (тип)PascalCaseErrorLevel
Перечисление (значение)PascalCaseFatalError
СобытиеPascalCaseClick
Приватное полеcamelCase_listItem
Защищенное полеPascalCaseMainPanel
Константное полеPascalCaseMaximumItems
Константная локальная переменнаяcamelCasemaximumItems
Read-only статическое полеPascalCaseRedValue
ПеременнаяcamelCaselistOfValues
МетодPascalCaseToString
Пространство именPascalCaseSystem.Drawing
ПараметрcamelCasetypeName
Параметры типаPascalCaseTView
СвойствоPascalCaseBackColor

Приватные поля начинаются с символа подчеркивания _, за которым следует имя в стиле camelCase. Все остальные элементы с уровнем доступа выше private используют стиль PascalCase без префиксов.

Осмысленные имена

Имена должны передавать суть элемента без дополнительных пояснений. Избегайте коротких имен, которые можно спутать с другими обозначениями. Сравните два примера:

// Плохой пример
bool b001 = (lo == l0) ? (I1 == 11) : (lOl != 101);

// Хороший пример
bool isPublish = (positionModificationCode == (int)tenderPlan2020PositionDecision.purchaseCanceled)
? (commonIKZ == checkCommonIKZ)
: (commonPositionNumber != null);

Свойства логического типа именуются с использованием утвердительных фраз и префиксов Is, Has, Can, Allows или Supports. Например: CanSeek, IsAvailable, HasPermission.

Методы именуются как глагол или глагол с существительным: ShowDialog, CalculateTotal, ValidateInput. Избегайте использования слова And в названиях методов. Если метод выполняет несколько действий, его следует разделить на несколько методов с отдельной ответственностью.

События и обработчики

События, происходящие до основного действия, именуются с суффиксом -ing: Closing, Deleting, Saving. События, происходящие после завершения действия, используют суффикс -ed: Closed, Deleted, Saved. Событие, представляющее само действие, именуется в инфинитиве: Delete, Save.

Методы-обработчики событий начинаются с префикса On: OnClosing, OnDataReceived, OnUserAuthenticated.

Асинхронные методы

Методы, возвращающие Task или Task<T>, получают суффикс Async: GetDataAsync, SaveChangesAsync, ProcessRequestAsync. Это соглашение помогает визуально отличать асинхронные операции от синхронных.

Требования по оформлению кода

Отступы и пробелы

В качестве отступов используются четыре пробела. Табуляция не применяется. Между ключевыми словами и выражениями вставляется один пробел:

if (condition == null)
{
return;
}

Пробелы добавляются перед и после операторов: +, -, ==, !=, >=, <=. Пробелы не ставятся после открывающей скобки и перед закрывающей скобкой в вызовах методов и условных выражениях.

Стиль Олмана для фигурных скобок

Фигурные скобки размещаются на отдельных строках с выравниванием по левому краю соответствующего блока:

if (condition)
{
DoSomething();
ProcessResult();
}
else
{
HandleError();
}

Первый оператор внутри блока начинается на новой строке с отступом в четыре пробела. Закрывающая скобка располагается на уровне открывающей скобки.

Цепочки методов

При вызове нескольких методов подряд каждый вызов размещается на отдельной строке. Точка ставится перед именем метода:

public string[] GetActiveUsers()
{
return DataContext
.Users
.Where(user => user.IsActive)
.OrderBy(user => user.RegistrationDate)
.Select(user => user.DisplayName)
.ToArray();
}

Такой подход улучшает читаемость длинных цепочек вызовов и упрощает добавление или удаление отдельных операций.

Тернарные выражения

Тернарные операторы записываются в три строки. При вложенных тернарных выражениях применяется дополнительный отступ:

public string GetValidationMessage(string name)
{
return string.IsNullOrWhiteSpace(name)
? "Empty name"
: name.Length < 3
? "Name too short"
: "Ok";
}

Пустые строки

Пустые строки добавляются между:

  • Многострочными выражениями
  • Членами класса разного типа (поля, свойства, методы)
  • Закрытием парных скобок и следующим блоком кода
  • Несвязанными логическими блоками внутри метода
  • Директивами using из разных пространств имен

Пустые строки внутри составного блока кода разделяют логические части алгоритма, улучшая восприятие структуры метода.

Проектирование классов и членов

Принцип единственной ответственности

Каждый класс решает одну задачу в рамках системы. Класс может описывать тип данных (например, EmailAddress, Money), представлять абстракцию бизнес-логики (OrderProcessor, PaymentGateway), описывать структуру данных (TreeNode, GraphEdge) или обеспечивать взаимодействие между другими классами (Repository, ServiceLocator).

Класс со словом And в названии обычно нарушает принцип единственной ответственности. Вместо UserAndRoleManager предпочтительнее разделить функциональность на UserManager и RoleManager.

Свойства против методов

Свойства используются для доступа к данным объекта без выполнения сложных операций. Метод применяется в следующих случаях:

  • Операция требует значительных вычислительных ресурсов
  • Свойство представляет конвертацию данных (например, ToString)
  • Значение меняется при каждом вызове без изменения входных параметров (например, DateTime.Now)
  • Операция вызывает побочные эффекты, изменяющие состояние объекта

Свойства никогда не возвращают null для типов, производных от IEnumerable<T>. Вместо null возвращается пустая коллекция:

// Правильно
public IEnumerable<Order> GetOrders()
{
return _orders ?? Enumerable.Empty<Order>();
}

// Неправильно
public IEnumerable<Order> GetOrders()
{
return _orders; // может вернуть null
}

Порядок членов класса

Члены класса располагаются в следующем порядке:

  1. Приватные поля и константы (внутри #region при необходимости)
  2. Публичные константы
  3. Публичные статические поля только для чтения
  4. Фабричные методы и статические методы создания
  5. Конструкторы
  6. События
  7. Публичные свойства
  8. Прочие методы, упорядоченные по функциональности

Такой порядок обеспечивает последовательное чтение файла снизу вверх, как книги, без необходимости перемещения по файлу для понимания структуры класса.

Статические классы

Статические классы допустимы только для методов расширения. Во всех остальных случаях статические классы затрудняют модульное тестирование и создают скрытые зависимости. Вместо статического класса FileHelper предпочтительнее интерфейс IFileService с конкретной реализацией.

Обработка аргументов и исключений

Проверка аргументов

Аргументы конструкторов и публичных методов проверяются на корректность. Проверка включает не только проверку на null, но и валидацию допустимых значений:

public class UserRepository
{
private readonly string _connectionString;

public UserRepository(string connectionString)
{
if (string.IsNullOrWhiteSpace(connectionString))
{
throw new ArgumentNullException(nameof(connectionString));
}

if (connectionString.Length > 4000)
{
throw new ArgumentException("Connection string exceeds maximum length", nameof(connectionString));
}

_connectionString = connectionString;
}
}

Проверка аргументов опускается в перегруженных методах, которые лишь передают параметры в основную реализацию. Проверка выполняется только в методе с полной сигнатурой.

Генерация исключений

Исключения генерируются вместо возврата кодов состояния. Коды состояния приводят к вложенным условным конструкциям и увеличивают вероятность пропуска ошибок. Структурированная обработка исключений позволяет централизованно обрабатывать ошибки на подходящем уровне приложения.

Сообщения об исключениях должны содержать контекстную информацию для диагностики:

throw new InvalidOperationException(
$"Cannot process order {orderId} with status {order.Status}. " +
$"Expected status: {OrderStatus.Pending}, actual status: {order.Status}");

Обработка событий

Перед вызовом события проверяется наличие подписчиков. Для потокобезопасности используется временная переменная или оператор ?.Invoke:

// Вариант 1: временная переменная
var handlers = Notify;
if (handlers != null)
{
handlers(this, args);
}

// Вариант 2: оператор null-условного вызова
Notify?.Invoke(this, args);

Для гарантии, что событие никогда не будет равно null, можно инициализировать его пустым делегатом:

public event EventHandler<NotifyEventArgs> Notify = delegate { };

Логирование исключений

При логировании исключений используется метод ToString() или неявное преобразование в строку. Это обеспечивает запись полного стека вызовов и вложенных исключений:

try
{
ProcessData();
}
catch (Exception ex)
{
logger.Error($"Operation failed: {ex}");
// или
logger.Error($"Operation failed: {ex.ToString()}");
}

Запись только ex.Message недостаточна для диагностики, так как теряется информация о месте возникновения ошибки и цепочке вызовов.

Работа с коллекциями

Возвращаемые типы коллекций

Методы не возвращают интерфейс IEnumerable<T>, если только не используется yield return. Возврат IEnumerable<T> создает риск множественного перечисления и неочевидного выполнения отложенных операций.

Предпочтительные типы возвращаемых значений:

  • Для неизменяемых коллекций: IReadOnlyList<T>, ReadOnlyCollection<T>, массив T[]
  • Для изменяемых коллекций: List<T>, IList<T>
// Правильно
public IReadOnlyList<User> GetActiveUsers()
{
return _users
.Where(u => u.IsActive)
.ToList()
.AsReadOnly();
}

// Допустимо при использовании yield return
public IEnumerable<User> EnumerateActiveUsers()
{
foreach (var user in _users)
{
if (user.IsActive)
{
yield return user;
}
}
}

Избегание магических чисел

Литеральные значения выносятся в константы или перечисления. Исключения составляют случаи, когда значение очевидно из контекста:

// Плохо
if (user.Age > 18)
{
GrantAccess();
}

// Хорошо
private const int LegalAge = 18;

if (user.Age > LegalAge)
{
GrantAccess();
}

Строки для логирования и трассировки могут оставаться литералами, если их смысл ясен из контекста.

Комментирование и документация

XML-документация

Публичные классы, интерфейсы, методы и свойства сопровождаются XML-комментариями. Комментарии описывают назначение элемента, параметры, возвращаемые значения и возможные исключения:

/// <summary>
/// Processes payment for the specified order using the provided payment method.
/// </summary>
/// <param name="orderId">Unique identifier of the order to process.</param>
/// <param name="paymentMethod">Payment method details including card information.</param>
/// <returns>Payment result containing transaction ID and status.</returns>
/// <exception cref="ArgumentNullException">Thrown when orderId or paymentMethod is null.</exception>
/// <exception cref="InvalidOperationException">Thrown when order status does not allow payment.</exception>
public PaymentResult ProcessPayment(Guid orderId, PaymentMethod paymentMethod)
{
// реализация
}

Комментарии отображаются в подсказках IntelliSense и используются для генерации документации.

Внутренние комментарии

Комментарии внутри методов объясняют нетривиальные решения или сложную бизнес-логику. Комментарии размещаются на отдельной строке перед кодом, начинаются с заглавной буквы и заканчиваются точкой:

// Calculate discount based on customer loyalty tier and order amount.
// Formula: baseDiscount + (loyaltyBonus * orderAmount / 1000)
var discount = baseDiscount + (loyaltyBonus * orderAmount / 1000);

Комментарии не дублируют очевидное поведение кода. Вместо комментария // увеличиваем счетчик предпочтительнее осмысленное имя переменной processedItemsCount.

Закомментированный код

Закомментированный код удаляется из репозитория. История изменений сохраняется системой контроля версий. Наличие закомментированного кода затрудняет понимание актуального состояния системы и создает неопределенность относительно его назначения.

Принципы проектирования

Принципы SOLID

SOLID представляет пять фундаментальных принципов объектно-ориентированного проектирования:

  • Принцип единственной ответственности (SRP): класс имеет одну причину для изменения.
  • Принцип открытости/закрытости (OCP): классы открыты для расширения, закрыты для модификации.
  • Принцип подстановки Лисков (LSP): объекты базового типа могут заменяться объектами производных типов без нарушения работы программы.
  • Принцип разделения интерфейсов (ISP): клиенты не должны зависеть от методов, которые они не используют.
  • Принцип инверсии зависимостей (DIP): модули верхнего уровня не зависят от модулей нижнего уровня. Оба зависят от абстракций.

Избегание двунаправленной зависимости

Двунаправленная зависимость возникает, когда два класса знают о публичных методах друг друга. Такая связь затрудняет рефакторинг и тестирование. Решение — введение интерфейса и применение инверсии зависимостей:

// Плохо: двунаправленная зависимость
public class Order
{
public Customer Customer { get; set; }
}

public class Customer
{
public List<Order> Orders { get; set; }
}

// Хорошо: зависимость через интерфейс
public interface IOrderRepository
{
IEnumerable<Order> GetOrdersForCustomer(Customer customer);
}

public class CustomerService
{
private readonly IOrderRepository _orderRepository;

public CustomerService(IOrderRepository orderRepository)
{
_orderRepository = orderRepository;
}

public IEnumerable<Order> GetCustomerOrders(Customer customer)
{
return _orderRepository.GetOrdersForCustomer(customer);
}
}

Инкапсуляция сложных выражений

Сложные логические выражения выносятся в отдельные методы с осмысленными именами:

// Плохо
if (member.HidesBaseClassMember && (member.NodeType != NodeType.InstanceInitializer))
{
// обработка
}

// Хорошо
if (NonConstructorMemberUsesNewKeyword(member))
{
// обработка
}

private bool NonConstructorMemberUsesNewKeyword(Member member)
{
return member.HidesBaseClassMember &&
(member.NodeType != NodeType.InstanceInitializer);
}

Такой подход улучшает читаемость кода и упрощает его поддержку.

Структура проекта и организация файлов

Организация пространств имен

Пространства имен отражают функциональную структуру приложения. Рекомендуемая структура:

CompanyName.ProductName
├── Core // Ядро приложения, доменные модели, интерфейсы
├── Application // Сценарии использования, сервисы приложения
├── Infrastructure // Реализация инфраструктурных компонентов
├── Presentation // Слой представления (MVC, Razor Pages, Blazor)
└── Tests // Тестовые проекты

Каждый файл содержит один публичный класс или интерфейс. Вложенные классы допустимы только при тесной функциональной связи и ограниченной области применения.

Размещение директив using

Директивы using размещаются вне объявления пространства имен. Это предотвращает проблемы с разрешением имен при добавлении новых зависимостей:

using System;
using System.Collections.Generic;
using CompanyName.ProductName.Core;

namespace CompanyName.ProductName.Application
{
public class OrderService
{
// реализация
}
}

Размещение using внутри пространства имен может привести к неожиданному разрешению имен через относительные пути.

Объявления пространств имен в пределах файла

Для файлов, содержащих одно пространство имен, используется сокращенный синтаксис:

namespace CompanyName.ProductName.Application;

public class OrderService
{
// реализация
}

Такой подход уменьшает уровень вложенности кода и улучшает читаемость.

Практические рекомендации

Асинхронное программирование

Асинхронные методы завершаются суффиксом Async. Внутри асинхронных методов используется оператор await вместо блокирующих вызовов Result или Wait:

// Правильно
public async Task<User> GetUserAsync(int userId)
{
var user = await _userRepository.GetByIdAsync(userId);
return user;
}

// Неправильно - возможна взаимоблокировка
public User GetUser(int userId)
{
return _userRepository.GetByIdAsync(userId).Result;
}

В библиотечном коде рекомендуется вызывать ConfigureAwait(false) для предотвращения захвата контекста синхронизации:

public async Task<Data> LoadDataAsync()
{
var rawData = await _dataSource.FetchAsync().ConfigureAwait(false);
return Parse(rawData);
}

Работа с ресурсами

Для объектов, реализующих IDisposable, используется оператор using. Предпочтителен современный синтаксис без фигурных скобок:

using var connection = new SqlConnection(connectionString);
using var command = new SqlCommand(query, connection);

connection.Open();
using var reader = await command.ExecuteReaderAsync();
// обработка результатов

Такой подход гарантирует освобождение ресурсов даже при возникновении исключений.

Проверка на null

Современный C# предоставляет операторы для безопасной работы с возможными значениями null:

// Оператор ?? для предоставления значения по умолчанию
var name = userName ?? "Anonymous";

// Оператор ?. для безопасного доступа к членам
var length = collection?.Count ?? 0;

// Шаблонное сопоставление для проверки и приведения типа
if (obj is string text)
{
ProcessText(text);
}

Избегайте явного сравнения логических значений с true или false. Вместо if (isActive == true) используйте if (isActive).

Конструкторы и инициализация

Конструктор создает полностью готовый к использованию объект. После создания объекта не требуется дополнительная настройка свойств для выполнения основных операций. Для сложной инициализации применяются шаблоны строителя или фабричные методы:

// Фабричный метод для создания объекта с валидацией
public static Order CreateNew(Customer customer, DateTime orderDate)
{
if (customer == null)
throw new ArgumentNullException(nameof(customer));

if (orderDate > DateTime.UtcNow)
throw new ArgumentException("Order date cannot be in the future", nameof(orderDate));

return new Order
{
Customer = customer,
OrderDate = orderDate,
Status = OrderStatus.Pending,
Items = new List<OrderItem>()
};
}