使用分部类重构 C# 代码
随着代码量的增长,我们经常需要寻找新的方法来保持代码的结构清晰和组织有序。系统性的重构是必要的,但往往并不容易做到。
我们经常面临的挑战之一是如何将一个大班级的不同部分组合在一起。即使已经进行了一定程度的划分,有时我们最终得到的班级规模仍然可能过于庞大,难以进行合理的分析。
从 C# 语言的早期版本开始,它就提供了一种名为“区域” (region)的结构。虽然它在组织代码时可能有所帮助,但大多数人似乎都认为使用区域通常是一种反模式。即使在某些情况下使用区域是合理的,但其带来的好处往往会以牺牲代码可读性为代价。
我确实认为将代码分组形成逻辑块很有用,但我同意区域划分带来的问题比解决的问题更多。因此,我一直积极使用分部类,它在很多方面都能达到类似的目的,而且不会有同样的缺点。
部分类是 C# 的一项特性,它允许你将类型的定义拆分成多个部分,每个部分可以位于各自的文件中。在编译过程中,编译器会收集所有这些部分并将它们组合在一起,生成一个单一的类,就像它定义在一个地方一样。启用此功能只需partial在定义中添加 `partial_classes` 关键字即可。
在本文中,我将向您展示我在重构代码时通常如何使用分部类。希望本文中的示例能让您也想尝试这种方法。
提取静态成员
我几乎总是会把静态属性和方法与类的其他部分分开。这看起来可能有点武断,但我认为这样做是有道理的,因为我们处理静态成员和非静态成员的方式确实不同。
我们来看一个例子。假设我们正在开发一个名为 `readyfile` 的抽象层,PartitionedTextWriter它实现了滚动文件的概念——它充当流式文本写入器,在前一个文件达到一定的字符阈值后自动切换到新文件。
该类使用一个基本路径进行初始化,并需要使用该路径为每个分区生成文件名。由于这纯粹是业务逻辑,没有副作用,因此将其放在一个静态辅助方法中是完全合理的。
通常情况下,混合使用静态成员和非静态成员会让人非常困惑。让我们看看如果改用分部类会是什么样子:
public partial class PartitionedTextWriter : TextWriter
{
private readonly string _baseFilePath;
private readonly long _partitionLimit;
private int _partitionIndex;
private TextWriter _innerWriter;
private long _partitionCharCount;
public override Encoding Encoding { get; } = Encoding.UTF8;
public PartitionedTextWriter(string baseFilePath, long partitionLimit)
{
_baseFilePath = baseFilePath;
_partitionLimit = partitionLimit;
}
private void InitializeInnerWriter()
{
// Get current file path by injecting partition identifier in the file name
// E.g. MyFile.txt, MyFile [part 2].txt, etc
var filePath = GetPartitionFilePath(_baseFilePath, _partitionIndex);
_innerWriter = File.CreateText(filePath);
}
public override void Write(char value)
{
// Make sure the underlying writer is initialized
if (_innerWriter == null)
InitializeInnerWriter();
// Write content
_innerWriter.Write(value);
_partitionCharCount++;
// When the char count exceeds the limit,
// start writing to a new file
if (_partitionCharCount >= _partitionLimit)
{
_partitionIndex++;
_partitionCharCount = 0;
_innerWriter?.Dispose();
_innerWriter = null;
}
}
protected override void Dispose(bool disposing)
{
if (disposing)
_innerWriter?.Dispose();
base.Dispose(disposing);
}
}
public partial class PartitionedTextWriter
{
// Pure helper function
private static string GetPartitionFilePath(string baseFilePath, int partitionIndex)
{
if (partitionIndex <= 0)
return baseFilePath;
// Inject "[part x]" in the file name
var fileNameWithoutExt = Path.GetFileNameWithoutExtension(baseFilePath);
var fileExt = Path.GetExtension(baseFilePath);
var fileName = $"{fileNameWithoutExt} [part {partitionIndex + 1}]{fileExt}";
var dirPath = Path.GetDirectoryName(baseFilePath);
if (!string.IsNullOrWhiteSpace(dirPath))
return Path.Combine(dirPath, fileName);
return fileName;
}
}
作为一名初次阅读这段代码的开发者,你很可能会欣赏这种分离式设计。当我们处理创建新文件的概念时,我们并不太关心GetPartitionFilePath它的具体实现方式。同样地,如果我们想了解GetPartitionFilePath它的工作原理,其余代码很可能只是无关的干扰信息。
有人可能会说,我们本可以把辅助方法移到另一个静态类中。在某些情况下,这样做或许可行,尤其是在该方法还会被其他地方复用的情况下。然而,这样做也会降低该方法的可发现性,而我通常更倾向于将依赖项尽可能地放在源代码附近,以减少认知负担。
请注意,在本例中,类的两个部分定义都放在同一个文件中。由于我们的主要目标是对代码进行分组而不是将其拆分,因此将代码放在一起更有意义。只有当代码块过大而无法放在一起时,我才会考虑将它们移动到单独的文件中。
这种方法与“资源获取即初始化”模式结合使用时效果尤佳。通过使用分部类,我们可以将负责初始化的方法分组,并将它们与类的其他部分隔离开来。
在以下示例中,我们有一个名为 ` NativeDeviceContextdeviceContext` 的类,它是 Windows 操作系统中设备上下文资源的包装器。可以通过提供本机资源的句柄来构造该类,但使用者无需手动执行此操作。相反,他们将调用可用的静态方法之一,例如 `initialize`,这些方法FromDeviceName(...)将自动处理初始化工作。
让我们再来看看把静态方法拆分出来之后会是什么样子:
// Resource management concerns
public sealed partial class NativeDeviceContext : IDisposable
{
public IntPtr Handle { get; }
public NativeDeviceContext(IntPtr handle)
{
Handle = handle;
}
~NativeDeviceContext()
{
Dispose();
}
public void SetGammaRamp(GammaRamp ramp)
{
// Call a WinAPI method via p/invoke
NativeMethods.SetDeviceGammaRamp(Handle, ref ramp);
}
public void Dispose()
{
NativeMethods.DeleteDC(Handle);
GC.SuppressFinalize(this);
}
}
// Resource acquisition concerns
public partial class NativeDeviceContext
{
public static NativeDeviceContext? FromDeviceName(string deviceName)
{
var handle = NativeMethods.CreateDC(deviceName, null, null, IntPtr.Zero);
return handle != IntPtr.Zero
? new NativeDeviceContext(handle)
: null;
}
public static NativeDeviceContext? FromPrimaryMonitor() { /* ... */ }
public static IReadOnlyList<NativeDeviceContext> FromAllMonitors() { /* ... */ }
}
与前面的例子类似,这通过在视觉上分离两个不相关(尽管耦合)的关注点——资源初始化和资源管理,使代码更易于阅读。
分离接口实现
我们可以使用分部类做的另一件有趣的事情是实现独立的接口。通常情况下,负责实现接口的成员对类的核心行为并没有实际贡献,因此将它们分离出来是合理的。
例如,我们来看一下 `<div> HtmlElement` 类,它表示 HTML DOM 中的一个元素。它实现了 `items` 方法IEnumerable<T>来遍历其子元素,并ICloneable方便进行深度复制。
使用分部类,我们可以这样组织代码:
// Core concerns
public partial class HtmlElement : HtmlNode
{
public string TagName { get; }
public IReadOnlyList<HtmlAttribute> Attributes { get; }
public IReadOnlyList<HtmlNode> Children { get; }
public HtmlElement(string tagName,
IReadOnlyList<HtmlAttribute> attributes,
IReadOnlyList<HtmlNode> children)
{
/* ... */
}
public HtmlElement(HtmlElement other)
{
/* ... */
}
public string? GetAttributeValue(string attributeName) { /* ... */ }
public IEnumerable<HtmlNode> GetDescendants() { /* ... */ }
}
// Implementation of IEnumerable<T>
public partial class HtmlElement : IEnumerable<HtmlNode>
{
public IEnumerator<HtmlNode> GetEnumerator() => Children.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
// Implementation of ICloneable
public partial class HtmlElement : ICloneable
{
public object Clone() => new HtmlElement(this);
}
将接口实现放在分部类中可以帮助我们减少由向上游转发调用的方法引起的“路由噪声”。此外,由于 C# 允许我们分别指定每个分区的类签名,因此我们可以方便地将属于同一接口的成员分组。
这种方法与条件编译结合使用时也非常有用。有时,我们可能需要引入依赖于框架特定版本功能的 API。为此,我们必须使用#if类似于区域(region)的指令,这会降低代码的可读性。
分部类可以帮助我们简化代码。让我们来看一个例子,其中我们重写了某些方法DisposeAsync,但前提是我们要针对 .NET Standard 2.1 构建程序集:
public partial class SegmentedHttpStream : Stream
{
private readonly HttpClient _httpClient;
private readonly string _url;
private readonly long _segmentSize;
private Stream? _currentStream;
public SegmentedHttpStream(HttpClient httpClient,
string url, long length, long segmentSize)
{
/* ... */
}
/* Skipped overrides for Stream methods */
protected override void Dispose(bool disposing)
{
if (disposing)
_currentStream?.Dispose();
base.Dispose(disposing);
}
}
#if NETSTANDARD2_1
public partial class SegmentedHttpStream
{
// This method is not available in earlier versions of the standard
public override async ValueTask DisposeAsync()
{
if (_currentStream != null)
await _currentStream.DisposeAsync();
await base.DisposeAsync();
}
}
#endif
在这种情况下使用分部类的明显好处在于,我们可以完全消除条件语句块带来的冗余代码。将它们移到代码外部而不是放在代码中间,代码看起来要好得多。
组织私人课程
拥有私有类并不罕见。当我们想要避免命名空间污染,同时又想定义一个仅在一个地方使用的类型时,私有类就非常方便。一个典型的例子是,当我们需要在第三方库或框架中实现自定义接口来覆盖某些行为时。
例如,假设我们要将销售报告导出为 HTML 文档,并且我们使用Scriban引擎来实现。在这种情况下,我们需要进行配置,以便模板可以从程序集中嵌入的资源而不是文件系统中解析。为此,框架要求我们提供一个自定义实现ITemplateLoader。
鉴于我们的自定义加载器仅在该类中使用,将其定义为私有类是完全合理的。然而,由于 C# 的语法非常冗长,私有类可能会给我们的代码引入不必要的冗余信息。
不过,使用分部类,我们可以像这样清理它:
public partial class HtmlReportRenderer : IReportRenderer
{
public async ValueTask<string> RenderReportAsync(SalesReport report, string templateCode)
{
var template = Template.Parse(templateCode);
var templateContext = new TemplateContext
{
TemplateLoader = new CustomTemplateLoader(), // reference the private class
StrictVariables = true
};
var model = new ScriptObject();
model.SetValue("report", report, true);
templateContext.PushGlobal(model);
return await template.RenderAsync(templateContext);
}
}
public partial class HtmlReportRenderer
{
// This type is only used within HtmlReportRenderer
private class CustomTemplateLoader : ITemplateLoader
{
private static readonly string ResourceRootNamespace =
$"{typeof(HtmlReportRenderer).Namespace}.Templates";
private static StreamReader GetTemplateReader(string templatePath)
{
var resourceName = $"{ResourceRootNamespace}.{templatePath}";
var assembly = Assembly.GetExecutingAssembly();
using var stream = assembly.GetManifestResourceStream(resourceName);
if (stream == null)
throw new MissingManifestResourceException("Template not found.");
return new StreamReader(stream);
}
public string GetPath(
TemplateContext context,
SourceSpan callerSpan,
string templateName) => templateName;
public string Load(
TemplateContext context,
SourceSpan callerSpan,
string templatePath) => GetTemplateReader(templatePath).ReadToEnd();
public async ValueTask<string> LoadAsync(
TemplateContext context,
SourceSpan callerSpan,
string templatePath) => await GetTemplateReader(templatePath).ReadToEndAsync();
}
}
任意代码分组
我们并非总是需要特殊情况才决定使用分部类。事实上,有时将代码拆分成一些逻辑块会感觉更合适。
在这个例子中,我们有一个用于格式化文件的命令行应用程序。选项和命令行为都定义在同一个类中,这可能有点令人困惑。
通过使用分部类,我们可以像这样拆分和组合类的不同部分:
// Core options
public partial class FormatCommand
{
[CommandOption("files", 'f', IsRequired = true, Description = "List of files to process.")]
public IReadOnlyList<FileInfo> Files { get; set; }
[CommandOption("config", 'c', Description = "Configuration file.")]
public FileInfo? ConfigFile { get; set; }
}
// Options related to formatting
public partial class FormatCommand
{
[CommandOption("indent-size", Description = "Override: indent size.")]
public int? IndentSize { get; set; } = 4;
[CommandOption("line-length", Description = "Override: line length.")]
public int? LineLength { get; set; } = 80;
[CommandOption("insert-eof-newline", Description = "Override: insert new line at EOF.")]
public bool? InsertEofNewLine { get; set; } = false;
}
// Command implementation
[Command("format", Description = "Format files.")]
public partial class FormatCommand : ICommand
{
private readonly IFormattingService _formattingService;
public FormatCommand(IFormattingService formattingService)
{
_formattingService = formattingService;
}
private Config LoadConfig() { /* ... */ }
public async ValueTask ExecuteAsync(IConsole console)
{
var config = LoadConfig();
foreach (var file in Files)
{
await _formattingService.FormatAsync(config, file.FullName);
console.Output.WriteLine($"Formatted: {file.FullName}");
}
}
}
概括
分部类不仅可以用于自动生成的代码,它还是一个强大的语言特性,能够以创造性的方式将代码组织成更小的、逻辑上独立的单元。当我们想要减轻认知负荷或仅仅是为了让代码更有条理时,这非常有用。
既然我们谈到了重构,不妨也了解一下使用扩展方法编写更简洁代码的几种有趣方式。与分部类类似,它们的用途可能比你想象的要多。
关注我的推特账号,即可在新文章发布时收到通知✨
文章来源:https://dev.to/tyrrrz/refactoring-c-code-using-partial-classes-1pla