别把 ImmutableArray 当成只读 List:C#.NET 不可变数组深入详解

别把 ImmutableArray 当成只读 List:C#.NET 不可变数组深入详解

很多代码会把集合暴露成 IReadOnlyList<T>

csharp 复制代码
public IReadOnlyList<string> Names => _names;

这样可以限制调用方不能直接 Add,但它只是一个只读视图。只要内部的 List<string> 还在变化,外部枚举到的内容就可能变化。

ImmutableArray<T> 解决的是另一件事:创建完成后,数组中的元素和数组长度都不能通过这个集合实例改变。需要"修改"时,返回一个新的数组,旧数组继续保持原样。

它保留了数组最重要的优点:连续存储、按索引访问快、遍历开销低;同时提供不可变集合的快照语义,适合配置快照、规则表、路由表、编译器结果、插件列表等读多写少场景。

本文从创建、读取和修改开始,逐步说明 Builderdefault 状态、线程安全、原子发布、性能取舍,以及什么时候该选择 List<T>ReadOnlyCollection<T>ImmutableList<T>

ImmutableArray 是什么?

命名空间:

csharp 复制代码
System.Collections.Immutable

ImmutableArray<T> 是一个不可变数组结构。它本身是 struct,内部持有一个数组引用;集合实例创建后,不能通过 Add、索引赋值等方式改变原内容。

普通数组可以修改:

csharp 复制代码
int[] numbers = { 1, 2, 3 };
numbers[0] = 100;

Console.WriteLine(numbers[0]); // 100

ImmutableArray<T> 没有可写索引器:

csharp 复制代码
using System.Collections.Immutable;

ImmutableArray<int> numbers = ImmutableArray.Create(1, 2, 3);

// numbers[0] = 100; // 编译错误

如果确实需要替换第一个元素,使用 SetItem

csharp 复制代码
ImmutableArray<int> replaced = numbers.SetItem(0, 100);

Console.WriteLine(string.Join(", ", numbers));  // 1, 2, 3
Console.WriteLine(string.Join(", ", replaced)); // 100, 2, 3

关键点是:SetItem 没有修改 numbers,而是返回了一个新数组。

为什么需要不可变数组?

只读接口不等于数据不变

下面的代码隐藏了写操作,但没有冻结数据:

csharp 复制代码
private readonly List<string> _names = new() { "Tom", "Jerry" };

public IReadOnlyList<string> Names => _names;

public void AddName(string name)
{
    _names.Add(name);
}

Names 不能直接调用 Add,但 AddName 仍然会让已经拿到的 Names 看到新元素。若某段逻辑需要一份稳定快照,IReadOnlyList<T> 不一定够用。

换成 ImmutableArray<T>

csharp 复制代码
private ImmutableArray<string> _names =
    ImmutableArray.Create("Tom", "Jerry");

public ImmutableArray<string> Names => _names;

public void AddName(string name)
{
    _names = _names.Add(name);
}

原来的 Names 仍然指向旧版本,新读取会拿到新版本。每个版本都是稳定快照。

多线程读取更简单

可变集合需要协调读写:

text 复制代码
线程 A 正在枚举
线程 B 修改 List<T>
线程 A 可能抛出异常或读到不一致状态

不可变集合的读取不需要为了"集合自身会不会被修改"而加锁:

text 复制代码
发布一份 ImmutableArray 快照
多个线程并发读取同一快照
生成新版本时不影响旧版本

这不是说所有业务状态都自动线程安全。数组元素 T 如果本身是可变对象,元素对象的并发安全仍然需要单独处理。

安装和命名空间

在旧版 .NET、.NET Standard 或需要显式引用包的项目中安装:

bash 复制代码
dotnet add package System.Collections.Immutable

代码中引用:

csharp 复制代码
using System.Collections.Immutable;

现代 .NET 项目通常已经间接包含相关程序集,但明确写入 PackageReference 有助于保持项目依赖清晰。具体是否需要安装,取决于目标框架和当前项目文件。

创建 ImmutableArray

使用 Create

元素数量较少时,工厂方法最直观:

csharp 复制代码
ImmutableArray<int> numbers = ImmutableArray.Create(1, 2, 3, 4);

foreach (int number in numbers)
{
    Console.WriteLine(number);
}

也可以显式指定类型:

csharp 复制代码
ImmutableArray<string> names = ImmutableArray.Create<string>(
    "Tom",
    "Jerry",
    "Spike");

使用 Empty

创建空数组:

