o (Golang) 是 Google 开发的一种编译型、并发型,并具有垃圾回收功能的编程语言,于 2009 年 11 月正式宣布推出成为开源项目,2012 年发布 1.0 版本。
如今,谷歌仍在继续投资该语言,最新的稳定版本是 1.22.5。
在最新的 TIOBE 7 月榜单中,Go 排名第七。与其他所有编程语言一样,有人喜欢 Go 语言也有人讨厌,同样的功能既会带来诋毁也会带来赞美。
InfoWorld 撰稿分析了开发人员喜欢或讨厌 Go 语言的 8 个原因,具体如下。
Go 的设计者特意打造了一种易于学习的语言,没有太多复杂的功能或特异之处。
喜欢的点在于:对于新程序员和团队成员来说,更简单的语言更容易学习和掌握。由于老程序员可以很快学会 Go 的新技巧,因此项目人员配备也更容易。不仅如此,相关代码通常也更容易阅读。
讨厌的点在于:太过简单反而束缚了手脚。“一个女巫会选择一本简略的咒语书吗?四分卫会选择只有几个战术的战术书吗?一些程序员认为,用 Go 编程就像一只手被绑在背后。这种语言缺乏其他语言设计者向世界展示的所有聪明才智,而这是要付出高昂代价的。”
最初开发人员希望创建一种小型语言,为此他们牺牲了其他语言中许多受欢迎的功能。Go 是一种精简的语言,即可以满足用户的需求,又省去了一些繁琐。
喜欢的点在于:许多开发人员都称赞 Go 的简单性。Go 不需要他们掌握或保持数十种功能的专业知识才能熟练使用。
讨厌的点在于:每个人都有一些喜欢的功能和技巧,但 Go 很可能不提供这些功能和技巧。开发人员有时会抱怨,他们只需用 COBOL 或 Java 或其他喜欢的语言写一行代码,就可以完成在 Go 中可以完成的相同任务。
Go 的设计团队确实基于传统 C 语言改进了一些缺陷,并简化了一些细节,使其看起来和感觉更现代。但在大多数情况下,Go 完全继承了始于 C 语言的传统。
喜欢的点在于:在 C 语言风格中成长起来的程序员会直观地理解 Go 的大部分内容。他们将能够非常快速地学习语法,并且可以花时间学习 Go 相较 C 或 Java 的一些改进之处。
讨厌的点在于:很多方面,Python 的设计都是与 C 截然相反的。对于喜欢 Python 方法的人而言,会觉得 Go 有很多让人讨厌的地方。
从一开始,Go 的创建者就希望不仅定义语法,还定义语言的大部分风格和使用模式。
喜欢的点在于:Go 的强惯用规则确保代码更容易理解,团队将减少对风格的争论。
讨厌的点在于:所有这些额外的规则和惯例都像束缚。“程序员在生活中拥有一点自由有那么糟糕吗?”
喜欢的点在于:Go 方法承认错误存在,并鼓励程序员制定处理错误的计划。这就鼓励程序员提前计划,并建立起一种弹性,从而开发出更好的软件。
讨厌的点在于:不必要的错误处理会让 Go 函数变得更长、更难理解。通常情况下,deep chain 中的每个函数都必须包含类似的代码,这些代码或多或少会执行相同的操作,并产生相同的错误。其他语言(如 Java 或 Python)鼓励程序员将错误 "throw" 到链上的特定代码块中,以 "catch" 它们,从而使代码更简洁。
喜欢的点在于:当许多标准功能由默认库处理时,大多数代码更易于阅读。因为没有人会编写自己的版本,或争论哪个软件包或第三方库更好。
讨厌的点在于:一些人认为,竞争能更好的推动需求和创新。有些语言支持多个软件包来处理相同的任务,表明大家对此确实有着浓厚的兴趣和丰富的文化。
Go 团队的目标之一是让部署 Go 程序变得更容易,他们通过将所有程序打包成一个可执行文件来实现这一目标。
喜欢的点在于:磁盘空间很便宜。当安装了不同版本的库时,在陌生的位置部署代码可能是一场噩梦。Go 开发人员只需构建一个可执行文件就可以节省大量时间。
讨厌的点在于:我的磁盘上有多少份 Go 库?如果我有 100 个程序,那就意味着 100 份。在某种程度上,效率是一个考虑因素。没错,磁盘空间比以往任何时候都便宜,但内存带宽和缓存仍然是影响执行速度的首要问题。
Go 由谷歌开发,这家大公司一直是 Go 的主要支持者之一。大多数情况下,Go 开发工作都直接来自 Google 内部。
喜欢的点在于:如今,大量的工作涉及为服务器和客户端编写代码,而这类工作在 Google 的工作量中占了很大一部分。如果 Go 对谷歌有利,那么对我们这些以同样方式工作的人也有好处。如果谷歌的工程师们能开发出自己喜欢的东西,那么任何有类似项目的人都会同样喜欢它。
讨厌的点在于:这并不是说人们不喜欢谷歌本身,而是程序员不信任中心化组织、供应商锁定和缺乏控制等问题,对任何试图管理技术堆栈的人来说都是严重的问题。谷歌的慷慨仍然让程序员们心存疑虑,尤其是当其他语言都拥有了围绕它们构建的庞大的开源社区。
Reference
https://www.infoworld.com/article/2514123/8-reasons-developers-love-go-and-8-reasons-they-dont.html
文[1]:Mateusz Piorowski[2] - 2023.07.24
先来了解一下我的背景吧。我是一名软件开发人员,有大约十年的工作经验,最初使用 PHP,后来逐渐转向 JavaScript。
大约五年前,我开始使用 TypeScript,从那时起,我就再也没有使用过 JavaScript。从开始使用 TypeScript 的那一刻起,我就认为它是有史以来最好的编程语言。每个人都喜欢它,每个人都在使用它……它就是最好的,对吗?对吧?对不对?
是的,然后我开始接触其他的语言,更现代化的语言。首先是 Go,然后我慢慢地把 Rust 也加了进来。
当你不知道存在不同的事物时,就很难错过它们。
我在说什么?Go 和 Rust 的共同点是什么?Error,这是最让我印象深刻的一点。更具体地说,这些语言是如何处理错误的。
JavaScript 依靠抛出异常来处理错误,而 Go 和 Rust 则将错误视为值。你可能会觉得这没什么大不了的......但是,好家伙,这听起来似乎微不足道;然而,它却改变了游戏规则。
让我们来了解一下它们。我们不会深入研究每种语言,只是想了解一般的处理方式。
让我们从 JavaScript/TypeScript 和一个小游戏开始。
给自己五秒钟的时间来查看下面的代码,并回答为什么我们需要用 try/catch 来包裹它。
try {
const request={ name: “test”, value: 2n };
const body=JSON.stringify(request);
const response=await fetch("https://example.com", {
method: “POST”,
body,
});
if (!response.ok) {
return;
}
// 处理响应
} catch (e) {
// 处理错误
return;
}
那么,我想你们大多数人都猜到了,尽管我们检查了 response.ok,但 fetch 方法仍然可能抛出一个异常。response.ok 只能“捕获” 4xx 和 5xx 的网络错误。但是,当网络本身失败时,它会抛出一个异常。
但我不知道有多少人猜到 JSON.stringify 也会抛出一个异常。原因是请求对象包含 bigint (2n) 变量,而 JSON 不知道如何将其序列化为字符串。
所以,第一个问题是,我个人认为这是 JavaScript 最大的问题:我们不知道什么可能会抛出一个异常。从 JavaScript 错误的角度来看,它与下面的情况是一样的:
try {
let data=“Hello”;
} catch (err) {
console.error(err);
}
JavaScript 不知道;JavaScript 也不在乎。你应该知道。
第二个问题,这是完全可行的代码:
const request={ name: “test”, value: 2n };
const body=JSON.stringify(request);
const response=await fetch("https://example.com", {
method: “POST”,
body,
});
if (!response.ok) {
return;
}
没有错误,没有语法检查,尽管这可能会导致你的应用程序崩溃。
现在,在我的脑海中,我听到的是:“有什么问题,在任何地方使用 try/catch 就可以了”。这就引出了第三个问题:我们不知道哪个异常被抛出。当然,我们可以通过错误信息来猜测,但对于规模较大、可能发生错误的地方较多的服务/函数来说,又该怎么办呢?你确定用一个 try/catch 就能正确处理所有错误吗?
好了,是时候停止对 JS 的挑剔,转而讨论其他问题了。让我们从这段 Go 代码开始:
f, err :=os.Open(“filename.ext”)
if err !=nil {
log.Fatal(err)
}
// 对打开的 *File f 进行一些操作
我们正在尝试打开一个返回文件或错误的文件。你会经常看到这种情况,主要是因为我们知道哪些函数总是返回错误,你绝不会错过任何一个。这是第一个将错误视为值的例子。你可以指定哪个函数可以返回错误值,然后返回错误值,分配错误值,检查错误值,处理错误值。
这也是 Go 被诟病的地方之一——“错误检查代码”,其中 if err !=nil { … 有时候的代码行数比其他部分还要多。
if err !=nil {
…
if err !=nil {
…
if err !=nil {
…
}
}
}
if err !=nil {
…
}
…
if err !=nil {
…
}
尽管如此,相信我,这些努力还是值得的。
最后,让我们看看 Rust:
let greeting_file_result=File::open(“hello.txt”);
let greeting_file=match greeting_file_result {
Ok(file)=> file,
Err(error)=> panic!("Problem opening the file: {:?}", error),
};
这里显示的是三种错误处理中最冗长的一种,具有讽刺意味的是,它也是最好的一种。首先,Rust 使用其神奇的枚举(它们与 TypeScript 的枚举不同!)来处理错误。这里无需赘述,重要的是它使用了一个名为 Result 的枚举,有两个变量:Ok 和 Err。你可能已经猜到,Ok 包含一个值,而 Err 包含……没错,一个错误 :D。
它也有很多更方便的处理方式来缓解 Go 的问题。最知名的一个是 ? 操作符。
let greeting_file_result=File::open(“hello.txt")?;
这里的总结是,Go 和 Rust 总是知道哪里可能会出错。它们强迫你在错误出现的地方(大部分情况下)立即处理它。没有隐藏的错误,不需要猜测,也不会因为意外的错误而导致应用程序崩溃。
而这种方法就是更好,好得多。
好了,是时候实话实说了;我撒了点小谎。我们无法让 TypeScript 的错误像 Go / Rust 那样工作。限制因素在于语言本身,它没有合适的工具来做到这一点。
但我们能做的就是尽量使其相似。并且让它变得简单。
从这里开始:
exporttype Safe<T>=| {
success: true;
data: T;
}
| {
success: false;
error: string;
};
这里没有什么花哨的东西,只是一个简单的通用类型。但这个小东西却能彻底改变代码。你可能会注意到,这里最大的不同就是我们要么返回数据,要么返回错误。听起来熟悉吗?
另外......第二个谎言是,我们确实需要一些 try/catch。好在我们只需要两个,而不是十万个。
exportfunction safe<T>(promise: Promise<T>, err?: string): Promise<Safe<T>>;
exportfunction safe<T>(func: ()=> T, err?: string): Safe<T>;
exportfunction safe<T>(
promiseOrFunc: Promise<T> | (()=> T),
err?: string
): Promise<Safe<T>> | Safe<T> {
if (promiseOrFunc instanceofPromise) {
return safeAsync(promiseOrFunc, err);
}
return safeSync(promiseOrFunc, err);
}
asyncfunction safeAsync<T>(
promise: Promise<T>,
err?: string
): Promise<Safe<T>> {
try {
const data=await promise;
return { data, success: true };
} catch (e) {
console.error(e);
if (err !==undefined) {
return { success: false, error: err };
}
if (e instanceofError) {
return { success: false, error: e.message };
}
return { success: false, error: "Something went wrong" };
}
}
function safeSync<T>(func: ()=> T, err?: string): Safe<T> {
try {
const data=func();
return { data, success: true };
} catch (e) {
console.error(e);
if (err !==undefined) {
return { success: false, error: err };
}
if (e instanceofError) {
return { success: false, error: e.message };
}
return { success: false, error: "Something went wrong" };
}
}
“哇,真是个天才。他为 try/catch 创建了一个包装器。” 是的,你说得没错;这只是一个包装器,我们的 Safe 类型作为返回类型。但有时候,简单的东西就是你所需要的。让我们将它们与上面的例子结合起来。
旧的(16 行)示例:
try {
const request={ name: “test”, value: 2n };
const body=JSON.stringify(request);
const response=await fetch("https://example.com", {
method: “POST”,
body,
});
if (!response.ok) {
// 处理网络错误
return;
}
// 处理响应
} catch (e) {
// 处理错误
return;
}
新的(20 行)示例:
const request={ name: “test”, value: 2n };
const body=safe(
()=>JSON.stringify(request),
“Failed to serialize request”,
);
if (!body.success) {
// 处理错误(body.error)
return;
}
const response=await safe(
fetch("https://example.com", {
method: “POST”,
body: body.data,
}),
);
if (!response.success) {
// 处理错误(response.error)
return;
}
if (!response.data.ok) {
// 处理网络错误
return;
}
// 处理响应(body.data)
是的,我们的新解决方案更长,但性能更好,原因如下:
但现在王牌来了,如果我们忘记检查这个:
if (!body.success) {
// 处理错误 (body.error)
return;
}
事实是……我们不能忘记。是的,我们必须进行这个检查。如果我们不这样做,body.data 将不存在。LSP 会通过抛出 “Property ‘data’ does not exist on type ‘Safe’” 错误来提醒我们。这都要归功于我们创建的简单的 Safe 类型。它同样适用于错误信息,我们在检查 !body.success 之前无法访问 body.error。
这是我们应该欣赏 TypeScript 以及它如何改变 JavaScript 世界的时刻。
以下也同样适用:
if (!response.success) {
// 处理错误 (response.error)
return;
}
我们不能移除 !response.success 检查,否则,response.data 将不存在。
当然,我们的解决方案也不是没有问题。最大的问题是你必须记住要用我们的 safe 包装器包装可能抛出异常的 Promise/函数。这个 “我们需要知道” 是我们无法克服的语言限制。
这听起来很难,但其实并不难。你很快就会意识到,你代码中的几乎所有 Promises 都会出错,而那些会出错的同步函数你也知道,而且它们的数量并不多。
不过,你可能会问,这样做值得吗?我们认为值得,而且在我们团队中运行得非常好:)。当你看到一个更大的服务文件,没有任何 try/catch,每个错误都在出现的地方得到了处理,逻辑流畅......它看起来就很不错。
这是一个使用 SvelteKit FormAction 的真实例子:
exportconst actions={
createEmail: async ({ locals, request })=> {
const end=perf(“CreateEmail”);
const form=await safe(request.formData());
if (!form.success) {
return fail(400, { error: form.error });
}
const schema=z
.object({
emailTo: z.string().email(),
emailName: z.string().min(1),
emailSubject: z.string().min(1),
emailHtml: z.string().min(1),
})
.safeParse({
emailTo: form.data.get("emailTo"),
emailName: form.data.get("emailName"),
emailSubject: form.data.get("emailSubject"),
emailHtml: form.data.get("emailHtml"),
});
if (!schema.success) {
console.error(schema.error.flatten());
return fail(400, { form: schema.error.flatten().fieldErrors });
}
const metadata=createMetadata(URI_GRPC, locals.user.key)
if (!metadata.success) {
return fail(400, { error: metadata.error });
}
const response=awaitnewPromise<Safe<Email__Output>>((res)=> {
usersClient.createEmail(schema.data, metadata.data, grpcSafe(res));
});
if (!response.success) {
return fail(400, { error: response.error });
}
end();
return {
email: response.data,
};
},
} satisfies Actions;
这里有几点需要指出:
看起来是不是很简洁?那就试试吧!也许它也非常适合你 :)
感谢阅读。
附注:下面的代码对比是不是看起来很像?
f, err :=os.Open(“filename.ext”)
if err !=nil {
log.Fatal(err)
}
// 使用打开的 *File f 做一些事情
const response=await safe(fetch(“https://example.com"));
if (!response.success) {
console.error(response.error);
return;
}
// 使用 response.data 做一些事情
[1] 原文: https://betterprogramming.pub/typescript-with-go-rust-errors-no-try-catch-heresy-da0e43ce5f78
[2] Mateusz Piorowski: https://medium.com/@mateuszpiorowski
一篇文章Go设计模式(2)-面向对象分析与设计里讲过,做设计最重要的是保留合适的扩展点。如何才能设计出合适的扩展点呢?
这篇文章会讲解一下经典的设计原则。这些设计原则大家可能都听过,但可能没有想过为什么会提炼出这些原则,它们有什么作用。对内一个设计原则,我会尽量找到一个实例,说明它的重要性。通过实例来感受原则,比起只看枯燥的文字有效的多。
在这里需要说明一点,设计原则是一种思想,设计模式是这种思想的具象化。所以当我们真正领悟到这种思想后,设计的时候会事半功倍。
本文要阐述的原则如下:
单一职责原则(SRP):一个类只负责完成一个职责或者功能。不要设计大而全的类,要设计粒度小、功能单一的类。单一职责原则是为了实现代码高内聚、低耦合,提高代码的复用性、可读性、可维护性。
不同的应用场景、不同阶段的需求背景、不同的业务层面,对同一个类的职责是否单一,可能会有不同的判定结果。实际上,一些侧面的判断指标更具有指导意义和可执行性,比如,出现下面这些情况就有可能说明这类的设计不满足单一职责原则:
假设我们要做一个在手机上玩的俄罗斯方块游戏,Game类可以设计如下:
type Game struct {
x int64
y int64
}
func (game *Game) Show() {
fmt.Println(game.x, game.y)
}
func (game *Game) Move() {
game.x--
game.y++
}
游戏的显示和移动都放在类Game里。后面需求变更了,不但要在手机上显示,还需要在电脑上显示,而且还有两人对战模式,这些更改主要和显示有关。
这时最好将Show和Move拆分到两个函数,这样不但可以复用Move的逻辑,而且今后无论如何更改Show,都不会影响Move所在的类。
但因为一开始Game职责不单一,整个系统中很多位置使用同一个Game变量调用Show和Move,对这些位置的改动和测试是十分浪费时间的。
对扩展开放、修改关闭(OCP):添加一个新的功能,应该是通过在已有代码基础上扩展代码(新增模块、类、方法、属性等),而非修改已有代码(修改模块、类、方法、属性等)的方式来完成。
我们要时刻具备扩展意识、抽象意识、封装意识。在写代码的时候,我们要多花点时间思考一下,这段代码未来可能有哪些需求变更,如何设计代码结构,事先留好扩展点,以便在未来需求变更的时候,在不改动代码整体结构、做到最小代码改动的情况下,将新的代码灵活地插入到扩展点上。
很多设计原则、设计思想、设计模式,都是以提高代码的扩展性为最终目的的。特别是23种经典设计模式,大部分都是为了解决代码的扩展性问题而总结出来的,都是以开闭原则为指导原则的。最常用来提高代码扩展性的方法有:多态、依赖注入、基于接口而非实现编程,以及大部分的设计模式(比如,装饰、策略、模板、职责链、状态)。
假设我们要做一个API接口监控告警,如果TPS或Error超过指定值,则根据不同的紧急情况通过不同方式(邮箱、电话)通知相关人员。根据Go设计模式(2)-面向对象分析与设计里讲的方案,我们先找出类。
业务实现流程为:
所以,我们可以设置三个类,AlertRules存放报警规则,Notification用来通知,Alert用来比较。
//存储报警规则
type AlertRules struct {
}
func (alertRules *AlertRules) GetMaxTPS(api string) int64 {
if api=="test" {
return 10
}
return 100
}
func (alertRules *AlertRules) GetMaxError(api string) int64 {
if api=="test" {
return 10
}
return 100
}
const (
SERVRE="SERVRE"
URGENT="URGENT"
)
//通知类
type Notification struct {
}
func (notification *Notification) Notify(notifyLevel string) bool {
if notifyLevel==SERVRE {
fmt.Println("打电话")
} else if notifyLevel==URGENT {
fmt.Println("发短信")
} else {
fmt.Println("发邮件")
}
return true
}
//检查类
type Alert struct {
alertRules *AlertRules
notification *Notification
}
func CreateAlert(a *AlertRules, n *Notification) *Alert {
return &Alert{
alertRules: a,
notification: n,
}
}
func (alert *Alert) Check(api string, tps int64, errCount int64) bool {
if tps > alert.alertRules.GetMaxTPS(api) {
alert.notification.Notify(URGENT)
}
if errCount > alert.alertRules.GetMaxError(api) {
alert.notification.Notify(SERVRE)
}
return true
}
func main() {
alert :=CreateAlert(new(AlertRules), new(Notification))
alert.Check("test", 20, 20)
}
虽然程序比较简陋,但是是面向对象的,而且能跑。
对于这个需求,有很多可能的变动点,最可能变的是增加新的报警指标。现在新需求来了,如果每秒内接口超时量超过指定值,也需要报警,我们需要怎么做?
如果在原有代码上修改,我们需要
这会导致一些问题,一是Check可能在多个地方被引用,所以这些位置都需要进行修改,二是更改了Check逻辑,需要重新做这部分的测试。如果说我们做第一版没有预料到这些变化,但现在我们找到了可能的变更点,我们是否有好的方案能够做好扩展,让下次改动量最小?
我们把Alert中Check做的事情拆散,放到对应的类里,这些类都实现了AlertHandler接口。
//优化
type ApiStatInfo struct {
api string
tps int64
errCount int64
timeoutCount int64
}
type AlertHandler interface {
Check(apiStatInfo ApiStatInfo) bool
}
type TPSAlertHandler struct {
alertRules *AlertRules
notification *Notification
}
func CreateTPSAlertHandler(a *AlertRules, n *Notification) *TPSAlertHandler {
return &TPSAlertHandler{
alertRules: a,
notification: n,
}
}
func (tPSAlertHandler *TPSAlertHandler) Check(apiStatInfo ApiStatInfo) bool {
if apiStatInfo.tps > tPSAlertHandler.alertRules.GetMaxTPS(apiStatInfo.api) {
tPSAlertHandler.notification.Notify(URGENT)
}
return true
}
type ErrAlertHandler struct {
alertRules *AlertRules
notification *Notification
}
func CreateErrAlertHandler(a *AlertRules, n *Notification) *ErrAlertHandler {
return &ErrAlertHandler{
alertRules: a,
notification: n,
}
}
func (errAlertHandler *ErrAlertHandler) Check(apiStatInfo ApiStatInfo) bool {
if apiStatInfo.errCount > errAlertHandler.alertRules.GetMaxError(apiStatInfo.api) {
errAlertHandler.notification.Notify(SERVRE)
}
return true
}
type TimeOutAlertHandler struct {
alertRules *AlertRules
notification *Notification
}
func CreateTimeOutAlertHandler(a *AlertRules, n *Notification) *TimeOutAlertHandler {
return &TimeOutAlertHandler{
alertRules: a,
notification: n,
}
}
func (timeOutAlertHandler *TimeOutAlertHandler) Check(apiStatInfo ApiStatInfo) bool {
if apiStatInfo.timeoutCount > timeOutAlertHandler.alertRules.GetMaxTimeOut(apiStatInfo.api) {
timeOutAlertHandler.notification.Notify(SERVRE)
}
return true
}
Alert类增加成员变量handlers []AlertHandler,并添加如下函数
//版本2
func (alert *Alert) AddHanler(alertHandler AlertHandler) {
alert.handlers=append(alert.handlers, alertHandler)
}
func (alert *Alert) CheckNew(apiStatInfo ApiStatInfo) bool {
for _, h :=range alert.handlers {
h.Check(apiStatInfo)
}
return true
}
调用方式如下:
func main() {
alert :=CreateAlert(new(AlertRules), new(Notification))
alert.Check("test", 20, 20)
//版本2,alert其实已经不需要有成员变量AlertRules和Notification了
a :=new(AlertRules)
n :=new(Notification)
alert.AddHanler(CreateTPSAlertHandler(a, n))
alert.AddHanler(CreateErrAlertHandler(a, n))
alert.AddHanler(CreateTimeOutAlertHandler(a, n))
apiStatInfo :=ApiStatInfo{
api: "test",
timeoutCount: 20,
errCount: 20,
tps: 20,
}
alert.CheckNew(apiStatInfo)
}
这样今后无论增加多少报警指标,只需要创建新的Handler类,放入到alert中即可。代码改动量极小,而且不需要重复测试。
系统还有许多改动点,大家可以自己尝试去改动一下,所有代码位置:https://github.com/shidawuhen/asap/blob/master/controller/design/3principle.go
里氏替换原则(LSP):子类对象能够替换程序(program)中父类对象出现的任何地方,并且保证原来程序的逻辑行为(behavior)不变及正确性不被破坏。
多态与里氏替换原则的区别:多态是面向对象编程的一大特性,也是面向对象编程语言的一种语法。它是一种代码实现的思路。而里式替换是一种设计原则,是用来指导继承关系中子类该如何设计的,子类的设计要保证在替换父类的时候,不改变原有程序的逻辑以及不破坏原有程序的正确性。
里式替换原则不仅仅是说子类可以替换父类,它有更深层的含义。
子类在设计的时候,要遵守父类的行为约定(或者叫协议)。父类定义了函数的行为约定,那子类可以改变函数的内部实现逻辑,但不能改变函数原有的行为约定。这里的行为约定包括:函数声明要实现的功能;对输入、输出、异常的约定;甚至包括注释中所罗列的任何特殊说明。所以我们可以通过几个点判断是否违反里氏替换原则:
里氏替换原则可以提高代码可扩展性。假设我们需要做一个发送信息的功能,最初只需要发送站内信。
type Message struct {
}
func (message *Message) Send() {
fmt.Println("message send")
}
func LetDo(notify *Message) {
notify.Send()
}
func main() {
LetDo(new(Message))
}
实现完成后,许多地方都调用LetDo发送信息。后面想用SMS替换站内信,处理起来就很麻烦了。所以最好的方案是使用里氏替换原则,丝毫不影响新的通知方法接入。
//里氏替换原则
type Notify interface {
Send()
}
type Message struct {
}
func (message *Message) Send() {
fmt.Println("message send")
}
type SMS struct {
}
func (sms *SMS) Send() {
fmt.Println("sms send")
}
func LetDo(notify Notify) {
notify.Send()
}
func main() {
//里氏替换原则
LetDo(new(Message))
}
接口隔离原则(ISP):客户端不应该强迫依赖它不需要的接口
接口隔离原则与单一职责原则的区别:单一职责原则针对的是模块、类、接口的设计。接口隔离原则提供了一种判断接口的职责是否单一的标准:通过调用者如何使用接口来间接地判定。如果调用者只使用部分接口或接口的部分功能,那接口的设计就不够职责单一。
如果把“接口”理解为一组接口集合,可以是某个微服务的接口,也可以是某个类库的接口等。如果部分接口只被部分调用者使用,我们就需要将这部分接口隔离出来,单独给这部分调用者使用,而不强迫其他调用者也依赖这部分不会被用到的接口。如果把“接口”理解为单个API接口或函数,部分调用者只需要函数中的部分功能,那我们就需要把函数拆分成粒度更细的多个函数,让调用者只依赖它需要的那个细粒度函数。如果把“接口”理解为OOP中的接口,也可以理解为面向对象编程语言中的接口语法。那接口的设计要尽量单一,不要让接口的实现类和调用者,依赖不需要的接口函数。
假设项目用到三个外部系统:Redis、MySQL、Kafka。其中Redis和Kafaka支持配置热更新。MySQL和Redis有显示监控功能。对于这个需求,我们需要怎么设计接口?
一种方式是将所有功能放到一个接口中,另一种方式是将这两个功能放到不同的接口中。下面的代码按照接口隔离原则编写:
//接口隔离原则
type Updater interface {
Update() bool
}
type Shower interface {
Show() string
}
type RedisConfig struct {
}
func (redisConfig *RedisConfig) Connect() {
fmt.Println("I am Redis")
}
func (redisConfig *RedisConfig) Update() bool {
fmt.Println("Redis Update")
return true
}
func (redisConfig *RedisConfig) Show() string {
fmt.Println("Redis Show")
return "Redis Show"
}
type MySQLConfig struct {
}
func (mySQLConfig *MySQLConfig) Connect() {
fmt.Println("I am MySQL")
}
func (mySQLConfig *MySQLConfig) Show() string {
fmt.Println("MySQL Show")
return "MySQL Show"
}
type KafkaConfig struct {
}
func (kafkaConfig *KafkaConfig) Connect() {
fmt.Println("I am Kafka")
}
func (kafkaConfig *KafkaConfig) Update() bool {
fmt.Println("Kafka Update")
return true
}
func ScheduleUpdater(updater Updater) bool {
return updater.Update()
}
func ServerShow(shower Shower) string {
return shower.Show()
}
func main() {
//接口隔离原则
fmt.Println("接口隔离原则")
ScheduleUpdater(new(RedisConfig))
ScheduleUpdater(new(KafkaConfig))
ServerShow(new(RedisConfig))
ServerShow(new(MySQLConfig))
}
这种方案比起将Update和Show放在一个interface中有如下好处:
依赖倒转原则(DIP):高层模块不要依赖低层模块。高层模块和低层模块应该通过抽象(abstractions)来互相依赖。除此之外,抽象(abstractions)不要依赖具体实现细节(details),具体实现细节(details)依赖抽象(abstractions)。
在程序代码中传递参数时或在关联关系中,尽量引用层次高的抽象层类,即使用接口和抽象类进行变量类型声明、参数类型声明、方法返回类型声明,以及数据类型的转换等,而不要用具体类来做这些事情。核心思想是:要面向接口编程,不要面向实现编程。
这个可以直接用里式替换中的例子来讲解。LetDo就使用了依赖倒转原则,提高了代码的扩展性,可以灵活地替换依赖的类。
迪米特法则(LOD):不该有直接依赖关系的类之间,不要有依赖;有依赖关系的类之间,尽量只依赖必要的接口
迪米特法则主要用来实现高内聚低耦合。
高内聚:就是指相近的功能应该放到同一个类中,不相近的功能不要放到同一个类中
松耦合:在代码中,类与类之间的依赖关系简单清晰
减少类之间的耦合,让类越独立越好。每个类都应该少了解系统的其他部分。一旦发生变化,需要了解这一变化的类就会比较少。
假设我们要做一个搜索引擎爬取网页的功能,功能点为
所以我们设置三个类NetworkTransporter负责底层网络、用于获取数据,HtmlDownloader下载网页,Document用于分析网页。下面是符合迪米特法则的代码
//迪米特法则
type Transporter interface {
Send(address string, data string) bool
}
type NetworkTransporter struct {
}
func (networkTransporter *NetworkTransporter) Send(address string, data string) bool {
fmt.Println("NetworkTransporter Send")
return true
}
type HtmlDownloader struct {
transPorter Transporter
}
func CreateHtmlDownloader(t Transporter) *HtmlDownloader {
return &HtmlDownloader{transPorter: t}
}
func (htmlDownloader *HtmlDownloader) DownloadHtml() string {
htmlDownloader.transPorter.Send("123", "test")
return "htmDownloader"
}
type Document struct {
html string
}
func (document *Document) SetHtml(html string) {
document.html=html
}
func (document *Document) Analyse() {
fmt.Println("document analyse " + document.html)
}
func main() {
//迪米特法则
fmt.Println("迪米特法则")
htmlDownloader :=CreateHtmlDownloader(new(NetworkTransporter))
html :=htmlDownloader.DownloadHtml()
doc :=new(Document)
doc.SetHtml(html)
doc.Analyse()
}
这种写法可以对应迪米特法则的两部分
终于写完了这6个原则,不过对我的好处也很明显,重新梳理知识结构,对原则的理解也更深了一步。宏观上看,这些原则都是为了实现可复用、可扩展、高内聚、低耦合的目的。现在大家在掌握了Go面向对象语法、如何做面向对象分析与设计、面向对象设计原则的基础上,可以做一些面向对象的事情了。
原则是道,设计模式是术,后面会写一些设计模式相关的内容。
本文所有代码位置为:https://github.com/shidawuhen/asap/blob/master/controller/design/3principle.go
大家如果喜欢我的文章,可以关注我的公众号(程序员麻辣烫)
我的个人博客为:https://shidawuhen.github.io/
技术
读书笔记
思考
*请认真填写需求信息,我们会在24小时内与您取得联系。