8 рекомендаций по написанию читаемого кода на C# с помощью .NET 6

Как говорит гуру программирования Роберт К. Мартин, несмотря на то, что чистый код может быть не идеален в своем определении, его результаты отчетливо понятны. Вы наверняка предпочтете, чтобы ваш код был читаем, подобно хорошей книге, и без проблем расширялся. Ошибки либо должны быть легко обнаружимыми, либо избегаться ввиду самой структуры программы. Чистый код позволяет поддерживать продуктивность на высоте и одновременно снижает затраты на обслуживание.

Вот цитата из книги “Чистый код. Создание, анализ, рефакторинг”:

“Чистый код может читать и улучшать любой разработчик, а не только его изначальный автор”,  —  Дэйв Томас.

Это одна из важнейших характеристик чистого кода. Если код можете обслуживать не только вы, но и любой другой разработчик близкого к вам уровня, то такой он автоматически впишется в большинство требований звания “чистый”. Естественно, он может быть не на все 100% таковым, но достаточно, чтобы он соответствовал свойствам, описанным в начале.

Читаемый код

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

Так что же делает код более читаемым?

В этой статье мы не будем разбирать методы, которые описаны в книге “Чистый код. Создание, анализ, рефакторинг” —  с ними вы можете ознакомиться самостоятельно. Вместо этого я сосредоточусь на конкретных рекомендациях, которые выработал за время работы с .NET в течение последних 8 лет.

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

Рекомендации к .NET и C#

1. Избегайте “Else”

Первый принцип прост  —  избегать else. Практически всякий раз, когда вы используете else, его можно легко избежать и сделать код более читаемым. Взгляните на следующие примеры:

//Плохо
public void Test(Test test) {
if (test == null) {
throw new ArgumentNullException(nameof(test));
} else {
//Do something
}
}

//Хорошо
public void Test(Test test) {
if (test == null)
throw new ArgumentNullException(nameof(test));

//Нужные действия
}

// ____________________

//Плохо
public Result Test(SomeEnum someEnum) {
if (someEnum == SomeEnum.A) {
//Do something
} else {
//Do the Other thing
}
}

//Хорошо
public Result Test(SomeEnum someEnum) {
return someEnum switch {
SomeEnum.A => DoSomething(),
SomeEnum.B => DoTheOtherThing(),
_ => throw new NotImplementedException()
};

Result DoSomething() { }
Result DoTheOtherThing() { }
}

Первый пример также известен как раннее возвращение. Нужно всегда отчетливо указывать в первых строках кода, чего вы ожидаете от аргументов.

Второй пример показывает откровенно новый способ переключения инструкций в C#  —  сопоставление шаблонов. Здесь вы можете легко заметить, что происходит при прохождении явного кейса, и какая функция этот кейс обрабатывает. Кроме того, тут была применена еще одна рекомендация: создавать дополнительную, грамотно названную, функцию для каждой вложенной логики.

2. Избегайте высокой цикломатической сложности

Еще один уместный здесь принцип заключается в избежании высокой цикломатической сложности. Это означает, что метод должен иметь минимум сторонней логики, выполняющейся при возникновении определенного состояния. Это можно представить на примере реализации интерфейсов для классов и прописывания отдельной логики в каждом классе:

interface IRequestHandler
{
Result Handle();
}

internal class Test1 : IRequestHandler
{
public Result Handle() {
//Действия
}
}

internal class Test2 : IRequestHandler
{
public Result Handle() {
//Прочие действия
}
}

public Result HandleRequest(IRequestHandler requestHandler)
=> requestHandler.Handle();

3. Избегайте излишних уровней отступов

В примере avoid_else.cs можно заметить и еще одно отличие, вносимое исключением else. Вы избегаете в методе одного уровня отступов. Хорошей практикой для повышения читаемости кода будет просто избежание лишних уровней отступов. Я на собственном опыте обнаружил, что лучше всего придерживаться трех-четырех. При этом они должны быть поистине необходимыми.

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

Практически единственный случай, в котором у меня получается более одного уровня отступов  —  это использование запросов LINQ.

4. Избегайте циклов с помощью Linq

Вам мог встречаться подобный код:

//Плохо
public List<string> Test(string[] someStrings) {
List<string> listToFill = new();

foreach (var s in someStrings) {
listToFill.Add(s.ToUpper());
}

return listToFill;
}

//Хорошо
public List<string> Test(IEnumerable<string> someStrings)
=> someStrings
.Select(s => s.ToUpper())
.ToList();

Спросите себя: “Можно ли заменить цикл запросом Linq?”. Гарантирую, что в 90% случаев ответом будет: “Да”. Это может быть моим личным мнением, но я считаю, что хороший запрос Linq гораздо понятнее масштабного цикла for.

Чаще всего при использовании таких циклов вы собираетесь преобразовать один или более классов Enumerable в результат. В Linq есть все необходимое для применения проверок, трансформаций и действий для IEnumerable<T>. Если вы с этим языком еще не работали, то настоятельно его вам рекомендую.

В Linq у вас есть выбор между синтаксисом запросов и синтаксисом методов. Лично я предпочитаю последний, но тут уже дело вкуса. Все расширяющие методы для IEnumerable можно найти здесь.

5. Извлечение и именование методов