csharp 复制代码
ImmutableArray<string> empty = ImmutableArray<string>.Empty;

Empty 是已初始化的空数组,表达"明确存在,只是没有元素"。

从数组或集合转换

csharp 复制代码
int[] source = { 1, 2, 3 };
ImmutableArray<int> immutable = source.ToImmutableArray();

IEnumerable<T> 转换需要 System.Linq 中的扩展方法:

csharp 复制代码
using System.Collections.Immutable;

List<string> names = new() { "Tom", "Jerry" };
ImmutableArray<string> immutableNames = names.ToImmutableArray();

转换完成后,继续修改 names 不会改变 immutableNames

csharp 复制代码
names.Add("Spike");

Console.WriteLine(immutableNames.Length); // 2
Console.WriteLine(names.Count);           // 3

这是因为转换会建立自己的数组存储,而不是继续暴露原 List<T> 的内部数组。

使用 CreateRange

不依赖 LINQ 扩展时,可以使用:

csharp 复制代码
IEnumerable<int> source = Enumerable.Range(1, 5);
ImmutableArray<int> numbers = ImmutableArray.CreateRange(source);

使用 Builder

动态构建大量元素时,使用 Builder

csharp 复制代码
ImmutableArray<int>.Builder builder =
    ImmutableArray.CreateBuilder<int>();

for (int i = 1; i <= 5; i++)
{
    builder.Add(i * 10);
}

ImmutableArray<int> numbers = builder.ToImmutable();

指定初始容量可以减少扩容:

csharp 复制代码
ImmutableArray<int>.Builder builder =
    ImmutableArray.CreateBuilder<int>(capacity: 1000);

Builder 是可变的,只有调用 ToImmutable() 后才得到不可变数组。因此 Builder 不应该直接跨线程共享,也不应该在构建完成后继续修改它并把它当成已发布快照。

读取元素

索引访问

csharp 复制代码
ImmutableArray<int> numbers = ImmutableArray.Create(10, 20, 30);

int first = numbers[0];
int last = numbers[^1];

Console.WriteLine(first); // 10
Console.WriteLine(last);  // 30

索引访问是 O(1),适合需要频繁按位置读取的场景。

LengthIsEmpty

csharp 复制代码
Console.WriteLine(numbers.Length);
Console.WriteLine(numbers.IsEmpty);

ImmutableArray<T> 使用 Length,与普通数组保持一致。IsEmpty 只判断长度是否为零。

遍历

csharp 复制代码
foreach (int number in numbers)
{
    Console.WriteLine(number);
}

ImmutableArray<T> 提供自己的 Enumerator,适合直接 foreach。也可以作为 IEnumerable<T> 传给 LINQ:

csharp 复制代码
int total = numbers.Sum();
int max = numbers.Max();

如果热路径对分配非常敏感,优先使用直接 foreach 和索引循环,避免不必要的接口转换和 LINQ 链。

"修改"ImmutableArray:返回新版本

不可变集合的修改方法不会改变原实例。

添加元素

csharp 复制代码
ImmutableArray<int> original = ImmutableArray.Create(1, 2, 3);
ImmutableArray<int> appended = original.Add(4);

Console.WriteLine(string.Join(", ", original));  // 1, 2, 3
Console.WriteLine(string.Join(", ", appended)); // 1, 2, 3, 4

添加多个元素:

csharp 复制代码
ImmutableArray<int> result = original.AddRange(new[] { 4, 5, 6 });

替换元素

csharp 复制代码
ImmutableArray<int> replaced = original.SetItem(1, 200);

Console.WriteLine(string.Join(", ", original));  // 1, 2, 3
Console.WriteLine(string.Join(", ", replaced)); // 1, 200, 3

如果不确定索引是否存在,可以使用 SetItem 前先判断:

csharp 复制代码
int index = 10;
ImmutableArray<int> result = index < original.Length
    ? original.SetItem(index, 100)
    : original;

索引超出范围时,SetItem 会抛出 ArgumentOutOfRangeException

删除元素

csharp 复制代码
ImmutableArray<int> removedByValue = original.Remove(2);
ImmutableArray<int> removedByIndex = original.RemoveAt(0);

Console.WriteLine(string.Join(", ", removedByValue)); // 1, 3
Console.WriteLine(string.Join(", ", removedByIndex)); // 2, 3

Remove 删除第一个匹配元素;没有找到时会返回内容相同的数组或按当前实现处理,业务代码不应依赖"是否返回同一个实例"这种实现细节。

