Reading 28: 软件工程中的伦理(Ethical Software Engineering)
Reading 28: 软件工程中的伦理(Ethical Software Engineering)
说明:本讲 sp22 原版使用 TypeScript,本笔记按用户要求提供 Java 代码示例;类型/API 与 sp21(6.031 Java 版)原文保持一致。本讲在 sp21 中的编号是 Reading 30,两版正文几乎逐段对应,只有少量案例引文与资源清单不同,差异处本笔记会单独标注。
概述
到本讲为止,6.031 一直在用三个技术目标衡量软件:Safe from bugs(SFB,免于缺陷)、Easy to understand(ETU,易于理解)、Ready for change(RFC,易于修改)。本讲引入第四个属性——伦理(ethicality),它和前三个一样是”好软件”的组成部分,一样可以在迭代式设计过程中被检验、被修复,而不是事后贴上去的标签。课程的原话是:软件构造从来不是发生在真空里的——你和别人一起工作、维护别人设计的软件、软件被其他人使用、而这些人的使用又影响到更多的人。因此,写得正确、清晰、可修改,是你对同事、对所在组织、对用户、乃至对并不使用你的软件却受其影响的人(expanding circles of influence,扩展的影响圈)应尽的职业义务。本讲不规定任何一套道德信条,也不告诉你发生冲突时哪个属性更重要;它给你的是工具:ACM 道德准则的条款、四种道德透镜(moral lenses),以及一个可以放进代码评审流程里的提问方法。这些工具直接服务于三大目标:伦理缺陷往往就是未被发现的 SFB(例如把私密数据写进日志)、未被讨论的 ETU 问题(例如不可读的代码让别人误解了规格)、以及未被识别的 RFC 债务(例如一个把紧急处置责任硬编码给”操作员”的设计,后来无论如何都改不动)。
本讲的学习目标只有两条,但要求很高:能够解释设计、构建、维护软件时需要考虑的伦理原则;能够用四种不同的道德透镜审视一个具体系统的伦理后果。
核心概念与设计原则详解
伦理作为软件的一个质量属性(Ethicality as a Quality Property)
- 定义与目的:伦理性和性能、安全、可用性一样,是软件的一个属性;课程明确指出,这些属性”cannot be bolted on later”(不能在事后用螺栓拧上去),必须作为迭代式设计过程的一部分被”烘焙”进系统。它解决的是”软件是否对人造成伤害”这一质量问题,而 SFB/ETU/RFC 只覆盖”软件是否按意图工作”。
- 直观解释(”它是什么?”):把”伦理测试”想象成戴上一顶不同的帽子。平时你戴上”测试帽”,会拼命想办法弄坏自己的代码;现在戴上”伦理帽”,你要拼命设想别人会怎样滥用、误用、伤害你构建的东西。作者的原话是:不要指望自己或别人”自然而然就做对了”。
- 关键规则与最佳实践:
- 把伦理后果写进设计评审的议程,而不是当作事后公关问题。
- 对你交付的每一个功能问三遍:谁会受益、谁会受损、受损者有没有发言权。
- 记录”为什么做了这个权衡”——权衡的过程本身就是一种可审查的产物。
- 承认伦理缺陷的修复成本与正确性缺陷一样,越晚修越贵。
- 记住伦理属性没有”规格说明”可以对照,所以必须靠人来提问,而不能靠测试套件自动发现。
扩展的义务圈(Expanding Circles of Influence)
- 定义与目的:课程把软件工程的责任从内向外分成四层:(1) 与你共事的同事,(2) 你所在的组织,(3) 你的软件的用户,(4) 即使不使用软件也被它影响的人,以及这些人所生活的世界。它解决的是”我对谁负责”这个边界问题——边界划得太小,就会出现”我只负责让代码编译通过”这种自我免责。
- 直观解释(”它是什么?”):社交网络是最直观的例子。一个奇迹般地从未向任何社交网站透露过一条信息的人,仍然会深深受到亲友、社群、社会使用社交网络的影响。软件的影响不是”系统 × 个人”的二元函数,而是”系统 × 个人 × 社群 × 社会”的复杂交互。
- 关键规则与最佳实践:
- 判断影响时,把非用户(non-users)也列进利益相关者清单。
- 警惕涌现属性(emergent properties):系统被真实的人在一个复杂世界里使用时,会产生设计者没有预料到的整体行为。
- 采纳 ACM 准则 3.7:要”recognize and take special care of systems that become integrated into the infrastructure of society”(识别并特别关照那些已经融入社会基础设施的系统)。
- 产品采用率越高,伦理责任越大——这是随成功一起增长的负债。
- 当系统成为基础设施时,用”社会会怎样依赖它”而不是”用户会不会喜欢它”来驱动设计。
抄袭、版权与许可证(Plagiarism, Copyright, and Licenses)
- 定义与目的:这是最小尺度的伦理问题:任何时候使用别人写的代码,都必须署名(attribute)并拥有许可证(license)。它解决的是”知识产权的边界在哪里”。
- 直观解释(”它是什么?”):软件工程有强烈的共享文化——自由软件运动(free software movement)认为”用户不能自由运行、修改、再分发”的软件是不道德的;开源运动(open source movement)主张共享源码在实践上更有优势;Stack Overflow 上那些协作积累的解释与示例就是这种文化的产物。但是,共享的前提是作者用许可证授予你这些权利;没有许可证就复制源码,既违法(侵犯版权)也不道德。若作者放弃版权、把代码放进公有领域(public domain),则是例外:任何使用都不需要许可证。
- 关键规则与最佳实践:
- 使用外部代码前先确认许可证,并保留版权声明。
- 作业与项目里引用的外部材料必须明确署名(6.031 的 Collaboration and Sharing 政策也同样要求)。
- 注意”免费”不等于”自由”:free software 指的是自由(可运行、可修改、可再分发),不一定是 $0。
- 不要把课程作业的解答公开到 GitHub——starter code 的版权属于课程组,你的解答是衍生作品,公开分发需要事先许可(这是 6.031 合作政策的明确条款)。
- 自己写的东西也要留痕:提交信息、注释里的出处链接,都是伦理习惯的一部分。
ACM 道德准则(ACM Code of Ethics and Professional Conduct)
- 定义与目的:ACM 是计算机科学界最主要的国际专业组织(它颁发图灵奖)。它的准则为本讲提供了一套可引用的条款编号体系,让”我觉得这样不太好”变成”这违反了第 1.2 条”。它解决的是”如何把直觉变成可讨论、可追责的规范”。
- 直观解释(”它是什么?”):准则分三大节:第 1 节 General Ethical Principles(一般伦理原则),第 2 节 Professional Responsibilities(职业责任),第 3 节 Professional Leadership Responsibilities(职业领导责任)。第 1 节里课程特别点名的条款是:1.1 对人类福祉的义务、1.2 防止伤害、1.3 诚实、1.4 公平与包容、1.5 尊重著作权与版权、1.6 尊重隐私、1.7 保密(尤其对雇主)。
- 关键规则与最佳实践:
- 1.2(避免伤害)在课程中被明确地连接到第 3 节的 3.7(关照已成为社会基础设施的系统)。
- 2.1 “strive to achieve high quality in both the processes and products”——质量和过程都是义务。
- 2.2 “maintain high standards of professional competence, conduct, and ethical practice”;准则 2.2 的原文还强调:职业胜任力”starts with technical knowledge and with awareness of the social context in which their work may be deployed”,并且需要沟通、反思性分析、识别与应对伦理挑战的技能。
- 准则本身不是法律,它是一份专业共同体对成员的期望;它给你语言,不给你答案。
- 引用条款时要具体到编号,这样在评审会上才能把讨论从”价值观之争”拉回”我们是否履行了这项义务”。
伦理结构,而不只是伦理个体(Ethical Structures, not just Ethical Individuals)
- 定义与目的:这是本讲最重要的一条社会学洞见:当一个大组织造出有害系统(造成伤害、歧视、加剧不平等、侵犯隐私)时,人们倾向于寻找一个”邪恶大反派”。但更常见的原因是:组织没有设计出任何结构,来保证众多参与者各自看似合理的努力汇聚成一个合乎伦理的结果。它解决的是”为什么好人也造出坏系统”。
- 直观解释(”它是什么?”):课程的原话很辛辣:如果真有一个大反派,那大概是因为你在看电影——电影才靠个体英雄与个体恶人推动叙事。更糟的是,我们生活在一个许多既有结构本身就不合伦理的世界里(它们歧视、延续不平等、剥夺自主),所以一个完全不关心伦理的组织,会在自己的产品里复制周围社会的伦理失败。
- 关键规则与最佳实践:
- 面试时问:”你们怎么做代码评审?”——这一个问题同时探测了 2.1(高质量过程)、2.2(技术知识共享)、2.4(专业反馈)。
- 继续问:”你们如何评审新功能可能被滥用的方式?”、”设计新产品时你们会考虑并沟通哪些不同的人群或群体?”、”有没有哪一次你们因为伦理原因改变了计划中的功能?是怎么决定的?”
- 如果对方答不上来,就要像听到”我们不需要代码评审,我们相信每个工程师都能写出好代码”一样警惕。
- 案例:Facebook 面向第三方开发者的 API 允许应用采集并滥用使用该应用的用户的所有好友的个人信息。没有任何一个工程师独自设计、构建、部署了这个 API。真正的问题是:当有工程师提出伦理质疑时,Facebook 这个组织是被设计成放大并审查这些问题,还是压制它们?
- 反面案例之后是正面案例:面对反疫苗错误信息,Pinterest 认为不能依赖算法来”提升真话、压低谎言”,于是停止返回数百个健康相关关键词的搜索结果,改为展示人工挑选的公共卫生来源。要问的是:有多少员工参与了这个决策与实现——数量本身就是”结构”的证据。
四种道德透镜(Four Moral Lenses)
- 定义与目的:这是本讲提供的可操作程序(procedure)。课程把它归纳为四个不同的视角,用来审视一个项目的正面与负面影响:Outcomes(结果)——项目的成本与收益;Process(过程)——这些成本与收益是如何达成的;Structure(结构)——好坏结果与过程的模式(patterns);Character(品格)——把项目当作一个人来看待。它解决的是”我说不清楚哪里不对”的问题。
- 直观解释(”它是什么?”):它像一副四色滤光镜:同一张照片,换一片滤镜就看出一类问题。只看 Outcomes 会漏掉”用欺骗手段达成好结果”的问题;只看 Process 会漏掉”程序完全合规但结果灾难”的问题;Structure 让你从单个用户跳到”某一类人是否被系统性地伤害”;Character 让你问”如果这个系统是一个人,它是个什么样的人”。
- 关键规则与最佳实践:
- 四个透镜都要用,不要只挑自己最顺手的那一个。
- 后果(Outcomes)透镜下要同时列成本与收益,并且问”成本落在谁身上、收益归谁”。
- 过程(Process)透镜关心手段:是否欺骗、是否未经同意、是否剥夺选择权。
- 结构(Structure)透镜关心模式:是否某一群体系统性地承受更差的错误率、更少的救济渠道。
- 品格(Character)透镜关心”这个项目/公司作为一个行为者,表现出什么品质”——慷慨、诚实、体贴,还是操纵、轻慢。
- 本讲的练习覆盖了典型场景:卖用户数据(结果与过程)、人脸识别对深色皮肤的准确率显著更差(结构上的不平等)、触屏对看不见屏幕的人不可用、车机触屏让驾驶员分心、餐厅平板在屏幕熄灭时用户看不懂于是”常亮 + 闪亮视频广告”(品格)、同事写了不可读的代码与不完整的规格导致别人误解(品格)、代码完全能跑但一次都没测试就提交到团队仓库(过程)。
透镜背后的伦理学传统:后果主义、义务论、美德伦理、社会契约(补充说明:课程原文只给出 Outcomes / Process / Structure / Character 四个透镜名称,以下是把它们与伦理学主要传统的对应关系整理出来,属于解说性补充,不是 6.031 原文的表述。)
- 定义与目的:四种透镜并非凭空而来,它们分别呼应伦理学中的四大传统,理解对应关系能让你在讨论中更快地定位分歧的性质。
- 直观解释(”它是什么?”):
- 后果主义(consequentialism)↔ Outcomes 透镜:一个行为的对错完全由它产生的后果(成本与收益)决定。它的力量在于关注真实的人受到的伤害;它的弱点在于难以比较不同人的收益,也容易为”多数人的好处”牺牲少数人。
- 义务论(deontology)↔ Process 透镜:某些行为本身(欺骗、违约、把人当作纯粹手段)就是错的,不论后果多好。它解释了为什么”未经用户知情就收集数据”即使”服务因此更好”也仍然是错的。
- 美德伦理(virtue ethics)↔ Character 透镜:不问”这个行为对不对”,而问”一个有德性的人/组织会怎么做”。它把项目当成行为者,考察诚实、正直、关怀、公正这些品质。
- 社会契约论与正义论(social contract / justice)↔ Structure 透镜:关注规则与制度如何系统性地分配利益与负担,关注某一类人是否被结构性地排除或损害。它解释了个体工程师都”没做错”而组织仍产出歧视性系统的机制。
- 关键规则与最佳实践:
- 当讨论卡住时,先判断分歧属于哪种传统:是后果估算不同,还是手段本身不可接受,还是结构性不公。
- 用 Outcomes 说服商人,用 Process 说服法务,用 Structure 说服公平性评审,用 Character 说服团队自己。
- 不要假装四个透镜总能给出一致结论:课程明确说”this reading does not prescribe a moral code”。
- 结构透镜最容易被忽略,也最难修——因为它要求改变流程,而不是改一行代码。
- 社会契约视角在本讲的具体落点是 ACM 的 3.7:融入社会基础设施的系统需要”special care”。
伦理测试(Ethical Testing)与设计评审中的伦理提问(Ethics in Design Review)
- 定义与目的:把伦理检查流程化:像写测试一样,尽可能悲观地设想你的作品会被如何滥用(misused)和恶用(abused)。它解决的是”伦理审查只在出事后才发生”的问题。
- 直观解释(”它是什么?”):课程把两种帽子并列:测试帽让你努力攻破自己的代码;伦理帽让你努力攻破自己的意图。和测试一样,伦理缺陷的修复很棘手——因为它牵涉的远不止系统和源码(还牵涉产品策略、激励、组织结构)。
- 关键规则与最佳实践:
- 在设计评审里固定留出”滥用场景”环节,并指定一个人扮演攻击者。
- 把”我们考虑过但决定不做”的伦理结论写进设计文档,作为 RFC 与 ETU 的一部分。
- 用 Reading 4(代码评审)的流程承载伦理评审:读规格、读代码、问”这段代码在什么输入下会造成伤害”。
- 关联 Reading 6(规格说明)与 Reading 7(设计规格):模糊的规格本身就是伦理风险,因为调用者会按最方便自己的方式理解它。
- 追问”这个功能如果被一个恶意客户使用会怎样”,而不是只问”我们的用户会不会喜欢”。
举报与责任(Whistleblowing and Responsibility) (补充说明:6.031 原文没有出现 whistleblowing 这个词,但本讲的 ACM 准则第 1.2 条(避免伤害)、第 2.5 条(对系统影响的全面评估)、第 2.2 条(维持高标准的职业操守)以及”伦理结构”的讨论,自然会导出这个话题;以下内容是把它接回课程框架的延伸,不是原文表述。)
- 定义与目的:举报是指组织内部成员在内部渠道失效后,向外部披露严重危害公众利益的行为。它解决的是”当结构本身压制伦理问题时,个体还剩什么手段”。
- 直观解释(”它是什么?”):课程的问题”当有工程师提出伦理质疑时,Facebook 是被设计成放大这些问题,还是压制它们?”就是举报问题的上游:好的结构让举报变得没必要。
- 关键规则与最佳实践:
- 把举报当作最后一招:优先使用内部渠道、书面记录、把问题升级给有权限的人。
- 你的第一道防线是留下书面记录:把质疑写进评审意见、设计文档、issue 里,这样问题不再依赖某个人的记忆。
- 用具体条款说话(1.1、1.2、3.7),把”我不舒服”变成”我们违反了已承诺的义务”。
- 承认代价真实存在,同时承认 ACM 准则把公众安全置于雇主忠诚之上(1.1 与 1.2 位于 1.7 保密义务之前)。
- 最好的职业策略仍然是选择结构:面试时问”你们如何评审滥用风险”,就是在选择不必举报的环境。
伦理与三大目标的张力(Tension with SFB / ETU / RFC)
- 定义与目的:伦理要求常常和三大目标、和进度、和商业指标冲突,本讲要求你识别这种冲突而不是假装它不存在。
- 直观解释(”它是什么?”):典型冲突有四类:
- 为进度牺牲安全性:赶 deadline 时跳过测试、直接
commit,这正是本讲练习里 Charlie 的做法(代码能跑,但一次都没测就提交)——它伤害的不是抽象的”质量”,而是队友的时间与项目的正确性。 - 为可观测性牺牲隐私:为了 SFB 和可调试性,把用户邮箱、IP 原样写进日志;日志的便利是收益,隐私损失是落在非自愿的第三方身上的成本。
- 为易理解性牺牲隐私/安全:把配置写得”一眼看懂”,于是把密钥硬编码进源码(这属于 ETU 的诱惑,却是安全与伦理的失败)。
- 为可修改性牺牲公平:为了以后好改,把处置策略做成可配置开关,并默认关闭最保守的安全行为——Uber 自动驾驶案例中的设计正是”系统设计排除了紧急制动的激活”。
- 为进度牺牲安全性:赶 deadline 时跳过测试、直接
- 关键规则与最佳实践:
- 冲突要显式记录,不要靠”大家都懂”来消化。
- 让最保守(最不容易造成不可逆伤害)的行为成为默认值;把”关闭它”变成需要论证的决定。
- 把伦理取舍写进规格说明(Reading 6/7):写清前置条件、后置条件,以及”在什么情况下系统必须拒绝服务”。
- 记住课程结论:正确、清晰、可修改只是起点;性能、安全、可用性、伦理都不能事后加装。
代码示例与对比分析
下面五组对比都取自本讲的真实议题:日志中的隐私、数据收集的范围、安全关键系统的默认值、算法偏见,以及”不可读的代码 + 不完整的规格”这一被本讲明确点名的伦理问题。每组都请用四种透镜各看一遍:Outcomes 是收益与成本怎么分配,Process 是这些收益与成本如何达成,Structure 是它形成了什么样的系统性模式,Character 是这个项目作为一个”人”表现出的品格。
场景 1:为了好排查问题,把用户邮箱和 IP 原样写进日志
❌ 错误代码
import java.util.logging.Logger;
/** 用户活动的记录器。 */
public class ActivityLogger {
private static final Logger LOG =
Logger.getLogger(ActivityLogger.class.getName());
/**
* 记录一次用户活动。
*
* @param email 用户邮箱(身份标识)
* @param ip 用户的 IP 地址(网络位置)
* @param action 活动名称
*/
public static void logActivity(String email, String ip, String action) {
// 为了方便排查问题,把能拿到的东西全部写进日志
LOG.info("user=" + email + " ip=" + ip + " action=" + action);
}
public static void main(String[] args) {
logActivity("alice@example.com", "18.26.4.9", "view-profile");
}
}
【错误代码的问题】
- 隐私伤害是永久且不可撤回的:日志一旦聚合、备份、导出到第三方分析平台,就再也收不回来;删除生产库里的记录并不会删除日志里的副本。
- 日志系统通常不设访问控制:日志往往对运维、实习生、外部监控服务都可见,实际访问范围远超”需要知道”的边界(对应 ACM 1.6 与 1.7)。
- 它静默地扩大了系统的影响圈:用户以为自己在和一个界面交互,实际上把自己的社交关系与网络位置交给了日志管道下游的所有人。
- 可测试性伪装成理由:它看起来是”为 SFB 服务”,实际上用一个未经验证的便利假设,换取了不可逆的隐私成本。
✅ 正确代码
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.logging.Logger;
/** 用户活动的记录器:只记录与运维目的直接相关、且无法反推身份的信息。 */
public class ActivityLogger {
private static final Logger LOG =
Logger.getLogger(ActivityLogger.class.getName());
/** 部署时注入的随机盐,不要写死在源码里。 */
private static final String SALT = System.getenv("ACTIVITY_LOG_SALT");
/**
* 记录一次用户活动。日志中只出现假名化的用户标识,不出现邮箱与 IP。
*
* @param userId 用户的内部 ID(不是邮箱、不是手机号)
* @param action 活动名称,取值来自固定的枚举集合
*/
public static void logActivity(String userId, String action) {
LOG.info("user=" + pseudonymize(userId) + " action=" + action);
}
/**
* 不可逆的假名化:同一用户在同一盐下可被关联,但无法从日志反推身份。
*
* @param userId 用户内部 ID
* @return 十六进制摘要字符串
*/
static String pseudonymize(String userId) {
try {
MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
byte[] digest = sha256.digest(
(SALT + userId).getBytes(StandardCharsets.UTF_8));
StringBuilder hex = new StringBuilder(digest.length * 2);
for (byte b : digest) {
hex.append(Character.forDigit((b >> 4) & 0xF, 16));
hex.append(Character.forDigit(b & 0xF, 16));
}
return hex.toString();
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 应当始终可用", e);
}
}
public static void main(String[] args) {
logActivity("u-10237", "view-profile");
}
}
【为什么这样更好】 日志仍然能完成它的本职工作(按用户维度关联行为、定位故障),但不再携带可直接识别个人的信息:邮箱与 IP 根本不进入这条链路,用户标识被加盐摘要成假名。同时,动作名称被限定为受控词表,避免有人为了”方便”把自由文本里的隐私内容一起塞进来。整个设计的默认值是”能少记就少记”,而不是”能多记就多记”。
【代码对比解说】 两段代码的功能差异极小,伦理差异极大——这正是本讲强调的重点:伦理问题常常藏在参数类型和日志格式里,而不在算法里。左版的 email 与 ip 是”看起来更有用”的字段,因为它们让调试变简单;右版用一个内部 ID 和一个摘要函数把同一件调试工作做完,代价是需要一次额外的用户 ID 传递。注意 pseudonymize 有意做成包级可见(static,无修饰符)以便测试——可测试性与隐私并不冲突,只要你在设计时就把它们放在一起考虑。还要注意右版并没有声称”绝对匿名”:加盐摘要是假名化(pseudonymization),不是匿名化(anonymization),因为掌握盐的人仍然可以枚举比对。诚实地说明这个边界,本身就是 Character 透镜下的品质。
【设计原则透视】 左版是典型的表示暴露:把内部数据(身份)原样穿过边界,写到一个不受 RI 保护的第三方存储里。右版给日志内容建立了一条抽象边界:外部只看到假名,真实身份的映射关系只存在于受控的地方。用 Reading 11(抽象函数与表示不变量)的语言说,日志格式有一个明确的 AF——”日志里的 user 字段表示某个用户,但只有配合外部映射才能确定是谁”;这条 AF 必须在注释里写清楚,否则未来的维护者会误以为它是邮箱。用 Reading 6(规格说明)的语言说,logActivity 的前置条件被收窄了(不接受邮箱),这比在后置条件里承诺”我们不会泄漏”要强得多——限制输入比承诺行为更可靠。此外,加盐值从环境变量读取而非硬编码,是 Reading 28 与安全实践的交汇点:源码会进版本库、会被 diff、会被贴到 issue 里(这也呼应 Reading 29 中”不要把密钥提交进仓库”)。
场景 2:默认收集一切,”以后也许用得上”
❌ 错误代码
import java.util.HashMap;
import java.util.Map;
/** 用户资料:什么都能存,因为"以后也许用得上"。 */
public class UserProfile {
private final Map<String, String> fields = new HashMap<>();
/** 把上游传过来的所有字段照单全收。 */
public void ingest(Map<String, String> everything) {
fields.putAll(everything);
}
/** 分析用的导出:把原始数据原样交给第三方。 */
public Map<String, String> exportForAnalytics() {
return fields; // 内部表示直接交给了调用者
}
public static void main(String[] args) {
UserProfile p = new UserProfile();
Map<String, String> signup = new HashMap<>();
signup.put("email", "alice@example.com");
signup.put("phone", "+1-617-555-0100");
signup.put("birthdate", "2001-03-14");
signup.put("homeAddress", "77 Massachusetts Ave");
signup.put("browsingHistory", "...");
p.ingest(signup);
System.out.println(p.exportForAnalytics().size()); // 5
}
}
【错误代码的问题】
- 过度收集(over-collection):注册流程并不需要生日、住址、浏览历史,但代码把它们全部收下——因为”以后也许用得上”。收集本身即风险,因为数据一旦存在就可能被泄漏、被传唤、被转卖。
- 没有同意边界:代码里没有任何地方表达”用户同意了什么”,所以没有任何机制能在未来阻止一个字段被加入导出。
- 返回可变内部引用:
exportForAnalytics直接把fields交出去,调用者可以清空或篡改它——这是 Reading 11 意义上的表示暴露,也是 Reading 24(队列/线程安全)意义上的并发隐患。 - 成本落在用户身上,收益归组织:这正是 Outcomes 透镜要你列清楚的那张表。
✅ 正确代码
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
/** 用户资料:只保存用户明确同意、且本次功能确实需要的字段。 */
public class UserProfile {
/** 注册流程真正需要的字段,并且用户已经勾选同意。 */
private static final Set<String> CONSENTED_FIELDS =
Set.of("email", "displayName");
private final Map<String, String> consented = new HashMap<>();
/**
* 保存一个字段——仅当它在同意范围内。
*
* @param field 字段名
* @param value 字段值
* @throws IllegalArgumentException 如果该字段不在用户同意范围内
*/
public void put(String field, String value) {
if (!CONSENTED_FIELDS.contains(field)) {
throw new IllegalArgumentException("未获得同意,不得收集字段: " + field);
}
consented.put(field, value);
}
/** 分析用的导出:只读视图,且只包含最小必要字段。 */
public Map<String, String> exportForAnalytics() {
return Collections.unmodifiableMap(new HashMap<>(consented));
}
public static void main(String[] args) {
UserProfile p = new UserProfile();
p.put("email", "alice@example.com");
p.put("displayName", "alice");
try {
p.put("browsingHistory", "..."); // 立刻失败,而不是悄悄收集
} catch (IllegalArgumentException e) {
System.out.println(e.getMessage());
}
}
}
【为什么这样更好】 三条防线被同时建立:(1) 白名单——只有 CONSENTED_FIELDS 里的字段能进入对象,任何越界尝试立刻抛出异常,而不是安静地存下来;(2) 只读导出——Collections.unmodifiableMap(new HashMap<>(consented)) 既做了防御性拷贝又做了不可变包装,第三方拿到的东西无法反向污染内部状态;(3) 显式失败优于静默接受——IllegalArgumentException 让”收集了不该收集的数据”变成一个会在开发阶段就暴露的缺陷,而不是一个三年后才被记者发现的丑闻。
【代码对比解说】 左版的 API 形状是 ingest(Map<String,String> everything):它把决定权交给调用者,自己只做搬运。右版的 API 形状是 put(String field, String value) 加一条白名单:它把伦理判断编码进了接口。这就是本讲”伦理结构”在代码层面的样子——不是靠每个工程师每次记得多想一步,而是靠接口本身让违规变得不可能(或者至少变得刺眼)。右版还有一个容易被忽略的收益:CONSENTED_FIELDS 是一个集中、可审查、可被法务与产品共同确认的清单,它在代码评审中一眼可见;左版则没有任何地方可以让人问”我们到底收集了什么”。
【设计原则透视】 这是 Reading 11(AF/RI)最直接的伦理应用:右版有一条明确的表示不变量——”consented 的键集合必须是 CONSENTED_FIELDS 的子集”,并且 put 在入口处维护它;左版的 RI 实际上是空集,因为什么都能进。同时它体现 Reading 8(不可变性)与 Reading 24(队列,防御性拷贝)的原则:跨越抽象边界时要么传不可变对象,要么传副本。用伦理语言重述技术规则:RI 保护的是”表示的正确性”,白名单保护的是”收集行为的正当性”,两者的实现手法完全相同。最后,Set.of 创建的是不可变集合(Java 9+;若要兼容更早版本可用 Collections.unmodifiableSet(new HashSet<>(Arrays.asList(...))))——让同意清单本身也无法被运行时改写。
场景 3:安全关键系统的默认值——把紧急处置责任交给”操作员”
❌ 错误代码
/** 自动驾驶的紧急制动子系统。 */
public class EmergencyBraking {
/** 碰撞时间阈值(秒)。 */
private static final double BRAKE_THRESHOLD_SECONDS = 1.2;
/**
* 遇到障碍时的处理。
*
* @param distanceMeters 与障碍物的距离(米)
* @param speedMps 当前车速(米/秒)
*/
public void onObstacle(double distanceMeters, double speedMps) {
double timeToCollision = distanceMeters / Math.max(speedMps, 1e-9);
if (timeToCollision <= BRAKE_THRESHOLD_SECONDS) {
// 为了减少"车辆行为异常"的可能,系统不自行紧急制动,
// 而是提示操作员介入
System.out.println("ALERT: operator must brake");
}
}
}
【错误代码的问题】
- 把最危险的默认值当成最安全的默认值:代码承认它已经识别出碰撞即将发生(
timeToCollision已经算出来了),却选择不采取唯一能减轻伤害的动作。这正是 sp21 原文引用的 NTSB 初步报告措辞:”emergency braking maneuvers are not enabled while the vehicle is under computer control, to reduce the potential for erratic vehicle behavior”。 - 隐含假设从未被验证:它假设操作员在场、专注、且反应时间足够。而在现实事故中,唯一被自动驾驶汽车撞死的行人,恰恰是在软件最终判断需要紧急制动时死去的——因为系统没有被编程为自行制动。sp22 版本引用了 NTSB 最终报告更严厉的说法:”The system design precluded activation of emergency braking for collision mitigation, relying instead on the operator’s intervention to avoid a collision or mitigate an impact.”
- 权衡没有被当作规格决定记录下来:这个选择(安全 vs 平顺)本应由产品、法务、安全团队共同签署,却在代码里表现为一条注释。
- 不可逆伤害 vs 可逆不适:把”乘客觉得刹车突兀”和”行人死亡”放在同一个天平上,却没有说明为什么前者更重。
✅ 正确代码
/**
* 自动驾驶的紧急制动子系统。
*
* 设计决策(见 design-review-2024-03.md):当碰撞时间低于阈值时,
* 系统自行制动,不依赖操作员是否在场。安全关键系统的默认行为是
* 失效安全(fail-safe):不确定时选择伤害更小的动作。
*/
public class EmergencyBraking {
private static final double BRAKE_THRESHOLD_SECONDS = 1.2;
/** 系统自行介入的次数,供事后审计与安全报告使用。 */
private int interventionCount;
/**
* 遇到障碍时的处理。
*
* @param distanceMeters 与障碍物的距离(米)
* @param speedMps 当前车速(米/秒)
*/
public void onObstacle(double distanceMeters, double speedMps) {
double timeToCollision = distanceMeters / Math.max(speedMps, 1e-9);
if (timeToCollision <= BRAKE_THRESHOLD_SECONDS) {
applyBrakes();
interventionCount += 1;
System.out.println("EMERGENCY BRAKE ttc=" + timeToCollision);
} else {
notifyOperator(distanceMeters, speedMps);
}
}
/** 驱动制动器。 */
private void applyBrakes() {
// 与车辆总线的接口略
}
/** 在还有充足余量时提醒操作员。 */
private void notifyOperator(double distanceMeters, double speedMps) {
System.out.println("ADVISORY distance=" + distanceMeters);
}
/** 供安全审计使用:系统在多少次险情中自行介入。 */
public int getInterventionCount() {
return interventionCount;
}
public static void main(String[] args) {
EmergencyBraking braking = new EmergencyBraking();
braking.onObstacle(5.0, 20.0); // 提醒
braking.onObstacle(10.0, 20.0); // 紧急制动
System.out.println(braking.getInterventionCount()); // 1
}
}
【为什么这样更好】 第一,动作与识别对齐:既然系统已经算出碰撞时间不足,它就必须做出与这一认知相称的动作,否则”识别”本身只是把责任转嫁给一个可能根本不在场的人。第二,默认值站在伤害更小的一边:不确定时选择制动,而不是选择”什么都不做”。第三,可审计:interventionCount 让”系统干预了多少次”成为可报告、可回归测试的事实——它把一次伦理判断变成了一个可持续检验的指标。第四,权衡被写进文档并在注释里指向它,这样未来的维护者能看见”这是一个被讨论过的决定”,而不是一条不知来由的注释。
【代码对比解说】 两段代码的差别只有三行:applyBrakes() 被调用而不是被跳过、计数器加一、文档注释指向一份设计评审记录。但它们的伦理性质完全不同。左版是一个不可逆伤害的默认值,并且它把一个未经验证的假设(”操作员会介入”)编码成了系统行为;右版把同样的信息(碰撞时间)用于驱动最保守的动作。注意右版并没有删除 notifyOperator,而是把它移到”还有余量”的分支里——伦理设计通常不是禁止某个功能,而是调整它在决策树中的位置。另外,右版保留了完整的阈值常量并让它是 static final,方便在被质疑时快速定位与复现计算。
【设计原则透视】 这是规格说明层面的伦理(Reading 6/7):左版的规格隐含地写着”本方法只负责报警”,这个后置条件在正常工况下无懈可击,在生死工况下是灾难;右版的规格把”在 TTC ≤ 阈值时必须使车辆减速”写成了硬性后置条件。它同时体现了 Reading 8(不可变性/状态最小化)与 Reading 9(避免调试)的反面:左版没有任何可观测状态,事故后无法回答”系统当时判断了什么”;interventionCount 给了调查者一个观测点。最后,它与”伦理结构”直接呼应:一条 if 分支的默认方向,是组织把风险推给谁的具体体现——代码里的默认值就是组织价值观的落地形式。
场景 4:人脸解锁的阈值——只看整体平均错误率
❌ 错误代码
import java.util.ArrayList;
import java.util.List;
/** 人脸解锁的阈值选择:只看整体平均错误率。 */
public class FaceUnlock {
/**
* 从冒名者得分中挑一个阈值。
*
* @param impostorScores 所有测试者的冒名者得分
* @return 使整体错误率最低的阈值
*/
public static double chooseThreshold(List<Double> impostorScores) {
double sum = 0.0;
for (double s : impostorScores) {
sum += s;
}
double mean = sum / impostorScores.size();
return mean + 0.05; // 在平均得分之上留一点余量
}
public static void main(String[] args) {
List<Double> scores = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
scores.add(0.10 + (i % 50) * 0.002);
}
System.out.println(chooseThreshold(scores));
}
}
【错误代码的问题】
- 平均值掩盖系统性差异:如果识别对不同肤色的用户误差差异很大,正负误差在求平均时会互相抵消,得到”整体表现良好”的假象,而某一群体始终被拒或被误认。
- 无法回答公平性问题:代码里根本不存在”群体”这个概念,所以任何公平性审计都无从下手——你连数据都没有。
- 把伦理问题伪装成技术指标问题:
chooseThreshold的签名暗示这是一个纯数值优化,于是没人会在评审会上问”这对谁更差”。 - 标签化风险:误拒让用户被锁在自己的设备外,误认则让攻击者进入——两种错误在不同的群体上代价不同,而平均指标对二者一视同仁。
✅ 正确代码
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/** 人脸解锁的阈值选择:验收标准是"最差子群体",不是整体平均。 */
public final class FaceUnlock {
/** 合格线:任何一个子群体的错误率都不得超过它。 */
public static final double MAX_GROUP_ERROR_RATE = 0.01;
/** 评估函数:给定阈值,给出各子群体的错误率。 */
public interface Evaluator {
Map<String, Double> at(double threshold);
}
/**
* 返回最差子群体的错误率。
*
* @param groupErrorRates 子群体名称到错误率的映射
* @return 最大的错误率(没有子群体时返回 0.0)
*/
public static double worstGroupErrorRate(Map<String, Double> groupErrorRates) {
double worst = 0.0;
for (double rate : groupErrorRates.values()) {
worst = Math.max(worst, rate);
}
return worst;
}
/**
* 选择一个满足所有子群体要求的阈值。
*
* @param candidates 候选阈值,按偏好顺序排列
* @param evaluator 评估函数
* @return 第一个通过"最差子群体"验收的阈值
* @throws IllegalArgumentException 如果没有任何候选阈值合格
*/
public static double chooseThreshold(List<Double> candidates,
Evaluator evaluator) {
for (double threshold : candidates) {
Map<String, Double> rates = evaluator.at(threshold);
if (worstGroupErrorRate(rates) <= MAX_GROUP_ERROR_RATE) {
return threshold;
}
}
throw new IllegalArgumentException("没有阈值能通过最差子群体验收");
}
public static void main(String[] args) {
Evaluator evaluator = threshold -> {
Map<String, Double> rates = new HashMap<>();
rates.put("groupA", 0.002 / threshold);
rates.put("groupB", 0.003 / threshold);
rates.put("groupC", 0.008 / threshold);
return rates;
};
System.out.println(chooseThreshold(
List.of(0.20, 0.30, 0.40, 0.60, 0.80), evaluator)); // 0.8
}
}
【为什么这样更好】 验收标准从”平均错误率”换成了最差子群体错误率(worst-group error rate):一个阈值只有在每一个子群体上都达标才算合格。这条规则的作用是结构性的——它把”某些人体验更差”从统计噪声变成了会阻塞发布的硬性条件。为了让这条规则可执行,代码必须显式地把”子群体”作为一等概念(Map<String, Double> 的键),并要求评估函数按群体报告,而不是吐出一个总数。当没有任何候选阈值能通过时,系统明确失败(抛异常),而不是悄悄退回到一个会伤害某一群体的默认值。
【代码对比解说】 左版的签名 chooseThreshold(List<Double>) 是”给我数字,我给你数字”,它在接口层面就抹掉了公平性;右版把 Evaluator 作为参数注入,是因为评估本身需要按群体切分数据,这件事不能藏在被调函数里假装不存在。注意右版仍然是一个”贪心地按偏好顺序取第一个合格阈值”的简单策略——伦理改进通常不是更复杂的算法,而是换一个验收标准。还要注意 MAX_GROUP_ERROR_RATE 是一个具名常量并附有注释,这让它在代码评审里可被质疑、可被修改、可被测试引用;把它藏进某个魔法数字里,就等于把公平性决定藏了起来。
【设计原则透视】 这是规格说明(Reading 6/7)中”前置条件/后置条件应当可检验”的伦理版本:worstGroupErrorRate <= MAX_GROUP_ERROR_RATE 是一句能被自动化测试直接检查的后置条件,而”识别要公平”不能被检查。它也对应 Structure 透镜 的核心:公平问题的本质是”某一类人系统性地承受更差的模式”,所以必须用按群体分组的做法把它暴露出来。从 Reading 11(AF/RI)的角度看,Evaluator 是一个清晰的抽象边界:它只承诺”给定阈值返回各群体错误率”,把数据来源、样本量、分组定义都留给实现,这样将来加入新的敏感属性(性别、年龄、口音)不需要修改 chooseThreshold。补充说明:真实系统还会同时报告误拒率与误认率,并记录样本量,因为一个只有 3 个样本的子群体上的”0% 错误率”毫无意义。
场景 5:不可读的代码与不完整的规格——本讲点名的”Alice 与 Bob”问题
❌ 错误代码
import java.util.ArrayList;
import java.util.List;
public class Datalist {
private List<String> l = new ArrayList<>();
// x 是什么?返回的是什么?会不会改到传入的列表?
// n 的取值边界在哪里?调用者完全不知道。
List<String> f(List<String> x, int n) {
List<String> r = new ArrayList<>();
for (int i = 0; i < x.size(); i++) {
if (x.get(i).length() >= n) {
r.add(x.get(i));
}
}
return r;
}
}
【错误代码的问题】
- 规格缺失导致调用者按自己的猜测行事:本讲的练习正是这样描述的——Alice 写了一个方法,代码不可读、规格不完整,队友 Bob 即将写调用它的代码,于是误解了它的行为(本讲练习给出的透镜是 Character:Alice 的行为表现出的不是”技术能力不足”,而是对同事不够负责的品格)。
- 边界行为未定义:
n为负数时会发生什么?返回的列表调用者能不能改?参数列表会不会被修改?这些都不是”细节”,而是别人据此写代码的全部依据。 - 命名抹掉了语义:
Datalist、f、l、x、n、r全是单字母或无语义的词,代码无法自我解释,读者只能靠反推。 - 未使用的可变字段:
private List<String> l从未被使用,读者会怀疑它是否隐含某种状态语义——这让 ETU 进一步恶化。
✅ 正确代码
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/** 只做筛选、不修改输入的字符串列表工具。 */
public class StringFilter {
/**
* 返回 strings 中长度至少为 minLength 的元素,保持原有顺序。
*
* @param strings 待筛选的字符串;本方法不修改它,调用者之后仍可修改它
* @param minLength 保留的最小长度,必须 >= 0
* @return 新的不可变列表,与 strings 不共享任何可变状态
* @throws IllegalArgumentException 如果 minLength < 0
*/
public static List<String> atLeast(List<String> strings, int minLength) {
if (minLength < 0) {
throw new IllegalArgumentException("minLength: " + minLength);
}
List<String> result = new ArrayList<>();
for (String s : strings) {
if (s.length() >= minLength) {
result.add(s);
}
}
return Collections.unmodifiableList(result);
}
}
【为什么这样更好】 第一,规格完整:Javadoc 写清了前置条件(minLength >= 0)、后置条件(返回新列表、保持顺序、不修改输入)、返回值是否可变(不可变)、以及违反前置条件时的行为(抛异常)。Bob 不需要读实现就能正确调用它。第二,命名承载语义:StringFilter.atLeast 让调用点 StringFilter.atLeast(names, 3) 读起来接近自然语言。第三,防御性且不可变:参数被当作只读来用,返回值被包装成不可变,消除了”谁改了谁的列表”这类经典困惑(Reading 8、Reading 24)。第四,边界被显式处理:负数长度立刻失败,而不是产生一个”看起来正常”的空列表。
【代码对比解说】 两版的功能在正常输入下完全一致,区别在于别人能否安全地依赖它。本讲把它归到 Character 透镜,因为这里的失败不是”结果变坏了”(Outcomes),也不是”流程被跳过了”(Process),而是一个人对同事的态度:写不可读的代码、留不完整的规格,等于把自己的方便建立在别人的时间上。这也解释了为什么本讲会把”代码质量”放进伦理讨论:团队里的代码是共享物,低质量的共享物是一种(轻度的)不尊重。值得强调的是,右版的改进没有增加任何复杂度:它只是把原本存在于 Alice 头脑中的信息写了下来。
【设计原则透视】 这是 Reading 6(规格说明)与 Reading 7(设计规格)的直接应用:规格是调用者与实现者之间的合同,规格含糊就等于合同漏洞,而漏洞的成本总是由调用者承担。它也对应 Reading 11(AF/RI):Collections.unmodifiableList 明确划定了抽象边界,返回对象不允许被外部改写,因此实现者可以放心地在将来改变内部实现。用 Reading 4(代码评审)的话说,这类代码在评审里应该被直接拦下——评审者最该问的问题不是”这段代码能不能跑”,而是”别人读了能不能用对”。补充一句:本讲另一个练习(”Commit it”)与此互补——Charlie 写了一个完全能工作的方法,一次都没测试就提交到团队仓库;那里用的是 Process 透镜,因为代码的产出本身没问题,错的是他跳过了团队约定的过程,把不确定性留给了队友。
与其他设计原则的关联
- 与 Reading 4(代码评审 Code Review):本讲反复建议把”代码评审”作为探测组织伦理结构的探针(面试问题”你们怎么做代码评审”),因为评审是唯一一个”有人会认真读你的规格、并问这段代码会被怎样滥用”的固定环节。伦理评审不是另开一个会,而是给现有评审加一段议程。
- 与 Reading 5(版本控制 Version Control)/ Reading 29(团队版本控制 Team Version Control):本讲练习中 Charlie 的做法(代码能跑、从不测试、直接提交)在 Reading 29 的团队准则里被逐条反驳:Review what you commit、Run the tests、Don’t use
commit -a。伦理要求”不要给队友制造清理垃圾的时间”,而版本控制纪律是它的工程化落地。同时,Reading 29 里”不要把编译产物和密钥提交进仓库”也直接服务于本讲的隐私与保密义务(ACM 1.6、1.7)。 - 与 Reading 6/7(规格说明与设计规格 Specifications / Designing Specifications):伦理取舍必须写成规格——前置条件、后置条件、异常行为。模糊的规格是伦理风险,因为它把解释权交给了最方便的一方。Scenario 5 就是这一点的教科书案例。
- 与 Reading 8/9(不可变性与避免调试 Immutability / Avoiding Debugging):不可变对象与防御性拷贝减少”谁改了谁的数据”这类事故,同时减少为了排查事故而过度记录用户数据的需求——更少的状态意味着更少的监控冲动,间接减轻隐私压力。
- 与 Reading 11(抽象函数与表示不变量 AF/RI):本讲的三组对比都可以用 AF/RI 的语言重写:日志的假名化是给日志内容定义一条诚实、受限的 AF;同意白名单是一条表示不变量;
unmodifiableList是抽象边界的守卫。伦理约束在代码里就是 RI。 - 与 Reading 13(调试 Debugging):事故复盘(如 NTSB 报告)依赖系统留下的可观测痕迹。Scenario 3 加入
interventionCount正是为了让”系统当时判断了什么”可被事后检验——这与 Reading 9/13 的”避免调试”原则一脉相承,只是服务对象从工程师扩展到了公众调查者。 - 与 Reading 21/23(并发与互斥 Concurrency / Mutual Exclusion):
exportForAnalytics返回可变内部引用在单线程下是表示暴露,在并发下是数据竞争;跨线程共享的数据还会引出”日志聚合服务能否看到个人数据”这类新的隐私边界。补充说明:Java 中可用Collections.unmodifiableMap或Map.copyOf(Java 10+)实现不可变快照。 - 与 Reading 22(Promise)/ Reading 25(Sockets and Networking):一旦数据离开进程边界(网络请求、第三方分析 SDK),隐私与同意义务的复杂度会陡增;本讲的”扩展的影响圈”在分布式场景下会扩大好几层。
- 与 Reading 29(团队版本控制)互为前后:Reading 29 讨论团队如何协作产出可维护的代码,本讲讨论这次协作对组织之外的人意味着什么。先读 29 再读 28,是”我们怎么一起工作”到”我们的工作对谁负责”的自然递进。
关键要点
- 伦理性是与 SFB/ETU/RFC 并列的软件属性,而且不能事后加装:请在每一轮迭代里就把它作为验收项,而不是上线前的合规检查。
- 用四种透镜逐个扫描,别只用最顺手的那一个:Outcomes(成本与收益归谁)、Process(手段是否正当)、Structure(是否形成系统性伤害模式)、Character(这个项目像什么样的人)。
- 把伦理判断编码进接口、默认值和 RI:白名单、失效安全的默认值、不可变返回、明确的异常——代码结构比个人自觉更可靠,这也是”伦理结构而非伦理个体”的工程含义。
- 规格即责任:写出完整的前置条件、后置条件和异常行为,是对同事、对用户、对未来的自己的最低义务;本讲的 Alice/Bob 与 Charlie 两个练习分别对应”写不清楚”和”没验证就交付”。
- 在做任何取舍时留下书面记录:把”我们考虑过、为什么这样决定”写进设计文档,让权衡成为可审查的产物——它是未来唯一能证明”我们当时认真想过”的东西。
常见陷阱与注意事项
- 把伦理当成”公关/合规问题”、以为它能像普通 bug 一样在最后集中修复 → 后果:伦理检查发生在功能冻结之后,任何修改都变成”来不及了”,所有问题都被降级为文档免责声明;而且这类缺陷往往牵涉产品策略、激励与组织结构(本讲原话:”it involves so much more than just the system and its source code”),越晚发现修复代价越高,甚至根本不可能由工程师单独修复。正确做法是在设计评审阶段就用四个透镜提问。
- 只做后果计算,忽略过程与结构 → 后果:用”总体收益更大”为欺骗性数据收集、或为某一群体系统性更高的错误率辩护;讨论永远无法收敛,因为双方在用不同透镜说话。
- 相信”只要雇好人、走对路就没事” → 后果:组织复制周围社会的伦理失败。本讲的反问很到位:如果面试官说”我们不需要代码评审,我们相信每个工程师”,你会警惕;那么对”我们没有评审滥用风险的流程”也应该同样警惕。
- 为了让代码”能跑、好调试”而顺手记录过多用户数据 → 后果:把不可逆的隐私成本当作可回滚的工程决定(Scenario 1);日志一旦扩散就收不回来。
- 在安全关键系统中把最保守的行为设为默认关闭 → 后果:直接对应 Uber 自动驾驶事故——系统看到了危险,却被设计成不采取行动;”减少车辆行为异常”这种可逆的不适被拿来与不可逆的人身伤害比较,而没有人为此签署决定(Scenario 3)。
- 用整体指标代替分群体指标 → 后果:平均值把系统性差异抹平,公平性问题在通过验收的报表里彻底消失(Scenario 4)。补充说明:真实评估必须同时报告各群体的样本量,否则小样本上的”完美指标”会误导决策。
思考题(带答案)
问题 1:你的软件在用户不知情的情况下记录用户活动并把数据卖给第三方。请分别用 Outcomes、Process、Structure、Character 四个透镜说明这件事为什么是坏的;如果只能用一句话向产品经理说明,你会选哪个透镜,为什么?
答案:Outcomes 透镜——用户承担了隐私暴露的成本(可能的骚扰、歧视、要挟),而收益全部归公司;成本与收益的分配不对称。Process 透镜——问题不只是”数据被卖了”,而是”在用户不知情、未同意的情况下被卖了”:手段本身(隐瞒)就是错的,即使服务因此变得更好也不能洗白。Structure 透镜——这种做法的真正危害在于它形成的模式:默认收集、默认共享、默认不告知,久而久之整个行业把”用户的数据属于数据管道”当作常态,个人无论怎么小心都无法退出。Character 透镜——把项目当成一个人看:它偷偷拿走你的东西去换钱,这是不诚实、不尊重人的品格,与”这个产品是否好用”无关。(补充说明:课程原文此处为练习题,没有公布答案;以上是依据四个透镜定义给出的推理。就本题而言,把重点放在 Outcomes 或 Process 上都是站得住的,选择取决于你要说服谁。如果只能说一句话给产品经理,通常选 Outcomes 最有效——因为”成本落在用户身上、收益归我们”是商业语言;而对工程与法务团队,Process(未经同意的收集本身就不可接受)更能形成硬约束。真正的教学点是:四个透镜都说一遍,才能判断这个决定在每个维度上是否都站得住。)
问题 2:本讲用 Alice(不可读的代码与不完整的规格,导致同事误解)和 Charlie(代码能跑但一次都没测试就提交到团队仓库)说明了两种不同的失职。请指出各自对应的道德透镜,并解释为什么它们不是同一个透镜。
答案:Alice 对应 Character——她的失败不在于产出了坏结果(Outcomes),她的方法也并没有违反某条流程(Process);问题在于她作为一个协作者所表现出的品格:把自己的方便建立在同事的时间与困惑之上,对自己的共享物缺乏责任感。Charlie 对应 Process——他的代码”perfectly working”(本讲原文措辞),所以从结果看暂时没有伤害任何人;他错在跳过了团队约定的过程:不测试就提交,等于把自己的不确定性转嫁给队友,让”随时可以拉取的仓库”变成一个不可信的起点。两者之所以不是同一个透镜,是因为它们指向不同的修复手段:Character 问题靠文化、评审规范与共同标准来改(”我们这里不允许别人看不懂的代码”),Process 问题靠流程与自动化来改(提交前必须跑测试、自动化构建通过才能推送,对应 Reading 29 的”Run the tests”与”Automate”)。这也正好说明为什么四个透镜缺一不可:如果只用 Outcomes,Charlie 在 bug 暴露之前都不会被认为做错了任何事。
问题 3:一个公司要上线”人脸解锁”功能,整体识别准确率 99.2%,但在深色皮肤用户上的误拒率是其他用户的 6 倍。技术负责人说:”我们有 99.2% 的准确率,而且我们已经发布了隐私政策。”请用本讲的概念指出这句话的两个问题,并给出至少两条可执行的设计改动。
答案:两个问题。第一,”整体准确率”这个指标本身就是选择过的(Outcomes/Structure 透镜):把 6 倍的差异平均掉,等于用多数群体的良好表现掩盖少数群体承受的系统性伤害;本讲的练习明确指出”准确率对深色皮肤用户显著更差”是要用结构透镜去看的问题——它是模式性的,不是一个随机误差。第二,”我们发布了隐私政策”是过程合格的辩护,但过程上的合规声明不能替代对具体伤害的评估(本讲关于”伦理结构”的讨论正是要拆掉这种”我们已经走了流程所以没问题”的自我安慰;”有没有隐私政策”与”这个系统是否公正地工作”是两个不同的问题)。可执行的设计改动:(1) 把验收标准从”整体准确率”改成”最差子群体错误率不超过阈值”,并在没有阈值合格时阻止发布(对应 Scenario 4 的 MAX_GROUP_ERROR_RATE 与显式抛出异常);(2) 为误拒率高的群体提供不依赖生物特征的降级路径(PIN、备用设备),并在设计文档中记录该降级路径作为后置条件;(3) 把分群体评估纳入持续集成,让每次模型更新都必须重新通过最差群体验收,而不是一次性的人工评估。补充说明:还要记录每个子群体的样本量,并让评估所用的分组定义公开可审查——否则你无法证明”达标”不是靠样本稀薄换来的。