Извлечение методов и присваивание им понятного имени также сильно повышает читаемость.

//Плохо
public List<string> Test(IEnumerable<string> someStrings)
=> someStrings
.Where(s => s.StartsWith("a", StringComparison.OrdinalIgnoreCase))
.Select(s => s.Replace("a", ""))
.ToList();

//Хорошо(Представьте, что здесь используется более сложная логика)
public List<string> Test(IEnumerable<string> someStrings)
=> someStrings
.Where(StartsWithA)
.Select(RemoveA)
.ToList();

private static bool StartsWithA(string s)
=> s.StartsWith("a", StringComparison.OrdinalIgnoreCase);

private static string RemoveA(string s)
=> s.Replace("a", "");

Представьте, что в методах Where() или Select() используется более сложная логика, чем в данном простом примере.

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

6. Используйте в коде разрывы и отступы

Еще один способ повышения читаемости кода  —  это правильное его разделение и использование отступов для строк. В большинстве случаев правильно расставить отступы вам поможет Visual Code, хотя тоже не идеально.

Вот несколько хороших правил относительно того, что нужно разбивать и где использовать отступы.

  • Строки кода не должны выступать за середину экрана. Это упростит его чтение и позволит использовать разделение экрана.
  • Уровень отступов открывающих и закрывающих скобок должен совпадать. Я знаю, что это займет несколько дополнительных секунд, но раскрытие аргументов и добавление им имен упрощает чтение метода и его редактирование (см. второй пример).
  • Делать разрыв перед точкой или после запятой. Таким образом, вы можете удалить целую строку для перемещения части кода.
  • Каждая лямбда-функция должна отделяться дополнительным уровнем отступа .

Разрыв перед точкой:

//Плохо
public Task<bool> BlobExistsAsync(string containerName, string blobName)
=> client.GetBlobContainerClient(containerName).GetBlobClient(blobName).ExistsAsync().GetValueAsync();

//Хорошо
public Task<bool> BlobExistsAsync(string containerName, string blobName)
=> client
.GetBlobContainerClient(containerName)
.GetBlobClient(blobName)
.ExistsAsync()
.GetValueAsync();

Разрыв аргументов и добавление имен:

public void Test() {
//Плохо
var test1 = new Test("a", "b", "c", "d", "e");

//Хорошо
var test2 = new Test(
argument1: "a",
argument2: "b",
argument3: "c",
argument4: "d",
argument5: "e"
);
}

7. Используйте краткий синтаксис

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

C# постоянно обновляется, и каждый год в нем появляется много новых возможностей. Нередко свежие версии предлагают более сжатый вариант написания некоторых инструкций, которые вы уже используете повседневно. Вот некоторые примеры для C#10:

  • Краткая if:
public void Test(bool predicate) {
//Плохо
string s;
if (predicate) {
s = "Hello World";
} else {
s = "Bye World";
}

//Хорошо
var s = predicate
? "Hello World"
: "Bye World";
}
  • Краткие проверки на null:
public void Test() {
//Проверяет левое значение на null, и если это так, использует //правое значение;
var a = null;
var b = a ?? new Xyz();

//Выбрасывает исключение, если значение null
var c = a ?? throw new Exception();

//Если d равно null, создает new D();
var d = null;
d ??= new D();
}
  • А вот, на мой взгляд, самая краткая интерполяция строк:
public void Test() {
var a = "Name"
var s = $"Hello {a}"
// s - это "Hello Name"
}
  • Сопоставление шаблонов. По началу покажется сложным, но в итоге окажется самым удобочитаемым механизмом различения кейсов:
public void Test() {
var a = 1;
var b = a switch {
1 => "a is 1",
2 => "a is 2",
_ => "a is not 1 or 2"
}
// b = "a is 1"
}
  • Тела выражений. Лучший вариант для однострочных функций или свойств:
public string Test(bool predicate) {
return predicate ? "true" : "false";
}

public string Test(bool predicate)
=> predicate ? "true" : "false";
  • Типы записей. Лучше всего подходят для Plain Old Class Object (POCO):
//Старый
public class Xyz() {
public string Test { get; }

public string Test2 { get; }

public Xyz(string test, string test2){
Test = test;
Test2 = test2
}
}

//Новый
public record Xyz(string Test, string Test2);

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

8. Global Usings и FileScopedNamespaces

В C#10 появилось две интересных возможности: file-scoped namespaces и global usings.

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

//Старый вариант
namespace This.Is.A.Test.Namespace {
public class Test {
}
}

//Новый вариант
namespace This.Is.A.Test.Namespace;

public class Test {
}

Global usings работают в масштабе проекта и экономят кучу места в начале файлов. Некоторые usings окажутся очень полезны для последующих разработчиков, помогая понять, на какие пакеты и пространства имен вы ссылаетесь в коде, тем не менее, использовать их нужно осторожно. Добавляйте global usings только для тех пространств имен, которые используются в проекте часто.

Результат global usings и implicit usings:

//Старый вариант
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace xyz;

class Abc {
}

//Новый вариант
namespace xyz;

class Abc {
}

В этом примере usings также можно импортировать, включив в проекте функционал implicit usings.

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Tobias Streng: 8 Guidelines to Write Readable Code in C# With .NET 6

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