插入和移除范围

csharp 复制代码
ImmutableArray<int> inserted = original.Insert(1, 99);
ImmutableArray<int> rangeRemoved = original.RemoveRange(0, 2);

Console.WriteLine(string.Join(", ", inserted));     // 1, 99, 2, 3
Console.WriteLine(string.Join(", ", rangeRemoved)); // 3

插入和删除会移动后续元素,因此通常是 O(n) 操作。

排序、反转和过滤

csharp 复制代码
ImmutableArray<int> numbers = ImmutableArray.Create(3, 1, 2);

ImmutableArray<int> sorted = numbers.Sort();
ImmutableArray<int> reversed = numbers.Reverse();
ImmutableArray<int> filtered = numbers.Where(x => x > 1)
    .ToImmutableArray();

这些方法都不会改动 numbers

Builder:大量写入的正确姿势

下面的写法逻辑上正确,但性能通常很差:

csharp 复制代码
ImmutableArray<int> numbers = ImmutableArray<int>.Empty;

for (int i = 0; i < 100_000; i++)
{
    numbers = numbers.Add(i);
}

每次 Add 都可能创建并复制一个新数组,累计复制量会非常大。

改用 Builder:

csharp 复制代码
ImmutableArray<int>.Builder builder =
    ImmutableArray.CreateBuilder<int>(100_000);

for (int i = 0; i < 100_000; i++)
{
    builder.Add(i);
}

ImmutableArray<int> numbers = builder.ToImmutable();

Builder 的工作方式更接近 List<T>:内部数组可以扩容,元素可以修改;转换成不可变数组时,尽量复用现有存储,避免多一次完整复制。

从旧数组变更得到 Builder

csharp 复制代码
ImmutableArray<int> original = ImmutableArray.Create(1, 2, 3);

ImmutableArray<int>.Builder builder = original.ToBuilder();
builder.Add(4);
builder[0] = 100;

ImmutableArray<int> updated = builder.ToImmutable();

ToBuilder() 本身需要把原内容复制到可变 Builder 中,因此适合"后面要连续做多次修改"的场景,不适合只改一个元素却频繁来回转换。

Builder 不是线程安全集合

csharp 复制代码
ImmutableArray<int>.Builder builder =
    ImmutableArray.CreateBuilder<int>();

// 不要让多个线程同时修改同一个 builder

Builder 只负责构建阶段。构建结束后调用 ToImmutable(),再把结果交给并发读取代码。

default、Empty 和 IsDefault

ImmutableArray<T> 是值类型,因此可以处于"默认值"状态:

csharp 复制代码
ImmutableArray<int> defaultArray = default;
ImmutableArray<int> emptyArray = ImmutableArray<int>.Empty;

Console.WriteLine(defaultArray.IsDefault); // True
Console.WriteLine(emptyArray.IsDefault);   // False
Console.WriteLine(defaultArray.IsEmpty);   // True
Console.WriteLine(emptyArray.IsEmpty);     // True

两者长度都为零,但语义不同:

状态 含义
default 尚未初始化的数组值
Empty 已明确初始化,但没有元素
IsDefaultOrEmpty 两种空状态都视为空

业务 API 通常可以返回 Empty,避免调用方额外判断默认状态:

csharp 复制代码
public ImmutableArray<string> GetNames()
{
    return ImmutableArray<string>.Empty;
}

对于可能来自字段默认值、序列化或可选参数的内部状态,可以使用:

csharp 复制代码
if (items.IsDefaultOrEmpty)
{
    Console.WriteLine("没有数据");
}

某些 API 在默认状态下调用枚举、索引或扩展方法时可能抛出异常。对外传递前先规范为 Empty,通常更稳妥:

csharp 复制代码
ImmutableArray<int> normalized = value.IsDefault
    ? ImmutableArray<int>.Empty
    : value;

浅不可变:元素对象仍然可能变化

ImmutableArray<T> 保护的是数组结构和元素引用,不会自动冻结 T 对象。

csharp 复制代码
sealed class User
{
    public string Name { get; set; } = string.Empty;
}

User user = new() { Name = "Tom" };
ImmutableArray<User> users = ImmutableArray.Create(user);

user.Name = "Jerry";

Console.WriteLine(users[0].Name); // Jerry

数组没有被修改,但数组中的 User 对象被外部修改了。

需要深层不可变时,应让元素类型本身不可变:

csharp 复制代码
public sealed record User(string Name);

