前言现在 AI 编程工具已经很常见了，像 Cursor、Trae、Copilot、Kiro 这些我都陆续试过，体验都还不错。不过能帮忙解读整个代码仓库的，目前来看 DeepWiki 算是比较独特的。刚看到这个功能的时候觉得挺实用。不过 DeepWiki 只能解析开源项目，私有项目就需要付费或者自己找解决办法了。我最开始试的是国人开发的 OpenDeepWiki，但部署起来遇到了不少问题，最后还是放弃了。后来又找到了 DeepWiki-Open，经过一番折腾终于跑起来了。现在网上相关资料比较少，所以写一下自己的部署流程，供大家参考。准备文件这部分应该是最麻烦的，而且要自己摸索。官方 Readme 提供了两种部署方式：可以用 Docker 一键启动整个服务，也可以分别在本地启动前后端。我这里选择了 Docker 部署。不管用哪种方式，第一步都是先把仓库拉下来。git clone https://github.com/AsyncFuncAI/deepwiki-open.git cd deepwiki-open接下来，需要在文件夹下创建一个 .env 配置文件，主要填写你使用的大模型和 embedding 模型的 API 地址和 key。我这里用的是 AzureOpenAI 和其他家的 embedding 模型。注意，一定要配置 embedding 模型的地址，而且要填在 OpenAI 的配置项里，否则后面会报错。# 我这里用的 Azure，请根据官方文档选择成自己用的服务商 AZURE_OPENAI_API_KEY=tomorin AZURE_OPENAI_ENDPOINT=https://anon.openai.azure.com/ AZURE_OPENAI_VERSION=2025-01-01-preview # 这里主要配置 embedding 模型。如果你的大模型和 embedding 模型都用的是同一家支持 OpenAI 兼容 API 的服务商，只需要填这一部分，就可以同时用他们的大模型和 embedding 服务了。 OPENAI_API_KEY=tomorin OPENAI_BASE_URL=https://api.anon.com/v1保存成 .env 文件。为了速度，我们可以修改一下 Dockerfile。npm 我们可以使用镜像：RUN npm ci --legacy-peer-deps # 改成 RUN npm ci --legacy-peer-deps --registry=https://registry.npmmirror.comapt 也可以用镜像（大部分能走）：# Install Node.js and npm # 下面部分修改成 RUN echo "deb http://mirrors.aliyun.com/debian/ bookworm main contrib non-free non-free-firmware\n\ deb http://mirrors.aliyun.com/debian-security bookworm-security main contrib non-free non-free-firmware\n\ deb http://mirrors.aliyun.com/debian-security trixie-security main contrib non-free non-free-firmware\n\ deb http://mirrors.aliyun.com/debian/ bookworm-updates main contrib non-free non-free-firmware\n\ deb http://mirrors.aliyun.com/debian/ trixie main contrib non-free non-free-firmware\n\ deb http://mirrors.aliyun.com/debian/ bookworm-backports main contrib non-free non-free-firmware" \ > /etc/apt/sources.list && \ apt-get clean && \ apt-get update && \ apt-get install -y \ curl \ gnupg \ git \ ca-certificates && \ mkdir -p /etc/apt/keyrings && \ curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \ echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list && \ apt-get update && \ apt-get install -y nodejs && \ apt-get clean && \ rm -rf /var/lib/apt/lists/*然后保存。接下来，需要进入 api/config 文件夹，配置你要用的 embedding 模型。里面有一个官方提供的模板文件 embedder.openai_compatible.json.bak，可以把它重命名为 embedder.json，替换掉原有的配置。通常只需要修改其中的 embedder 部分即可：{ "embedder": { "client_class": "OpenAIClient", "initialize_kwargs": { "api_key": "${OPENAI_API_KEY}", "base_url": "${OPENAI_BASE_URL}" }, "batch_size": 10, "model_kwargs": { "model": "bge-m3", "dimensions": 1024, "encoding_format": "float" } } } 把 model 改成你自己的 embedding 模型 ID。注意，dimensions 是向量的维度，有些 API 不支持这个参数，比如我用的服务就不需要，所以直接删掉了这行。具体怎么配，建议根据你的实际接口来调整。如果不确定，可以启动后查看 api/logs/application.log 里的报错信息，方便排查问题。然后是一个可选，但我建议做的操作，修改 docker-compose.yml 文件，在 volumes 下增加一个：- ./api/config:/app/api/config否则你后续对该文件夹下 json 文件的修改都没有办法同步到容器里，非常坑！至此文件准备工作完成。启动服务然后用 docker compose up 启动容器（最后确认一切正常后，后续可以用 docker compose up -d 让服务在后台运行）。第一次启动会在本地构建镜像，注意确保你的网络可以顺利拉取到 Docker 仓库里的官方镜像。镜像构建可能需要一些时间。服务启动完成后，直接访问 http://服务器IP:3000，就可以看到 DeepWiki-Open 的页面了（不知道为什么颜色搞得这么黄，看得难受）。仓库分析DeepWiki-Open 既支持 GitHub、GitLab 等在线仓库（包括使用 token 访问私有仓库），也支持本地路径。如果在 Docker 环境下用本地路径，记得先在 docker-compose.yml 里做好挂载。在上方输入地址或路径后，点击按钮。配置好相关选项后，点击 生成Wiki 即可。如果页面出现报错，建议查看 api/logs/application.log，从而定位和解决问题。最后耐心等待，就可以得到你的项目 Wiki 了！（一次会烧掉多少token就不好说了...）右下角可以继续问模型关于这个项目的问题。结语目前我也刚开始使用，文档的具体质量还有待进一步体验。但对于团队来说，有这样一份自动生成的文档，对新成员上手项目应该还是挺有帮助的。
前言因为最近在公司做大模型相关的工作，前段时间就趁着兴头，挑了点空闲时间按照自己瞎想的思路实现了一个埃癸斯的语音助手DEMO（可能也不算助手吧，算是角色扮演？）.先前演示和代码都已经放在B站和GitHub上了，就顺带也放到博客来吧。视频演示https://www.bilibili.com/video/BV1Dyf5YLE4p代码仓库https://github.com/CosineG/aegis-assistant系统介绍系统工作流程语音活动检测（VAD）：通过声音活动实时检测用户语音输入音频采集：触发录音模块进行语音捕获语音识别（ASR）：将音频转换为文本大模型处理（LLM）：解析用户意图并生成回复语音合成（TTS）：将文本回复转换为语音输出当前为循环对话模式，即程序启动后持续监听环境语音。模型选用语音识别采用 fast-whisper large-v3 模型，多语言支持、识别准确率高。大模型交互选用 Deepseek V3 的官方 API（性价比高，但是现在因为 R1 爆火很不稳定）。另外实现了：流式输出：通过分块响应降低等待延迟。Function Calling：支持天气查询、音乐播放等扩展功能（需自定义实现）。语音合成选用GPT-SOVTIS（基于P3R解包语音数据集训练），在 RTX 4080 环境下训练和推理速度都很优异，相比下 Fish-Speech 训练周期较长，生成质量不佳。针对长文本场景的语音延迟问题，采用分句级流式处理方案：实时解析大模型流式输出，动态检测完整语义分句，后台线程并行生成语音,最终实现播放与生成的流水线作业。可在播放当前语句时即可预生成后续内容，显著提升交互流畅度。扩展方向智能家居集成：通过米家 Python SDK 实现设备控制。离线部署优化：部署如 Qwen 2.5 14B 等大模型本地交互。
重点先说一个踩过的坑：如果要使用 SSE 流式响应，那么在 Nginx 里不要开启该接口的 Gzip ！！！因为开启 Gzip 后会导致数据达到一定大小才会被压缩然后返回给前端，导致前端不能实时获得数据，就无法形成流式的文本效果。例子举个在 ASP.NET Core 中实现 SSE 流式响应的简单例子。[HttpGet] public async Task<IActionResult> SseExample() { // 请求头 Response.Headers.Add("Content-Type", "text/event-stream"); Response.Headers.Add("Cache-Control", "no-cache"); Response.Headers.Add("Connection", "keep-alive"); // 假设不断得到新数据 for (int i = 0; i < 10; i++) { // 每个数据参照 SSE 返回数据格式进行组装 var content = "event: message\n" + "data: {\"color\":\"66ccff\"}\n\n"; // 立刻写入响应 await Response.WriteAsync(content); await Response.Body.FlushAsync(); } // 结束 return new EmptyResult(); }测试的时候可以从控制台的网络详情里看到本次响应的数据流：备注我目前主要是希望搭一套向量知识库（选了MaxKB），查询出知识库中的已知信息然后利用大模型分析回答。因为需要对知识库的输入做处理（比如增加关键字，过滤某些信息，相同问题查缓存等），直接暴露知识库 API 风险较高，可能 token 都被人用来问大模型其他问题了，因此需要做一层处理和转发。而当前后端是基于 ASP.NET Core 的，所以需要在 ASP.NET Core 里实现 SSE 流式响应，使得能够在前端展示一个字一个字蹦出来的效果。
前言还是想着写一篇技术相关的文章吧，不然显得我的博客完全没有意义了 ::aru:lurking:: 这次简单介绍一下混沌工程相关的 ChaosBlade 工具，因为我也是刚接触不久，希望能帮助到需要入门的同学，也请大佬在评论里指点赐教。混沌工程什么是混沌工程呢？美团技术博客最近刚好有一篇相关的文章：超大规模数据库集群保稳系列之二：数据库攻防演练建设实践。其中是这么介绍的：简单而言，混沌工程是在系统上进行实验的技术手段，目的是建立对系统抵御生产环境中失控条件的能力以及信心。这主要体现在两个方面，从系统角度来讲，混沌工程可以提升我们架构的容错能力和韧性，降低故障发生率和复发率，提升系统用户在使用时的体验；从人员角度来讲，通过混沌工程我们可以加深对架构的理解，在处理故障时能提高整体的应急效率，并且能主动去探究系统中潜在的一些问题。我的理解就是简单来说，用来提前做故障演练发现隐藏问题的。所以我们需要这么一个工具来实现这件事情，因此我们可以选择ChaosBlade。ChaosBlade介绍ChaosBlade 是阿里巴巴开源的一款遵循混沌工程原理和混沌实验模型的实验注入工具，帮助企业提升分布式系统的容错能力，并且在企业上云或往云原生系统迁移过程中业务连续性保障。它支持注入系统资源（仅Linux系统）和Java应用级别的异常。系统资源异常包括：• 硬件（CPU负载、内存负载、磁盘填充、磁盘IO负载）• 文件（文件新增、文件追加、文件移动、文件删除、文件权限变更）• 网络（网络延迟、网络丢包、网络DNS异常、网络占用）• 进程（进程挂起、进程杀死）Java应用异常包括：• OOM（内存溢出）• 延迟（方法调用延迟、HTTP请求延迟、Dubbo rpc调用延迟等）• 抛异常（方法调用异常、HTTP请求异常、Dubbo rpc调用异常等）• 返回值篡改（基础类型和 null）• 执行自定义脚本（在注入方法的前或后执行自定义脚本）更多的介绍可以看中文版介绍文档：ChaosBlade: 一个简单易用且功能强大的混沌实验实施工具。使用但我们这是入门向文章，所以主要还是面向使用。使用 ChaosBlade 主要通过本体命令行的方式，或者 ChaosBlade-Box 网页端进行操作。如果是 k8s 上部署的话也可以通过 operator 使用 yaml 文件来进行实验。我这里主要使用 ChaosBlade-Box，但是根本上是相通的，因为实验的参数配置都是一样的。而且目前不管是界面还是文档其实都不完善，界面上很多参数其实都没有具体说明，需要去看命令行的文档里的参数配置才知道具体用途，另外我也会把遇到的一些坑做一些说明。官方文档首先我先贴上官方文档，有两个：ChaosBlade 整体介绍 | ChaosBladeREADME | chaosblade-help-zh-CN不过当中很多内容是相同的，主要要看每个实验的配置参数部分。安装 ChaosBlade-Box首先看你是要在 k8s 集群中还是机器上安装：1、如果是 k8s 中，首先用 helm 安装一个 chaosblade-operatorhelm repo add chaosblade-io https://chaosblade-io.github.io/charts helm install chaosblade chaosblade-io/chaosblade-operator --namespace chaosblade然后安装 box，下载 Github Release 页里提供的 tgz 包，也用 helm 安装：# 下载（用 1.0.3 而不是 1.0.4 的原因是 github 上发布了 1.0.4 但是作者没把镜像传到 docker hub ……） wget https://github.com/chaosblade-io/chaosblade-box/releases/download/v1.0.3/chaosblade-box-1.0.3.tgz # 安装 helm install chaosblade-box chaosblade-box-1.0.3.tgz --namespace chaosblade --set spring.datasource.password=数据库密码其中 namespace 参数是你要安装的命名空间，记得先创建好。后面的参数是安装时，同时会部署一个 mysql 5.6 的 deployment，你需要为它的 root 账号设置密码。2、如果是在机器里安装，首先确保你有 java 1.8 的运行环境，以及 mysql。注意 mysql 有要求！如果是 5.7 直接使用即可，但更高版本必须关闭 ONLY_FULL_GROUP_BY 设置，否则后面使用时页面上会报错。然后下载 Github Release 页里提供的 jar 包：下载链接，接着用命令在后台启动nohup java -Duser.timezone=Asia/Shanghai -jar chaosblade-box-1.0.4.jar --spring.datasource.url="jdbc:mysql://数据库IP:3306/chaosblade?characterEncoding=utf8&useSSL=false" --spring.datasource.username=数据库用户名 --spring.datasource.password=数据库密码 --chaos.server.domain=服务器地址:端口 > chaosblade-box.log 2>&1 &记得替换命令中的参数。然后访问对应的地址就能看到 ChaosBlade-Box 的页面了，账号在页面上注册一个就好，登录进入控制台。安装探针(Agent)ChaosBlade 要想在某台机子上真正进行故障注入，就需要在那台机子上有 Agent，因此我们需要进行安装。在探针管理页面分别有介绍 k8s 和 主机情况下的两种安装方法。记得版本手动改到 1.0.3，否则可能出现 Agent 连不上管理端的情况。另外其中一串 hash 值不能改，它是用来标识 Agent 连接到你的账户上的，如果管理端登录了另一个账号，也是不能管理这些 Agent 的。k8s 的好处在于集群中部署后，那么所有节点都能进行注入了，但主机的话必须每台实验的机器上都要独立安装。开始实验点击左侧演练场景便可以创建实验。比较简单的实验，我这里就不细讲了。很多界面上写的参数介绍并不详细，请查看上面的使用文档进行填写！！一些坑我这里讲一些可能会是坑的点：1、有一些参数是几选一填写的，比如 k8s 中确定包含在实验范围中的 pod，你可以给 name 或者给 labels，界面上显示都是非必填项，但是如果你都不填，实验开始会报错！2、如果是 k8s 中实验，我们配置 yaml 文件里标签写的是 app:xxx，那界面上参数要填 app=xxx。3、有的参数是标志位，相当于命令行中的 -xxx 参数，那么这种参数在界面上留空就是不启用，写 true 就是启用。比如 dubbo 延迟实验里如果实验的是消费者，那么 consumer 这个参数填 true，provider留空。4、如果是进程相关的实验，要么提供 pid，要么提供 process 关键字。关键字是通过 ps -ef | grep {process填写的值} 来搜索要注入的进程，如果存在多个满足的进程会报错。5、文件追加实验好像有问题，我就没使用成功过。6、内存负载和Java的 OOM 实验比较考验参数配置，写的量少了要达到高内存占用很慢，如果写的量大了很容易一下被系统杀掉。7、文档没说的是，Java 自定义脚本场景中，除了方法输入参数之外，还可以通过 "target" 这个 key 拿到调用注入方法的对象。如果启用 after 参数的话，还可以通过 "return" 这个 key 拿到方法原本的返回值。Java 自定义脚本我想着重讲的是 Java 自定义脚本这个实验，因为它的自由度非常之高，因此我们可以拿它实现非常多的故障场景。首先介绍一下基本参数：参数名类型说明classnamestring指定类名，必须是实现类，带全包名，例如 com.xxx.xxx.XController (必填项)methodnamestring指定方法名，注意相同方法名的方法都会被注入相同故障 (必填项)afterbool方法执行完成返回前注入故障，比如修改复杂的返回对象script-contentstring脚本内容，和file二选一script-filestring脚本文件，文件绝对路径script-namestring脚本名称，日志记录用，可不填写script-typestring脚本类型，取值为 java 或 groovy，默认为 javaeffect-countstring影响的请求条数effect-percentstring影响的请求百分比注意官方文档（命令行版）里 script-content 是要填写 base64 编码后的问题，而 box 版不需要，直接把脚本代码复制进去即可。假设有一个场景，我想测试如果 HTTP 调用的第三方 API 挂了怎么办？我不能真的去把人家的 API 干掉。因此我们可以选择对自己的 HTTP 客户端方法做注入，篡改返回结果，模拟 API 挂了的场景。假设我用的是 okhttp3 库，设定after参数，脚本如下：import java.util.Map; import okhttp3.Call; import okhttp3.Response; import okhttp3.ResponseBody; /** * @author CosineG */ public class ChaosController { public Object run(Map<String, Object> params) { // "target"对应的value为调用原方法的实例 Object target = params.get("target"); if (target instanceof Call) { Call call = (Call)target; // 判断请求地址是否为所需篡改的API 其他请求保持正常 if (call.request().url().url().toString().contains("/api/xxx")) { // "return"对应的value为原方法的原返回值 // 拷贝一份和原Response基本相同的Response，篡改后返回 Response response = (Response) params.get("return"); MediaType mediaType = MediaType.parse("text/html; charset=utf-8"); // 篡改一份404响应 Response.Builder builder = new Response.Builder() .request(response.request()) .protocol(response.protocol()) .code(404) .headers(response.headers()) .handshake(response.handshake()) .message("Not Found") .receivedResponseAtMillis(response.receivedResponseAtMillis()) .sentRequestAtMillis(response.sentRequestAtMillis()) .body(ResponseBody.create(mediaType, " 404 Not Found ")); return builder.build(); } } // return null时注入方法不修改返回值 // 不符合条件的不修改返回值 return null; } }这样当指定 Java 进程的任何代码通过 okhttp3 库调用指定地址的 API，都会得到一个 404 的 html 响应，可以用来检查程序是否有相对完善的应对措施。同理，也可以注入 es 或其他客户端库，实现错误返回、延时超时、异常报错等情况。但比较蛋疼的就是脚本在返回 null 的时候代表返回原始返回值，我现在还不知道怎么通过脚本返回 null 值。 ::aru:distressed:: 目前我了解的大概就是这些了，后续学习了新的技巧的话还会再更新的（大概吧
前言为了提升一下自己的姿势水平，买了 Effective C# 和 More Effective C# 两本书，想一边阅读一边强迫自己做点笔记（主要是自己的理解），不然怕是看了就忘。另外手头还有一本《深入解析C# ( C# in Depth )》其实我已经阅读了一遍，但感觉没咋读懂，下次有机会也用这种方式重读一遍。忙着搞秋招和毕业论文，以及自己是在是太能摸了，导致距离上次更新已经快一年了。不过以后工作还是要写.NET的，所以还是不能把相关学习落下，今天开始边写变更吧。今天这篇是 Effective C# 的第四章。第四章 合理地运用LINQLINQ全名Language Integrated Query，中文译为语言集成查询，简单来说就是用类似SQL语言的方式来对数据进行查询和操作，C#里提供了两种可使用的语法，分别是查询语法和方法语法。查询语法更贴近SQL语法，但其实我从来没写过...方法语法就是通过扩展方法实现了一系列方法，在数据集合之后调用，比如list.Where(n => n > 0).ToList();，我觉得使用起来更加方便，而且这个应该能算作是函数式编程吧。LINQ给数据操作还是带来了很大的方便的，尤其是写后端的时候操作数据库，像Java也是从8开始引进了stream，用起来感觉也很相似，之前实习的时候基本都是在写stream。第29条 优先考虑提供迭代器方法，而不要返回集合当我们要返回一系列对象的时候，应该优先考虑提供迭代器方法而不是集合，这样可以立刻返回，并且等调用方需要实际使用的时候才会创建对应数据，节省时间和空间（就是yield return返回IEnumerable<T>而不是直接返回一个List）。举个最简单的例子来说，我可能需要用到所有非负int值组成的序列中的一部分，如果直接创建一个包含这些数值的List<int>很划不来，而通过Enumerable.Range(0, int.MaxValue)会返回IEnumerable<int>，可以在后续使用的时候再去生成数值。而如果调用方真的需要对这些数据反复使用或者缓存下来，那让他们自己调用ToList()或者ToArray()就好了，相当于提供了两种选择，方便灵活使用，因此即使调用方确实需要把数据保存到集合里，我们还是应该优先提供迭代器方法（其实LINQ就是基于这个原则的）。另外在编写迭代器方法的时候要注意一个问题，就是参数检查和生成序列的逻辑分开写。因为如果合在一起的话：// 求正数序列 public static IEnumerable<int> GeneratePositiveNumber(int first) { // 参数检查 if (first <= 0) { throw new ArgumentException("first must be postive", nameof(first)); } // 序列生成 var num = first; while (num <= int.MaxValue) { yield return num; num++; } }如果按照上面这么写的话，当进行var nums = GeneratePositiveNumber(-11);的时候，并不会抛出异常，只有当真正使用到nums里面的数值的时候，才会抛异常，这样很不合理而且难以诊断错误。因为编译器固定如此，无法修改，但我们可以把检查和生成分成两段函数来规避这个问题，例如：// 求正数序列 public static IEnumerable<int> GeneratePositiveNumber(int first) { // 参数检查 if (first <= 0) { throw new ArgumentException("first must be postive", nameof(first)); } // 序列生成 return GeneratePositiveNumberImpl(first); } // 序列生成的实现 private static IEnumerable<int> GeneratePositiveNumberImpl(int first) { var num = first; while (num <= int.MaxValue) { yield return num; num++; } }这样的话，调用的时候，在进入到迭代器方法之前就能抛出异常。第30条 优先考虑通过查询语句来编写代码，而不要使用循环语句C#提供了经典的比如for、foreazh、while等循环结构，但他们的功能其实也可以通过查询语句来实现。与采用循环语句所编写的命令式结构相比，查询语句能够更为清晰地表现开发着的意图。比我想要打印一批成绩高于80分的学生的ID和姓名，用循环语句可以写成：// 学生的定义 class Student { public int Id { get; set; } public string Name { get; set; } public int Score { get; set; } }// 假设有100个学生的列表 var students = Enumerable.Repeat(new Student(), 100).ToList(); // 生成存放结果的列表 var idAndNameTextList = new List<string>(); // 逐个保存学生的ID和姓名 for (var i = 0; i < students.Count; i++) { var student = students[i]; if (student.Score >= 80) { idAndNameTextList.Add($"{students[i].Id}: {students[i].Name}"); } } // 逐个打印学生的ID和姓名 foreach (var text in idAndNameTextList) { Console.WriteLine(text); }可以发现这种写法更注重的是操作的方式而非操作的意图，上面这个例子因为简单所以还比较好能够一眼看出是在干什么，但是复杂的情况下可能就比较麻烦。而查询语句的写法如下，一句话解决：// 挑选出学生中分数大于80分的，合并ID和姓名后把每个都打印出来 // ToList()是因为微软没给IEnumerable<T>提供ForEach的扩展方法，我不懂为什么，不过不少工具库里都会提供 students.Where(s => s.Score >= 80).Select(s => $"{s.Id}: {s.Name}").ToList().ForEach(t => Console.WriteLine(t));可以看到这种写法的可读性很好，语义清晰。而且LINQ还提供了比如Max()、Min()、OrderBy()这些方法，可以大幅简化原来使用循环语句的写法（而且我觉的这样的链式调用真的很爽）。第31条 把针对序列的API设计得更加易于拼接::aru:confuse:: 感觉这几条其实都是在讲差不多相同的事情。这一条依然是说，如果我们需要对一个集合进行多个操作，那么我们一般就需要多次循环遍历集合，并且每次都要生成一个新的集合对象作为中间结果。这样不仅会浪费内存空间和CPU时间，还会使得代码变得冗长和难以维护。所以我们就利用yield return返回IEnumerable<T>。而这样的另一个好处就是可以把处理方法变成类似流水线一样的结构：// 筛选为正数的元素 public static IEnumerable<int> Positive(this IEnumerable<int> source) { foreach (var element in source) { // 如果是正数就使用yield return语句返回元素 if (element > 0) { yield return element; } } } // 将元素数值翻倍 public static IEnumerable<int> Double(this IEnumerable<int> source) { foreach (var element in source) { // 使用yield return语句返回元素乘以2的结果 yield return element * 2; } }var nums = new int[] { -1, 2, -3, 4, 5 }; // 会输出4,8,10 foreach (var num in nums.Positive().Double()) { Console.WriteLine(num); }上面代码的例子简单地实现了将一个数组先筛选出其中的正数，再将数值翻倍的操作。因为输出还是IEnumerable<int>，因此后续完全还可以再加上更多额外的处理方法。这样设计的方法既容易拆解复用，又可以首尾相连拼接起来实现复杂的处理（LINQ就是这样啦）。第32条 将迭代逻辑与操作、谓词及函数解耦如果我们把迭代和数据操作写到一起，那么后续如果想要实现其他的操作就比较麻烦，最好的办法是将操作作为参数传入，也就是通过委托把我们写得操作方法传入给迭代逻辑。.NET自带了三种委托的包装：namespace System { public delegate void Action<T>(T obj); public delegate bool Predicate<T>(T obj); public delegate TResult Func<T, TResult>(T arg); }而LINQ就是通过传入这些委托来实现各种操作，比如上一条中我们举的例子，如果改用LINQ：var nums = new int[] { -1, 2, -3, 4, 5 }; // 会输出4,8,10 foreach (var num in nums.Where(n => n > 0).Select(n => n * 2)) { Console.WriteLine(num); }Where()用于筛选，因此我们传入n => n > 0这个lambda表达式（编译器会自动帮我们转换成Func<bool, int>），可以得到筛选正数的序列。Select()相当于投影、映射，我们传入n => n * 2（编译器会自动帮我们转换成Func<int, int>）就可以得到将序列元素分别乘2的结果序列。下次如果要改成是获得数字的字符串形式，那就改成往Select()里传入n => n => n.ToString()（Func<int, string>）就好了。我们编写迭代操作代码的时候，也最好像这样把迭代和操作逻辑解耦，更加灵活，也更加清晰。第33条 等真正用到序列中的元素再去生成我觉得这条是凑数的……其实就是第29条说的，优先考虑提供迭代器方法，而不要直接返回集合。通过yield return，就是用到的时候逐步生成元素，对提高性能和减少内存使用都有帮助。第34条 考虑通过函数参数来放松耦合关系说的和第32条类似，也就是考虑把操作通过委托传进来，实现解耦。如果直接把操作逻辑写死，那么耦合性太强，没法复用；或者提供接口也可以交由用户去自定义操作，但是这样相对起来实现麻烦，而且继承接口更倾向于是实现一个承诺（比如我的类实现了IEquatable<T>接口则是承诺我这个类可以进行相等判断）。至于像集合的RemoveAll()方法，我只是想操作删除掉某些特定的元素而已，我只要把判断方法作为参数传入即可，这样最灵活。但这样的不足之处就是开发工具能提供的帮助会较少，而且我们得自行判断传入参数的合法性等问题，例如判断是否为null，因此需要开发者自行保证这种灵活设计能够正常运作。第35条 绝对不要重载扩展方法这里的重载指的是在不同命令空间中实现同一个类的的相同签名的扩展方法，比如有一个Person类，我想输出名字信息，我可能在ConsoleExtensions命名空间里实现了Format()扩展方法，将名字信息输出到控制台。而后来我又想输出成xml格式，我就在XmlExtensions命名空间里实现了另一个Format()扩展方法，通过切换引入的命名空间来决定引用的是哪个扩展方法。这样是绝对错误的！因此太容易因为不小心导致引入错了命名空间，导致使用了错的扩展方法，而且还很可能看不出来。因此完全可以将这两个方法放在同一个命名空间同一个类夏，然后改用不同的名字就好，比如FormatAsText()和FormatAsXml()，这样又能保证不会出错，看上去也更加清晰，一眼就知道输出的格式。另外作者还认为这样的输出功能根本就不是对Person类的扩展，因为输出什么格式是外部决定的而不是Person类自己决定的。而扩展方法应该是实现从道理上讲应该是类型自己的一部分的功能，所以这个输出方法不应该作为Person类的扩展方法实现。 ::aru:diving:: 但我个人觉得没啥道理……如果换我实现的话肯定会倾向于将两个方法实现成名字不同的扩展方法，就像微软提供的HttpContent类，自身就带了方法ReadAsStringAsync()，同时也还实现了一个扩展方法ReadAsJsonAsync()，这不是就跟作者说的冲突了吗。所以我觉得主要还是要避免不同命名空间实现相同签名的扩展方法。第36条 理解查询表达式与方法调用之间的映射关系待续……
前言为了提升一下自己的姿势水平，买了 Effective C# 和 More Effective C# 两本书，想一边阅读一边强迫自己做点笔记（主要是自己的理解），不然怕是看了就忘。另外手头还有一本《深入解析C# ( C# in Depth )》其实我已经阅读了一遍，但感觉没咋读懂，下次有机会也用这种方式重读一遍。之前为了找实习（今年真难啊），花了很多时间在了刷LeetCode和背八股文上。现在离去实习也没多久了，啥也不会过去不太好，所以后续应该也要花相当时间在学Java上。今天这篇是 Effective C# 的第三章。第三章 合理地运用泛型泛型还是一个比较方便和重要的特性。Java的泛型基于类型擦除，也就是编译器会保证类型安全，但实际运行时类型都被擦除了，所有不同List<T>都是同一个类型List（个人的理解，有错误请务必指出）。而C#的泛型则是真正的泛型类型，比如通过typeof(List<int>)可以看到其运行时类型System.Collections.Generic.List`1[System.Int32]。而这些泛型类只有在运行时碰到了，CLR才会将其实例化，可以避免不必要的开销。另外值类型和引用类型分别作为类型参数的泛型类也不相同。泛型参数包含值类型时，CLR在JIT时都会生成单独特化的代码，不同泛型类之间不共享代码。这样能够防止值类型的装箱和拆箱问题。而对于引用类型，CLR生成的不同泛型类的代码是共享的，类型安全通过编译器在编译时保证（但我觉得即使生成的代码相同，也应该还另外保存了类型信息，因为typeof()是可以看到运行时泛型类中的泛型参数究竟是什么引用类型的，这一块书上没说），这样可以缩减程序尺寸和提升性能。第18条 只定义刚好够用的约束条件给泛型的类型参数指定约束条件，这样只有满足特定条件的类型才能作为该泛型的参数。约束条件具体都包括哪些，请参考文档 类型参数的约束（C# 编程指南）（中文版在表格的倒数第2-5行有显示错误，英文版没问题，分别应该是基类名和接口名）。书中主要是约束条件限制得太宽，那就很难使用，因为缺少信息，很多操作不能使用，比如public static bool AreEqual<T>(T left, T right) where T : IEquatable<T> { return left.Equals(right) }如果设定了IEquatable<T>的约束条件，那么在内部对于T类型的变量就可以使用IEquatable<T>.Equals()，否则就只能调用Object.Equals()。同样，如果设置得过严，那就失去了泛型的优势，比如：// 我想的一个例子，实际上不太合理，但用来说明应该足够了 static void Print<T>(T list) where T: IList<int> { foreach (var item in list) { Console.WriteLine(item + 1); } }HashSet<int>对象按理说应该也能完成上面循环输出的操作，但实际上却无法使用，因为HashSet<int>并没有实现IList<int>接口。而foreach操作只要实现了IEnumerable<T>就能使用，因此把IList<int>约束换为IEnumerable<int>更加合理。另外书中提醒还有new()约束也要注意使用，因为有的时候代码中的new()可以用default()替代。其中对于值类型，new()返回的就是值类型的默认值，所以和default()没区别（注意C# 10开始支持自定义struct的无参构造函数，对于这样的struct，new()和default()就不同了！）；对于引用类型，默认值都是null，显然new()和default()和差异很大。总的来说，泛型就是为了在各种场合能够广泛使用，所以要仔细考虑约束条件。约束条件太宽，有很多特性难以使用；约束条件太严，使用场合就受限。因此要仔细考虑每一条约束条件，只保留必要的条件。第19条 通过运行期类型检查实现特定的泛型算法放宽类型参数的限制（比如让输入参数的类型为接口，而非一个具体类型），可以让程序更加通用和泛化，但是这又会导致实现的时候无法使用一些具体类型才拥有的特性。因此可以通过运行期类型检查来检测参数的运行时类型，如果是一些特定类型则使用其的某些特性，以此达到提升程序性能的目的。书中举了个作者自己写的例子，但我觉得直接看List<T>的源码就好，List<T>有一个参数为IEnumerable<T>类型的构造函数：public List(IEnumerable<T> collection) { if (collection == null) ThrowHelper.ThrowArgumentNullException(ExceptionArgument.collection); // 运行时类型检查 if (collection is ICollection<T> c) { int count = c.Count; if (count == 0) { _items = s_emptyArray; } else { _items = new T[count]; c.CopyTo(_items, 0); _size = count; } } else { _items = s_emptyArray; using (IEnumerator<T> en = collection!.GetEnumerator()) { while (en.MoveNext()) { Add(en.Current); } } } }因为输入泛型参数为IEnumerable<T>，那么只能使用IEnumerable<T>的特性，从源中逐个访问元素然后添加进新的List<T>中。由于不知道源中的元素总数到底为多少，也就不能提前开辟好空间，逐个添加的时候会触发List<T>的自动扩容机制，相对性能就较差。而不少容器其实在实现了IEnumerator<T>的同时还实现了ICollection<T>（其实ICollection<T>本来就继承于IEnumerator<T>），ICollection<T>接口提供了Count和CopyTo()。因此如果输入参数实现了ICollection<T>，那直接先开辟Count大小的空间，然后利用CopyTo()把源元素都复制进新的List<T>中就好了。因此我们把List<T>构造函数输入参数的类型设为IEnumerator<T>，这样只要实现了IEnumerator<T>，就可以用来创造一个新List<T>对象。同时在构造函数中，还会做额外的运行时类型检查，如果输入参数还实现了ICollection<T>，那么就可以使用更高效的算法，兼顾了通用和性能。（其实感觉这条有点跑题，确实这条建议很有用，但是跟泛型没啥关系。）第20条 通过IComparable及IComparer定义顺序关系如果想把某个类型的对象放入集合以执行搜索和排序，那么需要将这些对象之间的关系定义出来。这里主要用到两个接口，一个是IComparable<T>和IComparer<T>。前者规定了该类型的对象之间的自然顺序关系，后者是用来表示一种排序机制（比较器）。IComparable<T>只有一个方法CompareTo()，如果本对象小于作为参数的对象，那么返回小于0的值（一般是-1），相等返回0，大于返回大于0的值（一般是1）。另外还有不带泛型的老版本IComparable，为了兼容性最好也实现，不过要加以限制（默认应该调用泛型版本），并且进行比较的时候要进行类型转换。举个例子：public class Student : IComparable<Student>, IComparable { public int Id { get; set; } public string Name { get; set; } // 实现IComparable<Student> public int CompareTo(Student other) { // 利用Id进行比较（使用默认的int.CompareTo()） return Id.CompareTo(other.Id); } // 实现IComparable // 只有当Student转型成IComparable时，调用的CompareTo()才会是这个版本 // 限制的原因是为了防止其他开发者误用，避免拆箱装箱等 int IComparable.CompareTo(object obj) { // 要做类型检查 if (obj is Student other) { return CompareTo(other); } throw new ArgumentException(); } }另外还可以重载关系运算符>, >=, <, <=，在运算符内部调用泛型版本的CompareTo()即可（我觉得不重载运算符也行吧，像string也有CompareTo()但是并不支持关系运算符）。实现了IComparable<T>和IComparer<T>，就为类型实现了默认的排序方式。但有时候可能需要新的排序方式，或者给一些本身并没有提供比较功能的类型排序，这时我们需要实现了IComparer<T>的排序器。比如要根据上面Student的Name排序，我们需要：var list = new List<Student>() { new Student() { Id = 2, Name = "1" }, new Student() { Id = 1, Name = "2" } }; // 经典写法，麻烦 public class StudentComparer : IComparer<Student> { public int Compare(Student x, Student y) { return x.Name.CompareTo(y.Name); } } list.Sort(new StudentComparer()); // 用Comparer<T>.Create(Comparison<T>)创造比较器 // 参数是委托Comparison<T>，输入两个参数x和y，返回小于0时代表x<y list.Sort(Comparer<Student>.Create((x, y) => x.Name.CompareTo(y.Name))); // 而且像List<T>.Sort()支持直接用Comparison<T>做参数，不用创造比较器 list.Sort((x, y) => x.Name.CompareTo(y.Name));另外设计排序比较时没有必要重载Equals()和==，因为确定先后顺序和判断是否相等是不同的操作。即使CompareTo()结果为0，Equals()也可能是false，因为那也只是代表可能作为排序标准的那个属性相等，而不代表两个对象就相等了。第21条 创建泛型类时，总是应该给实现了IDisposable的类型参数提供支持一般来说，给泛型添加约束，就能使用该条件所拥有的一些特性，比如如果约束了类型必须实现IEnumerable<T>，那么就能对其进行foreach等操作。而有些时候我们追求泛用性，不使用某些约束，自然也就不能使用相关的特性，也就无需关心输入的类型参数是不是具有这些特性了。但IDisposable不同，如果泛型类的类型参数没有对其做约束，那么可能会有如下情况：public interface IEngine { void DoWork(); } public class EngineDriver1<T> where T : IEngine, new() { public void GetSomethingDone() { var driver = new T(); driver.DoWork(); } }当T是实现了IDisposable的类型的时候，因为没有释放操作，所以有可能会造成内存泄露的问题。因此如果需要创建T类型的局部变量，就应该对其是否实现了IDisposable接口做检查：public void GetSomethingDone() { // 先实例化变量 var driver = new T(); // 在using语句里面做类型转换 using (driver as IDisposable) { // 不管using的是什么，都会正常执行这条语句 driver.DoWork(); } }这个方法还是挺巧妙的，以前应该是没见过这种操作。原理就是先实例化一个T变量，然后在using语句里将其类型转换至IDisposable。如果T没有实现IDisposable，那么using语句中的就是null，编译器就不会调用Dispose()；如果实现了IDisposable，那么using语句中的就是driver向上转型为IDisposable的临时变量，离开using范围的时候，就会调用这个变量的Dispose()来释放资源（本质上这个临时变量指向的对象跟driver指向的相同，调用的也是其方法）。而在using内部，因为调用的是driver，所以不管using语句中的临时变量是什么，都能正常执行。相当于：public void GetSomethingDone() { var driver = new T(); var tmp = driver as IDisposable; // temp是什么对driver.DoWork()没影响 driver.DoWork(); // driver如果未实现IDisposable，temp就为null，不会执行Dispose() tmp?.Dispose(); }但如果创建的是T类型的成员变量，就会麻烦一些，得让泛型类本身实现IDisposable接口，书中的例子如下：public sealed class EngineDriver2<T> : IDisposable where T : IEngine, new() { // 防止创建driver的代价过高，用了延迟加载 private Lazy<T> driver = new Lazy<T>(() => new T()); public void GetThingsDone() => driver.Value.DoWork(); // 实现IDisposable接口 public void Dispose() { if (driver.IsValueCreated) { var resource = driver.Value as IDisposable; resource?.Dispose(); } } }而且这个例子做了一定的简化，因为声明的是sealed类，意味着无法被继承，也就不用实现第二章第17条建议的标准dispose模式。另外还要再添加标识符防止多次调用Dispose()等等。可以看到如果要让泛型类支持IDisposable相关的还是很麻烦的，因此可以把创建T类型变量的职责放到外部，通过依赖注入的方式来使用该变量，这样也就把调用Dispose()的职责移到了外部：public sealed class EngineDriver<T> where T : IEngine { // 私有T类型的成员变量 private T _driver; // 通过依赖注入获取外部的T类型的变量 public EngineDriver(T driver) => _driver = driver; public void GetThingsDone() => _driver.DoWork(); }这样泛型类的职责相对就小了很多，实现难度也降低了。在实际泛型类的编写过程中，为了其实用性，有必要根据上面几种方法，针对IDisposable的可能情况做好应对。第22条 考虑支持泛型协变和逆变泛型的变体机制，分为协变和逆变，另外还有不变。只有泛型接口和泛型委托支持变体，泛型类和泛型方法都不支持。这个东西理解起来还是很绕的，举个例子来说：IEnumerable<string> a = new List<string>() { "#66ccff" }; IEnumerable b = a;string能隐式转换成object是很自然的事情，但是从IEnumerable<string>隐式转换到IEnumerable 在原来是做不到的，直到C# 4.0支持了泛型协变和逆变之后才可以。像在string能转换成object的情况下，IEnumerable<string>也能转换到IEnumerable 叫做协变（转换是同方向的）；而如果是在string能转换成object的情况下，Action 能转换到Action<string>就叫做逆变（转换是反方向的）。变体需要在泛型接口或委托中提前声明。协变发生在类型参数只用作输出时，所以修饰符为out；逆变发生在类型参数只用作输入时，所以修饰符为in；如果类型参数既做输入也做输出，那么就只能声明为不变了（即默认，无修饰符）。比如：// 协变（T仅作输出） public interface IEnumerable<out T> : IEnumerable { IEnumerator<T> GetEnumerator(); } // 逆变（T仅作输入） public delegate void Action<in T>(T obj); // 不变（T既做输入也做输出） public interface IList<T> : ICollection<T>, IEnumerable<T>, IEnumerable { ... }为什么只用于输出时是协变，而只用于输入时是逆变，既做输入也做输出就只能为不变呢？我继续根据例子讲讲我的理解：// 协变 IEnumerable<string> a = new List<string>() { "#66ccff" }; IEnumerable b = a; // c是object var c = b.First();我们原来就能从IEnumerable<string>中获取string类型对象（通过IEnumerable<string>的输出），而string类型本来就能转换为object类型，那理应IEnumerable<string>的每个输出也能以object类型的方式获取。而IEnumerable<string>的类型参数string恰好也仅用于输出，因此IEnumerable<string>可以协变到IEnumerable 。// 逆变 var a = new Action (Console.WriteLine); Action<string> b = a; // 输入字符串 b("#66ccff");Action 则相反，类型参数仅用于输入。Action 的委托接收的参数为object类型，那string类型的变量自然也可以通过隐式转换成object类型的方式作为该委托的参数。因此Action 可以逆变到Action<string>。// 不变 IList<string> a = new List<string>() { "#66ccff" }; // 尝试协变，但是本句无法通过编译 IList b = a; b.Add(new object());IList<string>因为类型参数仅用于输入也用于输出，因此为了安全考虑被设计成不变。对于输入（比如Add()方法）来说，假如可以实现上面例子中的协变，IList<string>协变到IList ，那么按理说就能往IList 中添加object类型元素。但两个IList引用本质指向的是同一个List<string>对象，上面的操作会导致往List<string>对象中添加了object元素，造成了类型的不安全。// 不变 IList a = new List () { new object() }; // 尝试逆变，但是本句无法通过编译 IList<string> b = a; var element = b[0];这个就更好理解了，对于输出（比如通过索引访问）来说，假如可以实现上面例子中的逆变，IList 逆变到IList<string>，那么通过IList<string>访问得到的岂不是都是string类型的变量？但实际List对象里存储的都是object类型变量，object类型也不能隐式转换到string。另外注意值类型是无法使用变体的，原因就是文章开头所说的，对于一个泛型类来说，当所有类型参数都为引用类型的时候，CLR通过JIT生成的泛型类的代码是复用的；但如果类型参数中有值类型，对于每一个不同的值类型，CLR都将生成单独的代码（这样可以避免装箱拆箱）。这样也就导致比如List<int>转型成IEnumerable<int>之后，没法协变成IEnumerable ，因为CLR生成的List<int>和List 的代码并不共享。单个类型参数最多只能有一个修饰符，如果有多个类型参数的话则可以各自有不同的修饰符，比如：public delegate TResult Func<in T, out TResult>(T arg);只要类型参数和其修饰符符合上述规则就行。另外还有当协变和逆变结合的时候，这个就更绕了，注意不要判断错误了，比如有如下关系：// 支持逆变 interface IA<in T> { } class A<T> : IA<T> { } // 支持协变 interface IB<out T> { // 好像类型参数明明只做了输入而不是输出？ void Add(IA<T> foo); } class ListA<T> : IB<T> { // 一个List用于存IA<T>对象 private List<IA<T>> _list = new List<IA<T>>(); // 往List里添加IA<T>元素 public void Add(IA<T> a) { _list.Add(a); } }可以看到接口IB明明是对T的协变，按理说应该是用作输出，但是在其方法里好像是作为输入参数的，为什么会这样呢？// 协变 IB b = new ListA<string>(); IA a = new A (); // 其实这里a发生了逆变 b.Add(a);ListA<string>对象，其内部的_list变量是List<IA<string>>类型的，Add()方法的参数也只能是IA<string>类型。但由于其接口IB<string>支持协变，因此可以协变为IB 。前面举过Add()方法的例子，原来存储的是IA<string>类型，这样协变完以后，岂不是可以添加IA 类型的元素，不就类型不安全了吗？但是IA 支持逆变，因此添加IA 元素也可以视作添加的是IA<string>元素，这样就保证了类型安全。第23条 用委托要求类型参数必须提供某种方法C#里泛型的约束就那么几种，有些时候要求不太好用这几种约束表达出来，比如要求类型参数必须要具有某种方法，或者必须提供某种形式的构造函数。其实先通过接口把要求列出来，然后设置必须实现该接口的约束也可以达到目的，但相对比较复杂，尤其是对于那些使用我们编写的泛型的开发者，这样可能导致他们嫌麻烦而不愿意去用。比如我们需要让我们的泛型类支持相加，因此要求类型参数也必须能够支持相加，比如具有Add()方法。我们可以先写一个IAdd接口，要求提供Add()方法，然后约束泛型类的类型参数必须实现该接口。这样实现就比较麻烦，而且其他开发者得改写自己的类实现IAdd接口才能使用我们的泛型类。而实际上我们可以用委托来实现要求类型参数必须提供该方法。比如：public class Adder<T> { public T Val { get; set; } public Adder(T val) { Val = val; } // 把两个Adder的Val相加，得到一个新Adder // 具体两个T的相加是怎么实现的，交由开发者自己把委托传进来 public Adder<T> Add(Adder<T> other, Func<T, T, T> addFunc) { return new Adder<T>(addFunc(Val, other.Val)); } }这样我们就不用约束泛型类需要实现某个接口，而是让开发者自己把泛型类需要的方法通过委托传进来。var adder1 = new Adder<int>(1); var adder2 = new Adder<int>(2); // 具体两个参数如何相加靠开发者自由实现 // 我这里通过lambda表达式写了个最简单的实现 adder1.Add(adder2, (x, y) => x + y);再比如实现一个能够自动转换类型的容器（虽然好像没啥意义）：public class TestList<T1, T2> { private readonly List<T2> _list = new List<T2>(); private readonly Func<T1, T2> _convertFunc; // 交由其他开发者实现转换的细节 public TestList(Func<T1, T2> convertFunc) { _convertFunc = convertFunc; } // 输出T2类型的元素 public T2 this[int index] => _list[index]; // 输入T1类型的元素，转换成T2类型存储 public void Add(T1 item) { _list.Add(_convertFunc(item)); } }// 传入转换的具体实现 var list = new TestList<string, int>(int.Parse); list.Add("0"); int element = list[0];第24条 如果有泛型版本，就不要再创建针对基类或接口的重载版本当同时有泛型版本和普通重载版本的时候，编译器自动匹配的并调用的版本可能并不是我们想要的。举个例子：// 父类 class Shape { } // 接口 interface IColor { } // 子类 class Circle : Shape, IColor { }然后有泛型和重载方法：// 泛型 void Print<T>(T t) { Console.WriteLine("Generic"); } // 重载（父类） void Print(Shape shape) { Console.WriteLine("Shape"); } // 重载（接口） void Print(IColor color) { Console.WriteLine("IColor"); }实际调用时，编译器自动匹配的版本是：// 子类实例 var circle = new Circle(); // 转型到父类 Shape shape = circle; // 转型到接口 IColor color = circle; // 调用的是Print<T>(T t) Print(circle); // 调用的是Print(Shape shape) Print(shape); // 调用的是Print(IColor color) Print(color);根据我的理解，简单来说就是，如果有参数类型与输入类型完全匹配的普通重载版本，那么就优先调用普通重载版本，否则调用泛型版本。就像上面例子中，按理说circle也能作为Print(Shape shape)和Print(IColor color)的输入参数，但需要经过一次隐式转换；而对于Print<Circle>(Circle t)，它则是完全匹配的，因此优先调用泛型方法。但如果转型到父类Shape或者接口IColor，因为本身就有实现这两种类型的重载版本，所以会优先调用普通重载版本的方法。所以一般来说，在已经有了泛型版本的前提之下，即使想要给某个类及其子类（或者接口）提供特殊的支持，也不应该轻易去创建专门针对该类的重载版本。不过数字类型没有这个问题，因为数字类型（int，double等）之间没有继承关系。不过如果不想通过重载的方式，也可以依靠运行时类型检查来实现为特定类或接口实现特定功能：void Print<T>(T t) { if (t is Shape) { Console.WriteLine("Shape"); } else if (t is IColor) { Console.WriteLine("IColor"); } else { Console.WriteLine("Generic"); } }不过这样会有类型检查的性能开销，只有你确定你的特定实现更为合适的时候才该这样做。比如前面第19条List<T>的中输入参数为IEnumerable<T>的构造函数，特别判断了输入是不是也实现了ICollection<T>，如果是的话就能调用更高效的算法。当然也不是说绝对不应该专门针对某些类型去创建更为具体的方法。如果能实现更好更高效的算法，那也是值得的。所以如果你想针对某个类型创建与已有的泛型方法相互重载的方法，那么就必须为该类的所有子类型也分别创建对应的方法（否则就会调用泛型版本的）。接口也同理，必须为所有实现了该接口的类型实现对应的方法。第25条 如果不需要把类型参数所表示的对象设置为实例字段，那么应该优先考虑创建泛型方法，而不是泛型类简单来说就是如果没必要的话就不要创建泛型类了，而是应该写成有泛型方法的非泛型类。因为写成泛型类会导致使用不同的类型参数时，每个都会在JIT时生成整套泛型类的代码，即使当中很多方法可能从来就没用上，而泛型方法则只针对单个方法生成。另外如果是泛型类，那么内部用到的泛型都得满足泛型类上的约束，而泛型方法每个可以单独定义其约束。而且使用泛型方法，编译器也能更容易匹配到所使用的方法。举个例子：// 泛型类 public static class Utils<T> { public static T Max(T left, T right) => Comparer<T>.Default.Compare(left, right) < 0 ? right : left; public static T Min(T left, T right) => Comparer<T>.Default.Compare(left, right) < 0 ? left : right; }// 能用，但是手动必须指明类型 // 生成了两份Utils<T>，且它们各自都没用到的Min()也还是被生成了 Utils<int>.Max(7, 12); Utils<string>.Max("lty", "#66ccff");改写成有泛型方法的非泛型类：public static class Utils { // 泛型方法 public static T Max<T>(T left, T right) => Comparer<T>.Default.Compare(left, right) < 0 ? right : left; // 而且还可以为一些特定类型设置更高效的重载方法 public static int Max(int left, int right) => Math.Max(left, right); public static T Min<T>(T left, T right) => Comparer<T>.Default.Compare(left, right) < 0 ? left : right; public static int Min(int left, int right) => Math.Min(left, right); }// 编译器会自动推断类型 Utils.Max(7, 12); Utils.Max("lty", "#66ccff");这样就无需开发者调用时手动指出所用类型，而且也能让一些特定类型使用更高效的算法（虽然明明前一节还叫别这么写，不过如果像例子里是数字类型的话也没啥问题）。另外还有一个例子：public class CommaSeparatedListBuilder { private readonly StringBuilder _builder = new StringBuilder(); public void Add<T>(IEnumerable<T> items) { foreach (var item in items) { if (_builder.Length > 0) { _builder.Append(", "); } _builder.Append(item); } } public override string ToString() => _builder.ToString(); }var builder = new CommaSeparatedListBuilder(); builder.Add(new List<int>() { 1, 2 }); builder.Add(new List<string>() { "3", "4" });用泛型方法实现，就可以对该类型的同一对象插入不同类型的元素。而如果使用泛型类的话，开始就要先给CommaSeparatedListBuilder指定一个类型，后续也只能插入该类型元素。不过也不是所有泛型算法都能绕开泛型类而单纯以泛型方法的形式得以实现，不过一般来说只有两种情况必须把类写成泛型类：该类需要将某个值用作其内部状态（成员变量），而该值的类型必须以泛型来表达（比如List<T>就是内部用了T[]来存储元素）。该类需要实现泛型版本的接口。所以，设计API的时候，尽量还是使用泛型方法而非泛型类。首先调用简单，编译器能自动判断。其次对于开发者，可以针对某些特定类型加入更高效的算法，编译器也能够自动匹配到该版本上。第26条 实现泛型接口的同时，还应该实现非泛型接口因为到.NET Framework 2.0才引入泛型，所以有部分非泛型的老代码还在使用。为了兼容性，我们在实现了泛型接口的同时，最好还是把非泛型接口也实现了。不过在实现非泛型接口时需要加以限定，使得默认情况下都会调用泛型接口的方法，只有转型成非泛型接口的时候才会调用对应的非泛型方法。例子可以看第20条的实现IComparable<T>和IComparable接口。第27条 只把必要的契约定义在接口中，把其他功能留给扩展方法去实现在接口里声明方法，那么实现这个接口的类就必须把其实现，如果这样的方法多了的话实现起来就很麻烦。最好是只在接口里定义必要的东西，然后通过扩展方法的形式实现一些扩展（或者说默认实现的）功能。这样既方便了后续类的实现，又提供了一些可以使用的便捷功能。比如IEnumerable<T>的定义非常简单：public interface IEnumerable<out T> : IEnumerable { IEnumerator<T> GetEnumerator(); }但是微软在Enumerable类里为IEnumerable<T>提供了非常多的扩展方法，比如Count():// 扩展方法 public static int Count<TSource>(this IEnumerable<TSource> source) { if (source == null) ThrowHelper.ThrowArgumentNullException(ExceptionArgument.source); if (source is ICollection<TSource> sources) return sources.Count; // 我看源码里挺多Provider接口的，不知道是什么，文档上也找不到 if (source is IIListProvider<TSource> ilistProvider) return ilistProvider.GetCount(false); if (source is ICollection collection) return collection.Count; int num = 0; using (IEnumerator<TSource> enumerator = source.GetEnumerator()) { while (enumerator.MoveNext()) checked { ++num; } } return num; }所以如果我们编写实现IEnumerable<T>的类，我们只需要将接口声明的GetEnumerator()和所需的迭代器实现，接着就可以对该类的实例使用各种IEnumerable<T>的扩展方法，而不需要我们手动实现。不过如果想在类中自己重新实现一个跟扩展方法同名的方法会有点问题。如果是以类的形式调用的话，那么编译器能保证调用的是类中定义的该方法。但如果是以接口的形式调用的话，那么编译器调用的就会是接口的扩展方法了。所以应该要尽量保证类中的同名方法要与原扩展方法行为一致，这样即使可能接口的扩展方法不如类中同名方法高效，但至少能保证结果正确。另外，本书的环境是C# 6.0，而C# 8.0中提供了默认接口方法（有点类似Java的接口的默认方法，但不太一样），可以在接口上提供默认的方法实现，而实现该接口的类不一定要实现该方法，但如果不实现的话无法以类的形式调用。举个例子：public interface IShape { public string Shape { get; set; } public void Print() { Console.WriteLine(Shape); } } public interface IColor { public string Color { get; set; } public void Print() { Console.WriteLine(Color); } } public class BlueCircle : IShape, IColor { public string Shape { get; set; } = "Circle"; public string Color { get; set; } = "Blue"; }var blueCircle = new BlueCircle(); // 无法编译，提示BlueCircle中找不到Print()的定义 blueCircle.Print(); // 输出Circle ((IShape)blueCircle).Print(); // 输出Blue ((IColor)blueCircle).Print();可以看到如果接口有默认方法，那么类可以不实现。但如果不实现，那么就无法以类的形式去调用该方法。而如果类继承了多个接口，且接口中有同名默认方法，那么就以其实际使用的接口类型的默认方法为准。如果类中实现了接口中的默认方法：public class BlueCircle : IShape, IColor { public string Shape { get; set; } = "Circle"; public string Color { get; set; } = "Blue"; public void Print() { Console.WriteLine($"{Color}{Shape}"); } }var blueCircle = new BlueCircle(); // 输出BlueCircle BlueCircle.Print(); // 输出BlueCircle ((IShape)blueCircle).Print(); // 输出BlueCircle ((IColor)blueCircle).Print();可以看到无论是以类的形式还是接口的形式调用该方法，最后全都是调用的类中实现的方法。我的感觉是，C#设计成在类未实现默认接口方法的时候，必须得转换成对应接口来调用默认方法，这样就能防止接口多继承带来的同名方法不知道该调用哪个的问题。然后像现在很多时候都是靠依赖注入和面向接口编程，所以通过接口调用默认方法也很方便（或者说就该用接口调用）。而如果类中实现了接口的默认方法，大概就可以看作覆写，那自然怎么调用都是以类中的实现为准。接口默认方法这个特性其实挺有争议的，不过多一种特性总是好事。第28条 考虑通过扩展方法增强已构造类型的功能就像上一条说的，很多功能我们可以通过扩展方法的形式实现，LINQ也是基于扩展方法实现的，用过的都说好。而尤其是对一些我们并不能修改的类型，比如自带的string、IEnumerable<T>等等，要在基础上加功能显然利用扩展方法会方便很多。说白了扩展方法就是个语法糖，实际调用的还是普通的静态方法，不过写起来很方便，而且可以实现链式调用，我个人很喜欢用这个特性。比如在OpenCC.NET里，每个转换内部其实都是对分好的词组利用不同字典进行多批次的转换：private static IEnumerable<string> ConvertBy(this IEnumerable<string> phrases, params IDictionary<string, string>[] dictionaries) { return phrases.Select(phrase => { ... // 根据字典进行转换 } }这就是给IEnumerable<string>新增的一个扩展方法，同时这个方法返回类型也是IEnumerable<string>，因此可以链式调用。比如将简中转换成繁中（台）并且转换地方词汇：public static string HansToTWWithPhrase(string text) { // 分词 var phrases = ZhSegment.Segment(text); // 链式调用扩展方法 return phrases.ConvertBy(ZhDictionary.STPhrases, ZhDictionary.STCharacters) .ConvertBy(ZhDictionary.TWPhrases) .ConvertBy(ZhDictionary.TWVariants) // Join()也是我写的扩展方法，其实就是调用string.Join()把词组重新合并成句子 .Join(); }如果没法链式调用的话就得写成：return string.Join(string.Empty, ConvertBy(ConvertBy(ConvertBy(phrases, ZhDictionary.STPhrases, ZhDictionary.STCharacters), ZhDictionary.TWPhrases), ZhDictionary.TWVariants));光看看都觉得崩溃。第三章完。
更新OpenCC.NET这两天有点空，把OpenCC.NET重构和优化了一下，版本号也正式来到了1.0。本来是想加入异步API，结果测试发现可能因为本来转换就是查字典速度很快，加上异步的话还需要Task的创建开销，导致性能还不如同步版本。然后尝试把原来用StringBuilder改成栈上的Span<char>，结果性能也没啥区别。不过原来的内部转换的实现复用性不强而且乱，还是决定重写成OpenCC原本那种链式处理。简单来说就是分词完后得到IEnumerable<string>格式的词组，给IEnumerable<string>写一个扩展方法：private static IEnumerable<string> ConvertBy(this IEnumerable<string> phrases, params IDictionary<string, string>[] dictionaries) { return phrases.Select(phrase => { ... // 根据字典进行转换 } }一批词组先用一些字典进行转换，得到中间结果，然后可以再用其他字典继续转换，直到得到结果。这样就可以等效出原OpenCC中的转换链。比如原OpenCC中的s2twp.json（简=>繁台+词汇）：{ "name": "Simplified Chinese to Traditional Chinese (Taiwan standard, with phrases)", "segmentation": { "type": "mmseg", "dict": { "type": "ocd2", "file": "STPhrases.ocd2" } }, "conversion_chain": [{ "dict": { "type": "group", "dicts": [{ "type": "ocd2", "file": "STPhrases.ocd2" }, { "type": "ocd2", "file": "STCharacters.ocd2" }] } }, { "dict": { "type": "ocd2", "file": "TWPhrases.ocd2" } }, { "dict": { "type": "ocd2", "file": "TWVariants.ocd2" } }] }根据conversion_chain部分，利用ConvertBy()可以等效出：public static string HansToTWWithPhrase(string text) { // 分词 var phrases = ZhSegment.Segment(text); // 链式转换 return phrases.ConvertBy(ZhDictionary.STPhrases, ZhDictionary.STCharacters) .ConvertBy(ZhDictionary.TWPhrases) .ConvertBy(ZhDictionary.TWVariants) // Join()是我写的扩展方法，其实就是调用string.Join()把词组重新合并成句子 .Join() }于是我就把原来的API全部重写成这种转换链的形式了，另外还加了日语汉字新旧字体转换的API（因为原OpenCC带了相应的字典，干脆也就实现了），虽然感觉也就图一乐用。顺带做了一下性能测试。程序的第一次转换，就算是转换空文本都要差不多花费500ms的时间，后续就不用了，可能是因为要加载Jieba分词的模型。然后测试了一本15.5M大小的网络小说，共14万行，同步的话大概要总共花费9s；改用异步方式，每行都Task.Run()调用转换方法然后await，可以缩短到4.5s左右。另外还尝试了一下GitHub上的Action，用了别人写好的脚本，可以自动把最新的工程构建和发布到Nuget上，很是方便。Nuget打包的一个坑先说结论，我在Visual Studio 2022 17.1.0上设置C#项目自动生成Nuget包时，如果在ItemGroup的Content上配置了PackageCopyToOutput属性，且同时又配置了PackagePath属性，那么PackageCopyToOutput会被无效。OpenCC.NET是我第一次打Nuget包，所以啥也不懂。其实打包都是自动化的，只要在工程配置文件里设置一下库的信息比如名字版本号啥的就好。但问题是这个库需要用到OpenCC字典和Jieba.NET的资源文件，所以按理说应该也要打进Nuget包里，然后其他程序如果引用了这个包，编译的时候会自动把这些文件复制到输出目录。一开始我实在是不知道咋操作，所以只能在readme上注明需要手动下载这两个文件夹放到程序目录。一开始我的配置类似：<ItemGroup> <Content Include="Dictionary\*.txt"> <Pack>True</Pack> <PackagePath>content\Dictionary\</PackagePath> </Content> <Content Include="JiebaResource\*.*"> <Pack>True</Pack> <PackagePath>content\JiebaResource\</PackagePath> </Content> </ItemGroup>文件确实是打进包里了，可是还是无法输出。困扰了我很久，我后面试过什么CopyToOutputDirectory属性之类的，都无效，只能放弃，然后在readme上写需要手工将两个文件夹下载放入程序目录下。这次我又重新经过一番搜索，发现是我想要的这个功能原来是靠PackageCopyToOutput属性实现的，于是我尝试：<ItemGroup> <Content Include="Dictionary\*.txt"> <Pack>True</Pack> <PackageCopyToOutput>True</PackageCopyToOutput> </Content> <Content Include="JiebaResource\*.*"> <Pack>True</Pack> <PackageCopyToOutput>True</PackageCopyToOutput> </Content> </ItemGroup>发现终于能输出了！但是生成的.nupkg却大了一倍，打开包结构一看，文件夹给我放进去一模一样的两份。一顿搜索之后发现官方文档上确实是介绍了这一点默认情况下，所有内容都添加到包中 content 和 contentFiles\any\<target_framework> 文件夹根目录，并保留相对文件夹结构，除非指定包路径但这样不是白白浪费了空间，于是我就在上面的基础上，像最开始一样添加PackagePath属性：<ItemGroup> <Content Include="Dictionary\*.txt"> <Pack>True</Pack> <PackageCopyToOutput>True</PackageCopyToOutput> <PackagePath>content\Dictionary\</PackagePath> </Content> <Content Include="JiebaResource\*.*"> <Pack>True</Pack> <PackageCopyToOutput>True</PackageCopyToOutput> <PackagePath>content\JiebaResource\</PackagePath> </Content> </ItemGroup>这次确实只打包一份资源文件了。但搞笑的是又不能自动把两个文件夹复制到程序输出目录了。所以最后我还是只能选择去掉指定路径，让它自动给我打包两份资源文件上去。虽然大小成了两倍，但至少能用不是嘛。只能说属实是有点坑了。
前言为了提升一下自己的姿势水平，买了 Effective C# 和 More Effective C# 两本书，想一边阅读一边强迫自己做点笔记（主要是自己的理解），不然怕是看了就忘。另外手头还有一本《深入解析C# ( C# in Depth )》其实我已经阅读了一遍，但感觉没咋读懂，下次有机会也用这种方式重读一遍。这半个月要回家过年，得等年后才能继续更新了。今天这篇是 Effective C# 的第二章。第二章 .NET的资源管理第11条 理解并善用.NET的资源管理机制简单来说.Net提供了垃圾回收器GC，帮助控制托管内存，让开发者不用担心内存泄漏等问题。GC在运行时会判断不再使用的对象为垃圾，将其回收，并压缩托管堆，把剩余的活动对象转移到连续的内存区域上。而非托管资源回收需要由开发者来管理控制。NET中提供释放非托管资源的方式主要是：finalizer（终结器）和IDisposeable接口。finalizer在C#中和C++类似，是通过Destructor()析构函数实现的，实际函数名为类名前面加上一个~，比如：class UnManagedClass { ~UnManagedClass() { Console.WriteLine("被释放了"); } }但这种方式适用于C++，并不适用于C#，因为GC回收得并不及时，只能保证finalizer会被执行，但不确定何时执行。而且当GC回收垃圾时，发现该对象需要运行finalizer，就不得不先挂起回收操作来运行finalizer，等到下一次才能把其回收掉。同时GC还有代（generation）的概念，分为第0，1，2代，每运行完一次GC，剩余留在内存里的对象就会长一代（用来区分短期变量和长期变量，短期变量更有可能是垃圾），GC对于越后面的代，检测的次数越少，这会导致拥有finalizer的对象会长时间占在内存里得不到回收。因此释放资源首选实现IDisposeable接口，然后主动调用Dispose()方法来释放资源。// 定义了class UnManagedClass : IDisposeable // 在finally中调用Dispose()确保资源被释放 var unManaged = new UnManagedClass(); try { // 操作unManaged } finally { unManaged.Dispose(); } // 更通常的情况是使用using using (var unManaged = new UnManagedClass()) { // 操作unManaged }//到此，using域结束，unManaged自动释放注：C# 8.0提供了新的using声明语法，不需要原来的大括号了，在using的变量的生命周期结束后将自动释放。public void DoSomeWork() { using var unManaged = new UnManagedClass() // 操作unManaged }// 到此，unManaged生命周期结束，自动释放详细的IDisposeable接口实现将在第17条讲述。第12条 声明字段时，尽量直接为其设定初始值如果都把字段的初始值设定放在构造函数里做的话，在构造函数多了之后可能就会忘记给部分字段设定初始值，所以不如在声明字段的时候就直接初始化。但书中也给了三种不应该使用初始化语句的情况：第一种：把对象初始化成0和null。因为默认的初始化逻辑就是把对象初始化成0或null，自己手动再度设0和null没有意义。第二种：构造函数都各自以不同方式初始化了字段。public class MyClass { private List<string> _labels = new List<string>(); public MyClass() { } public MyClass(int size) { _labels = new List<string>(size); } } // 就类似于 public class MyClass { private List<string> _labels; public MyClass() { _labels = new List<string>(); } public MyClass(int size) { // 白白创建了一个List然后舍弃 _labels = new List<string>(); _labels = new List<string>(size); } }不过我觉得书本这里逻辑也有点问题，本来前面就是说为了防止因构造函数太多导致漏掉字段赋初值，所以要在声明时初始化。结果这里又说如果构造函数都进行了初始化，那么就不要在声明的时候初始化了，那万一漏掉了怎么办，有点矛盾。还是老老实实、规规矩矩检查代码最重要。第三种：初始化变量的时候可能抛出异常。因为在声明的时候初始化是没有办法使用try-catch语句的，所以碰到这种情况应该把初始化过程放到构造函数里进行。第13条 用适当的方式初始化类中的静态成员创建某类型的实例之前，应先初始化该类型的静态成员，这个工作交由静态构造函数进行（当然简单的初始化直接用初始化语句就可以了）。在初次访问这个类的方法、成员之前会执行静态构造函数。静态构造函数最常见的用途是实现单例模式（单例模式的介绍、实现）。所以给类中的静态成员设置初始值，简单的话直接用初始化语句，复杂的话，比如会抛出异常要做处理的，那么就使用静态构造函数。第14条 尽量删减重复的初始化逻辑有些时候需要实现各种输入参数的构造函数，里面有很大一部分逻辑是相同的，为了图方便有的人可能就直接把相同代码复制粘贴（书里还特别写了：笔者觉得你应该不是这种人吧）。正确的写法应该是把重复的逻辑放到一个共同的构造函数中，然后其他构造函数来调用该构造函数。这样写能减少重复代码，而且编译器也不会因为这种写法而反复调用基类的构造函数。下面是示例class Student { public string Name { get; set; } public int Age { get; set; } // 用:修饰符调用其他的构造函数 public Student() : this(string.Empty, 18) { } public Student(int age) : this(string.Empty, age) { } // 相同的逻辑放到一个构造函数里 public Student(string name, int age) { Name = name; Age = age; } }看起来似乎跟把相同的初始化逻辑都放到一个单独的方法里，然后所有构造函数都调用这个方法的方式也没有什么不同？实际上这样做的效率要比链式调用构造函数低。因为这样的话，编译器无法合并构造函数中的相同操作，不得不在每个构造函数里都调用基类构造函数，然后执行这个共同用于初始化的方法。而且像readonly变量只能通过初始化语句或者构造函数进行初始化，无法在其他方法中赋值，因此要么选择链式调用初始化，要么只能在不同构造函数里分别初始化。另外，除了重载，也可以利用默认参数实现：class Student { public string Name { get; set; } public int Age { get; set; } // 默认值必须为编译时常量，用string.Empty会报错 public Student(string name = "", int age = 18) { Name = name; Age = age; } }使用默认参数的写法较为简洁，也会让用户使用起来更方便。如果参数很多，那么用重载就需要编写大量不同版本的构造函数。因此比较推荐使用默认参数来实现构造函数。但是有部分问题需要注意，首先是比如带有new()约束的泛型类或泛型方法需要的是真正的无参构造函数，如果只有所有参数都有默认值的构造函数也是不满足条件的，因为实际上实现的还是有参构造函数。所以如果开发者允许用户通过new()来调用构造函数，那么即使有所有参数都有默认值的构造函数，也应该实现一个真正的无参构造函数，让new()这种调用方法在所有场合都是可以使用的。class Student { public string Name { get; set; } public int Age { get; set; } // 无参构造函数 public Student() : this("", 18) { } public Student(string name = "", int age = 18) { Name = name; Age = age; } }其次是方法的默认值作为编译时常量，有点类似第一章提到的const的行为，在编译时就会用实际值替换写死在程序中。如果调用的是外部的带有默认值参数的方法，某天被调用的该方法更新了默认值，但调用的程序没有重新编译，那么调用时仍然会使用旧的默认值，类似下面这样// Student类来自于其他库 var student = new Student(); // 实际编译和调用时类似于 var student = new Student("",18); // 某天该库更新了版本，把Student构造函数中name的默认值改为了"unknown" public Student(string name = "unknown", int age = 18) // 但我们的程序未重新编译 var student = new Student(); // 实际运行时调用的还是 var student = new Student("", 18);不过在网上看到一个解决方法，用0或null作为默认行为的哨兵值。当被调用方法检测到参数为null或0时，就知道该使用默认参数，于是在方法中替换为真正的默认值。这样就能避免上面外部库中方法默认值更新带来的问题。class Student { public string Name { get; set; } public int Age { get; set; } public Student() : this(null, 0) { } // null和0作为哨兵值 public Student(string name = null, int age = 0) { // 如果传入的是默认值（哨兵值），就替换为真正的默认值 Name = name ?? "unknown"; Age = age == 0 ? 18 : age; } }回顾一下C#中对象的初始化工作的整个顺序，当构建某个类型的第一个实例的时候：把存放静态变量的空间清零执行静态变量的初始化语句执行基类的静态构造函数执行（本类的）的静态构造函数把存放实例变量的空间清零执行实例变量的初始化语句适当地执行基类的实例构造函数执行（本类的）实例构造函数之后再次构建该类型的其他实例，会直接从第5步开始执行。另外，可以通过链式调用构造函数来优化第6、7步。总的来说，C#的编译器一定会保证变量得到了某种初始化，至少是其使用的内存已经清空了。因此开发者需要做的就是保证变量得到初始化且只进行一次初始化。简单的初始化直接用初始化语句即可，复杂的初始化用构造函数实现，并且使用链式调用构造函数的方式来简化代码。第15条 不要创建无谓的对象在堆上创建和销毁对象都是需要时间的，创建过多无谓的对象会大幅度降低性能。防止频繁创建局部对象有几个技巧。第一个，如果在频繁调用的方法中需要创建同一个类型的对象，请考虑把它从局部变量改为成员变量，以实现复用。只有调用相当频繁的时候才值得这样做。public class MyClass { // 假设会频繁调用UseAnotherClass() // 每次调用都会创建一个新AnotherClass对象，调用完后销毁 public void UseAnotherClass() { var anotherClass = new AnotherClass(); anotherClass.DoSomeWork(); } } // 改为下面这种 public class MyClass { // 把局部变量改为成员变量 AnotherClass anotherClass = new AnotherClass(); public void UseAnotherClass() { // 每次调用同一对象，避免重复创建和销毁 anotherClass.DoSomeWork(); } }第二个，采用依赖注入。追求的是复用需要使用的对象，同时可以避免创建未使用的对象。不过我感觉这里有点问题，书上举的例子其实更类似单例模式？// 书上的例子 // Brushes类型中包含成员Black // 调用Brushes.Black时，如果blackBrush未创建，则新创建并返回 // 否则返回已有的成员变量blackBrush private static Brush blackBrush; public static Brush Black { get { if (blackBrush == null) { blackBrush = new SolidBrush(Color.Black); } return blackBrush; } }依赖注入的话，我是在ASP.NET Core中接触到的，确实也能实现对象的复用，不过感觉更重要的作用还是解耦合。原理就是类型A中的方法或成员需要依赖于类型B的对象，现在改为依赖对应接口IB。这样类型A的对象a不需要控制该B对象的生成，只需要在外部生成b然后传入a中使用即可。一旦后面替换成使用B对象b2，直接给a传入b2即可，从而实现了解耦合。// 原设计 public class Music { // 跟LuoTianyi耦合了 private LuoTianyi _luoTianyi = new LuoTianyi(); public void Play(string content) { _luoTianyi.Sing(content); } } // 如果更换使用到的类型要大改 public class Music { // 又跟HatsuneMiku耦合了 private HatsuneMiku _hatsuneMiku = new HatsuneMiku(); public void Play(string content) { _hatsuneMiku.Sing(content); } } // 改为依赖注入，实现解耦合 // 同时也可以达到复用对象的目的 public class Music { // LuoTianyi和HatsuneMiku均实现了IVocaloid private IVocaloid _vocaloid; public Music(IVocaloid vocaloid) { _vocaloid = vocaloid; } public void Play(string content) { _vocaloid.Sing(content); } }第三个技巧是针对不可变对象的，大家最熟悉的就是字符串类型，不应该用+号频繁拼接字符串这个也是老生畅谈的问题了，因为会生成大量不需要的子字符串变成垃圾，增加GC压力。正确做法应该是，简单的用内插字符串，复杂的用StringBuilder。类似的，其他不可变类型也应该尽量使用对应的builder类来操作。第16条 绝对不要在构造函数里面调用虚函数简单来说就是如果在构造函数里面调用虚函数逻辑很混乱，细节不一定能搞清楚，最终导致出现问题，书上举了个例子：class B { protected B() { VFunc(); } protected virtual void VFunc() { Console.WriteLine("VFunc is B"); } } class Derived : B { private readonly string _msg = "Set by initializer"; public Derived(string msg) { _msg = msg; } protected override void VFunc() { Console.WriteLine(_msg); } public static void Main() { var d = new Derived("Constructed in main"); } }请问输出是什么？class B { // 4. 在基类构造函数中调用了方法VFunc() protected B() { VFunc(); } // 5. 发现VFunc()是虚函数，转而调用子类中覆写的VFunc() protected virtual void VFunc() { Console.WriteLine("VFunc is B"); } } class Derived : B { // 2. 先执行实例变量的初始化语句 private readonly string _msg = "Set by initializer"; // 3. 然后执行构造函数 // 在调用子类构造函数之前，会先调用基类的构造函数 public Derived(string msg) { _msg = msg; } // 5. 此时的_msg已经被初始化语句所初始化，但还未被构造函数修改 // 输出结果是"Set by initializer" protected override void VFunc() { Console.WriteLine(_msg); } public static void Main() { // 1. 创建Derived实例 var d = new Derived("Constructed in main"); } }所以答案是"Set by initializer"。可以看到非常绕且完全没有必要，因此绝对不要在构造函数里面调用虚函数。第17条 实现标准的dispose模式第11条中讲到释放资源首选实现IDisposeable接口，实际上我们要实现的不止IDisposeable接口，而应该实现标准的dispose模式。要实现dispose模式，书上给出了一些规则：根部的基类需要做到：实现IDisposeable接口，以便释放资源。如果本身包含非托管资源，就要添加finalizer，防止使用者忘记调用Dispose()。尽管11条中说过使用finalizer不利于性能，但至少能保证资源被释放。如果没有非托管资源就不用加。Dispose()和finalizer都应该把释放资源的工作交给虚方法完成，这样子类能够重写该方法来释放他们自己的资源。子类应该做到：如果子类有自己的资源要释放，就应该重写基类提供的释放资源的虚方法。如果子类自身的某个成员表示非托管资源，要实现finalizer，防止用户忘记调用Dispose()导致资源泄露。记得调用基类的同名函数。IDisposeable接口中只包含了Dispose()这一个方法，实现Dispose()时需要注意四点：把非托管资源全部释放掉。把托管资源全部释放掉。设定相关的状态标志，表示该对象已被清理。如果访问该对象时从标志得知已被清理，抛出ObjectDisposedException。阻止垃圾回收期重复清理该对象。通过GC.SuppressFinalize(this)来完成。另外前面说过Dispose()和finalizer都应该把释放资源的工作交给虚方法完成，这样子类能够重写该方法来释放他们自己的资源。因此我们应当重载一个protected virtual void Dispose(bool isDisposing)来完成实际的资源释放工作，让Dispose()和finalizer都调用它。子类可以覆写这个虚方法，编写代码清理自身的资源并调用基类方法来清理基类资源。isDisposing代表了是否是通过Dispose()调用的，如果未true则同时清理托管和非托管资源；如果为false则表明是finalizer调用的，只需要清理非托管资源。如果是子类的话，还要调用基类的Dispose(bool)方法。一个标准的dispose模式实现如下：public class BaseClass : IDisposable { private bool _isDisposed = false; // 虚Dispose(bool)方法，用于实际释放资源 protected virtual void Dispose(bool isDisposing) { // 防止多次释放 if (!_isDisposed) { if (isDisposing) { // 通过Dispose()调用 // 在这里释放托管资源 } // 在这里释放非托管资源 // 设置标志 _isDisposed = true; } } // 实现IDisposable接口 public void Dispose() { // 调用虚方法 Dispose(true); // 防止被GC重复清理 GC.SuppressFinalize(this); } // finalizer，只有在有非托管资源的情况下才应该实现 ~BaseClass() { Dispose(false); } public void ExampleMethod() { // 在释放后尝试调用该对象的方法和成员 // 抛出ObjectDisposedException异常 if(_isDisposed) { throw new ObjectDisposedException(); } } }如果有子类继承该类的话，子类的实现为：public class ChildClass : BaseClass { // 子类有自己的标志，要和基类的区分开来 private bool _isChildDisposed = false; // 覆写虚Dispose(bool)方法 protected override void Dispose(bool isDisposing) { // 防止多次释放 if (!_isDisposed) { if (isDisposing) { // 在这里释放托管资源 } // 在这里释放非托管资源 // 调用基类的Dispose(bool)方法释放基类资源 base.Dispose(isDisposing); // 设置标志 _isChildDisposed = true; } } }Dispose()和finalizer最好只用来释放资源，如果加入其他操作的话请考虑清楚，防止导致本该已经宣告消亡的该对象重新被其他地方保留，因为理论上该对象已经被终结了，所以不会再被GC所清理，导致残留。按照上面的模板编写标准的dispose模式，既方便了自己，也方便了用户和从该类中派生子类的开发者。因此应该让自己养成这个好习惯，在要实现dispose模式时尽可能按照标准模板去编写。第二章完。
前言为了提升一下自己的姿势水平，买了 Effective C# 和 More Effective C# 两本书，想一边阅读一边强迫自己做点笔记（主要是自己的理解），不然怕是看了就忘。另外手头还有一本《深入解析C# ( C# in Depth )》其实我已经阅读了一遍，但感觉没咋读懂，下次有机会也用这种方式重读一遍。今天这篇就先从 Effective C# 的第一章开始，后续慢慢更新。第一章 C#语言的编程习惯第1条 优先使用隐式类型的局部变量简单来说就是在声明局部变量时，应该使用var而尽量避免使用类型的全称。var的引入主要是为了匿名类型和Linq，var跟dynamic动态类型不同，它是靠编译器自动推断出变量的实际类型。这个还是比较好理解的，因为用var很多时候方便且不会影响对代码的理解，常用的几个场景比如// 很容易就能看出该变量是什么类型，用var简洁 string s1 = "66ccff"; var s2 = "66ccff"; Dictionary<int, List<Tuple<int, int>>> d1 = new Dictionary<int, List<Tuple<int, int>>>(); var d2 = new Dictionary<int, List<Tuple<int, int>>>(); // 匿名类型，因为“匿名” var a = new { A = 1, B = 2 }; // Linq，编译器推断比人工指定更合适，更方便 var student = Students.Where(s => s.Score >= 80).OrderBy(s => s.Id)但也不是所有局部变量都适合用var，因为此时不好看出变量的类型，或编译器推断并不适合，比如// 这个很好看出变量类型 var count = GetBooksCount(); // 遇到起名不规范的，就难办了 var result = DoSomeWork(); // 手工指定方便阅读 string result = DoSomeWork(); // 函数默认返回int，做除后结果为int var num = GetNumber(); var result = num / 7; // 手工指定double，做除后结果为double double num = GetNumber(); var result = num / 7;所以在保证能在阅读时清楚知道变量类型的情况下，应尽量使用var做到简洁明了；反之就要明确指出类型，比如面对int，float，double等时。第2条 考虑使用readonly代替constconst是编译期常量，readonly是运行期常量。区别在于编译时，所有用到const常量的地方，该常量都会被常量实际值给完全替换，而用到readonly常量的地方仍会保留对readonly常量的引用，类似于// 声明 const int a = 712; readonly int b = 712; // 使用 void DoSomeWork() { Console.WriteLine(a); Console.WriteLine(b); } // 等价于 void DoSomeWork() { // 在编译的时候，const常量就被替换成了值 Console.WriteLine(712); // 仍引用了readonly常量 Console.WriteLine(b); }问题在于在库A中声明了一常量const int num = 100;，若项目B引用了A的num，编译B时也会自动将B中用到的num自动替换为100。若后续库A将num更新为200，但项目B没有重新编译，则B中num仍为100。而改为readonly常量则没有这个问题，因为使用的时候引用的时真实变量，而不是编译时就写死的值。因此只有在对性能极端敏感和有需求将值在编译期就固定下来的时候才使用const，不然最好选择readonly。第3条 优先考虑is或as运算符，尽量少用强制类型转换is运算符用来判断变量是否兼容（支持转换成）某种类型，as运算符用来转换变量的类型。object o = "66ccff"; // 强制转换 // 如果无法转换会抛出异常 try { var s1 = (string)o; Console.WriteLine(s1); } catch (InvalidCastException e) { Console.WriteLine(e); throw; } // is 判断类型是否兼容某类型 // null或不兼容返回false if (o is string) { string s2 = (string)o; Console.WriteLine(s2); } // as 用来转换变量的类型 // 转换失败返回null string s3 = o as string; if (s3 != null) { Console.WriteLine(s3); } // 本书只涵盖到C#6.0 // C#7.0之后可以使用模式匹配的is语句 if (o is string s4) { Console.WriteLine(s4); }因此我基本都是用第四种模式匹配的is语句来做类型转换，不过也有用as的时候，主要结合?.（null条件运算符），比如// 模式匹配 if (o is Student student) { student.UpdateScore(100); } // 可以替换成 // 若转换失败返回null var student = o as Student; // 若为null则不执行且无异常 student?.UpdateScore(100);第4条 用内插字符串取代string.Format()内插字符串也是C#6.0的特性之一，主要就是用来代替string.Format()，功能基本一致，使用时在字符串前面加上$符号即可，比如var info1 = string.Format("学生姓名为{0}，{1}岁", student.Name, student.Age); // 用内插字符串代替 var info2 = $"学生姓名为{student.Name}，{student.Age}岁";可以看出内插字符串的可阅读性更好、更直观。且内插字符串功能丰富，在{}之间可以像原string.Format()一样用:格式说明符，也可以调用方法、使用null条件运算符、调用Linq等等。当然内插的表达式过于复杂就背离了我们使用内插字符串的初衷，不如先声明为局部变量再内插更直观。// 使用Linq、null条件和合并运算符、格式说明符 var info1 = $"获得最高分的同学的生日为{(students.OrderByDescending(s => s.Score).FirstOrDefault()?.Birth ?? DateTime.MinValue):d}"; // 我觉得这样更好 var birthOfMaxScoreStudent = students.OrderByDescending(s => s.Score). FirstOrDefault()?.Birth ?? DateTime.MinValue; var info2 = $"获得最高分的同学的生日为{birthOfMaxScoreStudent:d}";第5条 用FormattableString取代专门为特定区域而写的字符串FormattableString也是通过上一条的内插字符串生成的，主要是用来实现全球化和本地化。编译器在推断内插字符串类型的时候会默认推断为string，如有使用需要需要手工指定为FormattableString类型// date1为string类型 var date1 = $"{DateTime.Now.Date}"; // date2为FormattableString类型 FormattableString date2 = $"{DateTime.Now.Date}";然后需要编写一个方法用来将FormattableString转换为适应不同国家和地区的字符串，比如// 将FormattableString转换为针对中国大陆地区的风格的字符串 public static string ToCN(this FormattableString src) { return string.Format(null, System.Globalization.CultureInfo.CreateSpecificCulture("zh-cn"), src.Format,src.GetArguments()); }第6条 不要用表示符号名称的硬字符串来调用API某些地方会使用到变量的名字，比如WPF实现INotifyPropertyChanged接口的时候，需要以字符串形式传入对应变量的名字实现绑定，如果直接用"Name"这种字符串常量写死会导致强耦合，一旦后来代码修改时更改了变量名字，那么就会出现问题且IDE没有提示。所以需要用到nameof()运算符，可以获取类型、变量、接口、命名空间的名字。// WPF实现MVVM的属性绑定 public class ViewModelBase : INotifyPropertyChanged { public event PropertyChangedEventHandler PropertyChanged; protected virtual void OnPropertyChanged(string propertyName) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } } public class Student : ViewModelBase { private string _name; public string Name { get => _name; // 用nameof()运算符 // set { _name = value; OnPropertyChanged("Name"); } set { _name = value; OnPropertyChanged(nameof(Name)); } } }还有一点题外话（点击展开）// 题外话：针对类似场景可以使用CallerMemberName这个Attribute public class ViewModelBase : INotifyPropertyChanged { public event PropertyChangedEventHandler PropertyChanged; // CallerMemberName protected virtual void OnPropertyChanged([CallerMemberName]string propertyName = null) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } } public class Student : ViewModelBase { private string _name; public string Name { get => _name; // 调用OnPropertyChanged()时会自动传入调用者的名字 set { _name = value; OnPropertyChanged(); } } } // 再次题外话：实现MVVM还是直接用库比如MVVM Toolkit就好，简单易用 using Microsoft.Toolkit.Mvvm.ComponentModel; using Microsoft.Toolkit.Mvvm.Input; public class Student : ObservableRecipient { private string _name; public string Name { get => _name; set => SetProperty(ref _name, value); } }第7条 用委托表示回调C#中用委托表示回调，委托有点类似函数指针的意思，简单地说就是把需要使用的方法传给其他地方进行调用。比如我跟厨师说到11点做西红柿炒蛋，做西红柿炒蛋()是一个方法，我将其包装成委托，传给厨师，厨师到了合适的时候就会根据我的委托回调做西红柿炒蛋()这个方法。原先委托用delegate声明，不过现在一般直接用内置的Action<T>和Func<T>，区别在于Action<T>无返回值而Func<T>有返回值。比如// 用delegate声明 delegate void SimpleDelegate(string text); SimpleDelegate simpleDelegate = Console.WriteLine; // Action<T> // Action<T>的所有T均为输入类型 Action<string> simpleAction = Console.WriteLine; // Func<T> public static int Add(int x, int y) { return x + y; } // Func<T>的最后一个T为返回类型，前面的T均为输入类型 Func<int, int, int> simpleFunc = Add; // 当然也可以用lambda表达式一步到位 Func<int, int, int> simpleFunc2 = (x, y) => x + y; // Predicate<T>，不常见 // 类似于Func<T, bool>，但不等价 // 有且只能有一个输入参数 Predicate<int> simplePredicate = x => x == 1;委托很常见，比如Linq其实就利用了委托，比如var count = students.Count(s => s.Score >= 90); // 其实是编译器自动将lambda表达式的匿名函数转换为了委托 Func<Student, bool> over90Func = s => s.Score >= 90; var count = students.Count(over90Func);第8条 用null条件运算符调用事件处理程序简单来说就是触发事件因为线程不安全，很有可能造成NullReferenceException，而利用null条件运算符可以用很安全且简洁的方式调用事件处理，我在第6条折叠的题外话中使用过。// 详细见第6条题外话代码 public class ViewModelBase : INotifyPropertyChanged { public event PropertyChangedEventHandler PropertyChanged; protected virtual void OnPropertyChanged([CallerMemberName]string propertyName = null) { // 不要用PropertyChanged(this, new PropertyChangedEventArgs(propertyName)); // Invoke()是委托和事件自动生成的方法，等于调用其自身 // 语法不支持PropertyChanged?.()，所以要用PropertyChanged?.Invoke() PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } }第9条 尽量避免装箱与取消装箱这两种操作老生常谈的问题了，装箱和取消装箱（拆箱）的详细定义见微软官方文档《装箱和取消装箱（C# 编程指南）》。简单来说装箱是将值类型转换为引用类型，也就是将值类型包装用object类型包装，并将其存储在托管堆中。取消装箱则相反，是从引用类型对象中提取出值类型。装箱是隐式的，取消装箱是显式的。int i = 123; // 装箱（隐式） object o = i; // 拆箱（显式） int j = (int)o;装箱与取消装箱的问题在于频繁进行这两种操作会严重影响性能，因为每次装箱，都要在托管堆中生成一个拷贝值类型的对象，取消装箱则是根据托管堆中的对象生成一个值类型拷贝，如下图所示官方文档里说：如果值类型必须被频繁装箱，那么在这些情况下最好避免使用值类型（例如在诸如 System.Collections.ArrayList 的非泛型集合类中）。可通过使用泛型集合（例如 System.Collections.Generic.List<T>）来避免装箱值类型。装箱和取消装箱过程需要进行大量的计算。对值类型进行装箱时，必须创建一个全新的对象。这可能比简单的引用赋值用时最多长 20 倍。取消装箱的过程所需时间可达赋值操作的四倍。不过感觉日常使用其实装箱和取消装箱的操作还是比较少的，毕竟现在的集合应该都是支持泛型的，基本没有放弃List<T>而使用ArrayList的情况吧。我印象里最近使用过，不得不装箱的场景就是WPF中实现基于IValueConverter接口的转换器，输入和输出都是object类型，如果涉及到值类型肯定不可避免产生装箱和取消装箱，但就这个场景来说可能对性能没有那么敏感。// int类型和枚举类型的转换 public class VariantTypeToIntConverter : IValueConverter { public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) { return (int)(VariantType)value; } public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) { return (VariantType)(int)value; } }第10条 只有在应对新版基类与现有子类之间的冲突时才应该使用new修饰符子类继承父类时，可以用new修饰符隐藏父类的成员和方法（包含虚和非虚）：public class Parent { public void Print() { Console.WriteLine(nameof(Parent)); } } public class Child : Parent { // 不加new也不报错，但编译器会提醒若是有意隐藏父类成员请加new public new void Print() { Console.WriteLine(nameof(Parent)); } }但是测试会发现这种情况：Child child = new Child(); Parent parent = new Child(); child.Print(); parent.Print(); // 输出 // Child // Parentchild和parent的运行时类型都为Child，但parent的编译时类型为Parent，在调用时只会调用其编译时类型中的方法，而不考虑其实际运行时类型中的同名new修饰的方法。作为对比，override修饰符则会动态搜寻其运行时类型向上所能接触到的最近的方法。public class Parent { public virtual void Print() { Console.WriteLine(nameof(Parent)); } } public class Child : Parent { // override用来覆写虚方法 public override void Print() { Console.WriteLine(nameof(Child)); } }测试输出：Child child = new Child(); Parent parent = new Child(); child.Print(); parent.Print(); // 输出 // Child // Child书中建议，只有当你基于现有父类A的子类B包含方法X，而新版的A中添加了和B中同名的X时，可以让你的B中的X添加new修饰符作为暂时的解决方案，当然就长远来看最好还是考虑改名。而不应该把new当作override的替代品用于覆写父类中非虚的方法。这也需要父类的设计者应该认真考虑清楚什么方法可能和应该交由子类重新实现，然后用virtual修饰。第一章完。
前言之前我基于OpenCC写过一个OpenCC.NET的库，写得也比较烂，就想稍微重构一下然后再写个即开即用的桌面端程序，于是就有了这个OpenCC.NET GUI。本来是想尝鲜用Win UI3来实现的，最后还是决定稳妥点用了WPF+UI库ModernWpf。这次也是我第一次用MVVM Toolkit，尽量照着MVVM的思想去写代码，也算是有所收获。虽然我完全不懂设计，但程序总得有个Logo。于是我把「字」字做了一点改造，搞出一个类似转换箭头的结构，代表中文转换；然后分层加阴影和光照，尝试去模仿Fluent Design图标的那种质感，最后就得到了：程序已经开源放在了GitHub和Gitee上，有兴趣的可以取看看，地址：https://github.com/CosineG/OpenCC.NET.GUIhttps://gitee.com/cosineg/OpenCC.NET.GUI下面的部分我就偷懒直接照抄readme了。介绍OpenCC.NET GUI是基于OpenCC(Open Chinese Convert, 开放中文转换)，使用OpenCC.NET实现的中文转换工具，支持中文简繁体之间词汇级别的转换，同时还支持地域间异体字以及词汇的转换。特点支持简=>繁，繁=>简，繁=>繁的转换模式支持包括OpenCC，中国大陆，台湾，香港四种异体字标准支持中国大陆和台湾地区的常用词汇转换支持txt文本文件的批量转换开始获取可以直接在仓库页面右侧获取最新Release版本，或下载源码后自行编译。若无法运行，请确保安装了.NET Core 3.1运行时。使用软件提供了文本编辑转换和文件批量转换两种功能。文本编辑转换此模式下直接在文本框中输入你所需要转换的语句，在右侧选择转换选项后，点击转换按钮即可得到结果。文件批量转换此模式下可以批量导入txt文本文件进行转换。若生成文件为乱码，请确保原文件为UTF-8编码。引用OpenCC.NETOpenCC.NET实现文本转换。OpenCCOpenCC为OpenCC.NET提供词库。jieba.NETjieba.NET为OpenCC.NET提供分词。ModernWpfModernWpf提供UI控件。Ookii.Dialogs.WpfOokii.Dialogs.Wpf提供选择文件夹窗口（没错，WPF没有自带选择文件夹窗口）。