ImmutableArray<User> users = ImmutableArray.Create(new User("Tom"));
users = users.SetItem(0, users[0] with { Name = "Jerry" });

也可以使用只读属性、record、不可变 DTO 或其他不可变对象模型。集合不可变和元素不可变是两层不同的保证。

线程安全:读取安全不等于更新自动安全

多线程读取

一份已经构建好的数组可以被多个线程读取:

csharp 复制代码
ImmutableArray<string> names = ImmutableArray.Create(
    "Tom",
    "Jerry",
    "Spike");

Parallel.For(0, 100, _ =>
{
    foreach (string name in names)
    {
        _ = name.Length;
    }
});

读取过程中没有线程会改变 names 的内容,因此不需要为集合本身加锁。

使用 ImmutableInterlocked 发布新版本

如果一个字段会被多个线程读取,同时由后台线程整体替换,需要使用适合不可变集合的原子操作:

csharp 复制代码
using System.Collections.Immutable;

public sealed class RulesSnapshot
{
    private ImmutableArray<string> _rules =
        ImmutableArray<string>.Empty;

    public ImmutableArray<string> Current => _rules;

    public void Replace(IEnumerable<string> rules)
    {
        ImmutableArray<string> next = rules.ToImmutableArray();
        ImmutableInterlocked.InterlockedExchange(
            ref _rules,
            next);
    }
}

读取时先保存局部快照:

csharp 复制代码
public bool Contains(string rule)
{
    ImmutableArray<string> snapshot = _rules;
    return snapshot.Contains(rule, StringComparer.OrdinalIgnoreCase);
}

一次方法调用会基于同一份数组快照完成,不会读到"半更新"的集合内容。

需要注意:

  • ImmutableArray<T> 保证的是集合内容不可变。
  • ImmutableInterlocked 负责多个线程之间发布新版本时的原子交换。
  • 元素对象 T 若可变,仍然需要自己的线程安全策略。

用锁也可以,但语义不同

以下方式同样可以保护字段:

csharp 复制代码
private readonly object _gate = new();
private ImmutableArray<string> _rules =
    ImmutableArray<string>.Empty;

public void Replace(IEnumerable<string> rules)
{
    ImmutableArray<string> next = rules.ToImmutableArray();

    lock (_gate)
    {
        _rules = next;
    }
}

如果只是替换一个不可变快照,ImmutableInterlocked 更贴合这个模型;如果更新还涉及多个字段、外部资源或复杂事务,锁或其他同步方案仍然有价值。

实战 Demo:线程安全的配置快照

下面实现一个简单的配置仓库:

  • 配置读取不加锁。
  • 后台更新时先构建完整数组。
  • 完整数组构建完成后一次性发布。
  • 每次读取拿到稳定快照。

配置模型

csharp 复制代码
public sealed record AppSetting(
    string Key,
    string Value);

使用 record 让配置项本身具备值语义和不可变属性。

配置仓库

csharp 复制代码
using System.Collections.Immutable;

public sealed class ConfigurationSnapshot
{
    private ImmutableArray<AppSetting> _settings =
        ImmutableArray<AppSetting>.Empty;

    public void Replace(IEnumerable<AppSetting> settings)
    {
        ImmutableArray<AppSetting> next =
            settings.ToImmutableArray();

        ImmutableInterlocked.InterlockedExchange(
            ref _settings,
            next);
    }

    public bool TryGetValue(
        string key,
        out string? value)
    {
        ImmutableArray<AppSetting> snapshot = _settings;

        foreach (AppSetting setting in snapshot)
        {
            if (string.Equals(
                setting.Key,
                key,
                StringComparison.OrdinalIgnoreCase))
            {
                value = setting.Value;
                return true;
            }
        }

        value = null;
        return false;
    }

    public ImmutableArray<AppSetting> GetAll()
    {
        return _settings;
    }
}

运行示例

csharp 复制代码
using System.Collections.Immutable;

var configuration = new ConfigurationSnapshot();

configuration.Replace(new[]
{
    new AppSetting("ConnectionString", "Server=db01"),
    new AppSetting("LogLevel", "Information")
});

if (configuration.TryGetValue("LogLevel", out string? level))
{
    Console.WriteLine(level); // Information
}

ImmutableArray<AppSetting> snapshot = configuration.GetAll();

configuration.Replace(new[]
{
    new AppSetting("ConnectionString", "Server=db02"),
    new AppSetting("LogLevel", "Debug")
});

Console.WriteLine(snapshot[0].Value); // Server=db01
Console.WriteLine(configuration.GetAll()[0].Value); // Server=db02

旧快照仍然可以继续使用,新配置不会影响正在执行的读取逻辑。这种模式很适合定时加载配置、刷新路由、替换规则表和热更新插件清单。

ImmutableArray 与 List 的性能取舍

ImmutableArray<T> 不是"所有场景都更快"的 List<T> 替代品。

操作 ImmutableArray<T> List<T>
按索引读取 O(1) O(1)
遍历 快,连续数组 快,连续数组
末尾添加一次 通常 O(n),生成新数组 摊销 O(1)
中间插入/删除 O(n) O(n)
多次连续构建 使用 Builder 直接 Add
多线程读取 快照安全 修改时需同步
内存结构 数组 + 值类型包装 List + 内部数组

重点不在于"不可变集合没有成本",而在于把修改成本集中到版本生成阶段,换取读取阶段的稳定性和更简单的并发模型。

什么时候适合 ImmutableArray?

适合:

  • 初始化后长期读取的静态数据。
  • 配置、规则、路由和权限快照。
  • 编译器、分析器、代码生成器中的结果集合。
  • 多线程读、低频整体替换。
  • 需要快速索引访问的不可变数据。
  • API 返回值需要明确表达"调用方不能修改"。

什么时候应该使用 List?

适合:

  • 单线程内部临时构建。
  • 高频增加、删除、排序和修改。
  • 集合生命周期只存在于一个方法内部。
  • 不需要跨线程共享稳定快照。

常见组合是:内部用 List<T> 构建,完成后转换成 ImmutableArray<T> 对外发布:

csharp 复制代码
List<string> buffer = new();
buffer.Add("A");
buffer.Add("B");

ImmutableArray<string> published = buffer.ToImmutableArray();

ImmutableArray 与 ReadOnlyCollection 的区别

ReadOnlyCollection<T> 是对现有 IList<T> 的只读包装:

csharp 复制代码
List<int> list = new() { 1, 2, 3 };
ReadOnlyCollection<int> view = list.AsReadOnly();

list.Add(4);
Console.WriteLine(view.Count); // 4

它适合"限制当前引用的写操作",但不会创建独立快照。

ImmutableArray<T> 则把内容固定下来:

csharp 复制代码
List<int> list = new() { 1, 2, 3 };
ImmutableArray<int> snapshot = list.ToImmutableArray();

list.Add(4);
Console.WriteLine(snapshot.Length); // 3

选择依据:

text 复制代码
需要只读视图,原集合变化也应该可见 → ReadOnlyCollection<T>
需要独立稳定快照 → ImmutableArray<T>

ImmutableArray 与 ImmutableList 的区别

两者都不可变,但底层结构和修改成本不同:

特性 ImmutableArray<T> ImmutableList<T>
底层结构 连续数组 树形结构
索引读取 O(1) 通常 O(log n)
添加/删除 通常需要复制数组,O(n) 结构共享,通常 O(log n)
遍历局部性 相对复杂
适合场景 读多写少、频繁索引 需要多个版本并频繁修改

如果数据主要是读取和遍历,ImmutableArray<T> 往往更合适;如果每次修改都要生成新版本,且集合较大、修改频率高,可以评估 ImmutableList<T>

API 设计:返回 ImmutableArray

对外返回不可变结果

csharp 复制代码
public sealed class PluginCatalog
{
    private readonly ImmutableArray<string> _plugins;

    public PluginCatalog(IEnumerable<string> plugins)
    {
        _plugins = plugins.ToImmutableArray();
    }

    public ImmutableArray<string> GetPlugins()
    {
        return _plugins;
    }
}

调用方可以遍历、索引和 LINQ 查询,但不能直接修改集合结构。

不要把内部 Builder 暴露出去

csharp 复制代码
public ImmutableArray<int>.Builder GetBuilder()
{
    return _builder;
}

这种 API 会把可变构建状态泄露给外部,破坏封装和线程安全。应返回 ImmutableArray<T>,或者返回只读接口:

csharp 复制代码
public ImmutableArray<int> GetItems()
{
    return _builder.ToImmutable();
}

参数接收

如果方法需要稳定快照,可以直接接收 ImmutableArray<T>

csharp 复制代码
public void RegisterRules(ImmutableArray<string> rules)
{
    _rules = rules;
}

如果方法只需要枚举,不关心集合具体类型,使用 IEnumerable<T> 更灵活;如果需要避免调用方在执行过程中改变数据,入口处可以转换一次:

csharp 复制代码
public void RegisterRules(IEnumerable<string> rules)
{
    _rules = rules.ToImmutableArray();
}

常见误区

误区一:ImmutableArray 中的对象也自动不可变

它只保证数组结构不变,不能自动冻结引用类型元素。元素类需要使用 record、只读属性或其他不可变设计。

误区二:每次 Add 都很便宜

单次 Add 使用简单,但在循环中不断 array = array.Add(item) 会反复分配和复制。大量写入使用 Builder。

误区三:IReadOnlyList 就等于 ImmutableArray

IReadOnlyList<T> 只是接口能力限制,不保证底层集合不变化。需要快照时使用 ToImmutableArray()

误区四:ImmutableArray 的字段赋值就自动完成并发发布

不可变内容和版本发布是两个问题。并发整体替换字段时,使用 ImmutableInterlocked 或锁,明确同步语义。

误区五:default 和 Empty 完全一样

两者都没有元素,但 default 表示未初始化状态,Empty 表示已初始化的空集合。公共 API 更推荐返回 Empty

误区六:所有线程安全问题都消失了

数组本身不会被修改,但元素对象、外部资源、多个字段之间的组合状态仍可能需要同步。

一个简单的基准测试思路

比较集合性能时,不能只测一次 Add。应该分开测读取和构建:

csharp 复制代码
using System.Collections.Immutable;
using System.Diagnostics;

const int count = 100_000;

Stopwatch stopwatch = Stopwatch.StartNew();
ImmutableArray<int>.Builder builder =
    ImmutableArray.CreateBuilder<int>(count);

for (int i = 0; i < count; i++)
{
    builder.Add(i);
}

ImmutableArray<int> immutable = builder.ToImmutable();
stopwatch.Stop();

Console.WriteLine($"Builder 构建:{stopwatch.ElapsedMilliseconds} ms");

stopwatch.Restart();
long total = 0;
for (int i = 0; i < immutable.Length; i++)
{
    total += immutable[i];
}

stopwatch.Stop();
Console.WriteLine($"索引读取:{stopwatch.ElapsedMilliseconds} ms");
Console.WriteLine(total);

正式性能结论应使用 BenchmarkDotNet,并结合真实数据量、元素类型、读写比例和内存分配结果判断。不要因为某个集合在单项测试中更快,就直接替换所有业务集合。

总结

ImmutableArray<T> 可以理解为"拥有数组读取性能的不可变快照":

text 复制代码
创建完成
   ↓
内容和长度固定
   ↓
多个线程安全读取
   ↓
修改时生成新版本

使用时记住几个关键原则:

  • 少量固定数据使用 ImmutableArray.Create
  • 大量动态构建使用 ImmutableArray<T>.Builder
  • 修改操作返回新数组,旧数组不会改变。
  • Emptydefault 都是空,但语义不同。
  • 数组不可变不代表元素对象不可变。
  • 并发整体替换使用 ImmutableInterlocked 或锁。
  • 高频写入优先考虑 List<T>,需要不可变版本时再转换。

真正适合 ImmutableArray<T> 的场景,是"读取远多于写入,并且读取方需要稳定快照"。如果核心需求是频繁增删,List<T>ImmutableList<T> 往往更合适。

参考资料:

相关推荐
zlinear数据采集卡3 小时前
D223 ADC数据处理流水线:从原始采样值到工程单位的完整转换链
开发语言·arm开发·嵌入式硬件·fpga开发·开源·c#
花落人散处7 小时前
CDiskClean 开发历程 —— 文件清理_目录树解析实现方案参考
c#
离陌在学C#7 小时前
C# 泛型:从基础到高级应用
开发语言·c#
Vertira7 小时前
C# 与 OpencvSharp 编程[基础目录]
c#
格林威9 小时前
多相机并行采图最佳实践:Task.WhenAll + 异常处理 + 资源释放
开发语言·人工智能·数码相机·计算机视觉·c#·视觉检测·机器视觉
yuhulkjv33518 小时前
Claude表格复制到word不再崩溃,AI导出鸭批量导出+格式无损一键搞定
人工智能·ai·c#·word·ai导出鸭
arbboter1 天前
【网络工具】NetProxy网络代理用户手册
开发语言·网络·c#·网络代理·网络异常·代理工具
微小冷1 天前
海康威视相机C#二次开发环境配置
数码相机·c#·.net·海康威视·相机开发
吴可可1231 天前
C#重写机制与CAD图元开发要点
c#