在开头
事实上,我想强调的从来不是 “编程语言中的OOP”, 而是 OOP 本身。
OOP,即面向对象编程,是一个编程范式,一种软件开发的方法。
无论你先前🈶🈚️接触过OOP这个概念,请时刻记住下面这几个规则:
- 任何复杂软件的开发都需要遵循 分析 → 设计 → 编码 的过程
- 在这个过程中,主要的元素分别是 需求/问题域、架构/类对象、编程语言/代码/框架
在熟练掌握你所使用的技术或编程语言的前提下:
- 如果你对编码不够确定,请回头看看设计是否合理与全面📚
- 如果你对设计感到困惑,请审视你的问题域找到答案📚
- 良好的编码能够体现设计的精妙✨
- 优秀的设计能够体现问题域的核心✨
从编码开始
好叭,我知道有些人可能会问,“为啥不从分析开始学起🤔”。
事实上,大学里边授课也先是从某一两门确切的编程语言开始学起,而非直接教大家捕获需求,因为分析需求是一个异常困难的步骤😰,许多有经验的、熟练掌握技术的程序员往往也不具备准确捕获需求的能力。
以及,对于初学者来说,以我们当下的能力能解决的问题实在少之又少。我们不妨假设一个不那么复杂的小问题,尝试着通过它来先学习编码过程。
当然,为了完整地践行上述规则,我也会引导大家尝试了解分析与设计相关的内容,带着大伙走一遍这个过程。
小问题
这一段问题描述可以看看我的 B站视频 😋
我们的主角Aru要前往QP家里,在路上,她会不断地分发🎁。
她想知道,自己的口袋里边每时每刻还剩下多少🎁。
分析与设计
对于不那么复杂的问题,我有一个非常非常好用的小方法😋
- 找出和你需要解决的问题相关的 动宾短语。这个 动宾短语 就是对象中的 行为
- 再看看这个问题中是否还存在其它需要关注的内容,按照步骤3抽取出 行为
- 通过 行为 来设计你的 类对象
- 查看这些 行为 所依赖的 数据 是否存在共性。将有共性的 数据 抽取出来,设计成对象的 状态
通过分析上面的问题,显然易得:
- 分发🎁
- 获取🎁数量
这便是我们分析得到的两个 行为 。
为了让这两个行为有实际意义,它们明显需要一个状态来支撑:🎁数量。
编码
通常来说,状态往往用变量来表示,而行为则用函数来表示。
⚠️ 并不绝对,请遵照具体设计
据此,我们可以将上述设计得到的俩仨个玩意变成C#的代码:
// 🎁数量
int presentCount;
// 分发🎁
void GiveOutOnePresent()
{
presentCount --;
}
// 获取🎁数量
int GetPresentCount()
{
return presentCount;
}
好啦😋,现在一切万事大吉了
我对象搁哪呢?
很明显,上边我们得到的那些玩意儿好像还不太够。因为到现在我们的代码中完全没有任何对象的影子,只有这点儿东西,简直和C语言没啥区别。
一方面,这些个状态和行为除了名字几乎都没有任何关联性。
另一方面,我们的状态和行为都是直接暴露在外的,这样的设计是不够安全的。
好叭,那怎么做呢?
我们可以将这些个状态和行为封装到一个名为 class 的作用域中,这一整个玩意儿就是我们的类。
class Aru
{
int presentCount;
void GiveOutOnePresent()
{
presentCount --;
}
int GetPresentCount()
{
return presentCount;
}
}
这个 class 是C#面向对象编程的一个关键玩意儿,你可以称呼它为 类。
请注意⚠️:类 和 类型 是两个不同的概念。
当下,对于初学者而言,你可以将 类 理解为 类型,它就和你在C语言中认识的 int、float、char 一样。
但是,类型实质上涉及到的东西远远不止这些,例如 类、结构体、枚举、接口……
有的同学可能会问了,“我能不能把这个类的名字改成别的呢?比如改成Pocket
之类的?🤔”
事实上,在对象的设计中,类的命名其实并不十分关键,真正重要的是这个类所涵盖的内容。因为这些内容决定了这个类的 “职责” 。
你每次往一个类中增删改内容时,都要思考它的合理性:
- 这些内容是否必需?
- 这些内容是否重复?
- 对于这个类而言,它所涵盖的内容是否与它的 “职责” 相关?
当然,类的命名从某种意义上来说也是值得思考的,它能够让你和其他人更好地识别与理解这个类的职责。
值得一提的是:
- 当你把变量放到一个类中以后,你可以管这个变量叫字段(Field)。
- 当你把函数放到一个类中以后,你可以管这个函数叫方法(Method)。
当然,我个人还是更倾向在设计时管它们叫状态和行为😋
那我咋用这个类呢?
在C#中,你可以通过 new 关键字来创建一个类的实例,这个实例就是对象。
我的天呐这个人终于讲到对象这个字眼儿了
Aru aru = new Aru();
这样,你就创建了一个名为 aru
的对象,它是 Aru
这个类的一个实例,它拥有 Aru
这个类中定义的状态和行为。
🍬 对于
new
的操作而言,在C#中你还可以写成这样:Aru aru = new();
我们可以尝试通过这个对象来调用行为:
aru.GiveOutOnePresent();
如果你真的按照我给的代码写了一遍,你会发现,aru
的后边好像“点儿”不出来 GiveOutOnePresent
这个东西。
好叭,让我们把之前的代码修改修改,让我们能把我们需要的行为暴露出来:
class Aru
{
int presentCount;
public void GiveOutOnePresent()
{
presentCount --;
}
public int GetPresentCount()
{
return presentCount;
}
}
这里,我在行为的前边加上了一个 public
关键字,这样我们就可以在外部访问这个行为了。
很明显,public
,但凡学过🤏英文的同学都知道它是啥意思。
同理,其实还有 private
,刚才你“点儿”不出来的原因就是因为它不加任何访问修饰符的情况下默认就是private
的。
这时候,华生发现了盲点,“我确实是这么做了,但是Aru发了一次🎁,GetPresentCount
的时候她的🎁变成负数了😦”
很明显,我们还需要在Aru出发的时候给她装一些🎁才行。
我们可以通过 构造函数(Constructor) 来实现这个功能:
class Aru
{
int presentCount;
public Aru(int count)
{
presentCount = count;
}
public void GiveOutOnePresent()
{
presentCount --;
}
public int GetPresentCount()
{
return presentCount;
}
}
你会发现,这段代码中出现了一个四不像的玩意儿:public Aru(int count)
这个玩意儿就是构造函数,它是一个特殊的方法,用来在new
的时候初始化对象的状态。
你还会发现,你刚才写的那个new()
好像出了大问题。
这是因为,在你没有定义任何构造函数的情况下,C#会自动帮你生成一个构造函数,这个构造函数没有任何参数,所以在起初你能随心所欲地new()
。
但是,一旦你定义了一个构造函数,那么C#就不会再帮你生成那个没有参数的构造函数了。这时,你就必须按照你定义的构造函数来new
一个对象。
Aru aru = new(10);
这样,你就可以在new
的时候给aru
装10个🎁了。
我明白了,但这么做有啥好处呢?
其实我们上述的代码中,已经体现出了封装的概念。
对象的封装性有以下几个重要作用:
数据安全:封装可以保护对象的内部数据,防止外部直接修改或破坏。例如,可以通过private关键字隐藏属性,只允许通过定义好的方法(如getter和setter)访问或修改数据。这有助于防止意外错误和恶意操作。
易维护性:封装让类的内部实现细节对外部不可见。当需要修改对象的内部逻辑时,不影响外部代码。例如,改变类的实现时,只需保持外部接口不变即可。
控制访问:通过封装,可以精确控制哪些数据和方法对外可见,哪些仅供内部使用。这使得对象的使用更加清晰和安全,减少了可能的误用。
提高灵活性:封装可以提供受控的访问方式,比如验证输入数据或在获取/修改数据时进行额外的处理,灵活应对需求变化。
总结来说,封装性使得程序更安全、稳定且更易于维护。
⬆️这些个玩意儿是GPT帮我生成的,我知道它可能不是那么很好懂,所以我给大伙提炼成俩需要关注的词儿:
整体 、 密封
整体:一个完整的业务逻辑被包裹在一个类中,依赖它内部的状态和行为来共同阐述并完成这个业务逻辑。对于外部而言,只需要知道这个类的行为,而不需要关心它的状态。
密封:状态和行为被封装在一个类中,外部无法直接访问状态。这样,类的状态就不会被外部随意修改,只会按照业务逻辑的规则来变化。
那么,C#带来了哪些🍬呢?
如果现在,需求发生了变更:
Aru不发🎁了,她打算在路上选择让自己口袋里边装多少🎁,并且查看里边有多少🎁。
我们可以很轻松地在Aru
这个类中增删一些行为:
class Aru
{
int presentCount;
public void SetPresentCount(int count)
{
presentCount = count;
}
public int GetPresentCount()
{
return presentCount;
}
}
有的同学可能会问,“既然我都把对presentCount
的Get
和Set
都暴露出去了,那我为啥不直接让presentCount
公开呢?🤔”
这个问题其实很有意思,因为你发现presentCount
这个字段其实在一些时候是需要被外部访问的。但是,如果按照封装的约定,我们好像只能通过方法调用来访问这个状态。
其实叭,我们管类中的字段与围绕它做访问控制的这么一对Get
和Set
方法叫做属性(Property)。在一些情况下,没有Set
也是可以的,这样的属性叫做只读属性。
⚠️ 应该,或者说,仅应该通过属性来表达一个对象对外公开的状态。
但是,这样似乎比较违背一般意义上的常识。如果你还没有意识到这一点有多呃呃,那好,我现在要对presentCount
做一次 “++” 的操作,你会发现,我要写的代码会变成这样:
aru.SetPresentCount(aru.GetPresentCount() + 1);
我的天呐简直太🪝🔟了
这样的代码显然不够优雅。甚至比较恶心
所以,C#为我们提供了一个更加优雅的写法:
class Aru
{
public int PresentCount { get; set; }
}
你会发现,你遇到了一个🚢新的玩意儿:{ get; set; }
这在其它语言里边儿是没有的 (骄傲)
这个东西是C#中的属性,它会自动帮你生成一个字段,在这时你可以管它叫自动属性(Auto-Property)。
有了这玩意儿,你就不用费劲扒拉地写一堆Get()
、Set()
方法和对应的字段了,它会自动帮你生成。
对于刚才 “++” 的操作,你现在可以这么写:
aru.PresentCount ++;
我的天呐太舒服了
如果你想让这个属性变成只读属性,你可以这么写:
public int PresentCount { get; private set; }
这样,你就只能在类的内部修改这个属性了。在外部你虽然可以读取它的值,但是无法修改它。
如果你在内部也不想修改这个属性,你可以这么写:
public int PresentCount { get; }
这样,你就只能在类的构造函数中初始化这个属性,之后就无法再修改它了。
对于属性
我们还能够在属性的get
和set
中做一些额外的操作,就像你在方法中做的那样。
public int PresentCount
{
get
{
return presentCount;
}
set
{
// 这个value是set中特有的一个关键字,表示你要设置的值
if (value < 0)
{
return;
}
presentCount = value;
}
}
这样,你就可以在属性的set
中做一些额外的判断,保证你的属性不会被设置成一个不合理的值。
这个时候,这个属性就不再是 “自动” 的了,尽管好像和一开始那么写俩方法没啥区别,但是对于外部调用而言,属性能让你用起来感觉就和一个字段一样自然,而不需要关心它的内部实现。
以及,你会发现,我们好像要额外写一个字段来存储这个属性的值。这个字段叫做后备字段(Backing Field)。在.NET 9和未来的版本中,我们甚至可以丢掉这个字段,用新的关键字field
来代替。
public int PresentCount
{
get{ return field; }
set
{
if (value < 0)
{
return;
}
field = value;
}
}
还有好东西?
我们刚才提到了构造函数,它是用来new
的时候做一些事儿的,但是,如果你就是懒只是想初始化一些属性,没其它的破事儿要干,你可以使用初始化器(Initializer):
Aru aru = new() { PresentCount = 10 };
class Aru
{
public int PresentCount { get; set; }
}
啥?你说你这个属性原先只有get
?没事儿,你可以把那个set
换成init
:
public int PresentCount { get; init; }
你可以使用init
这个关键字来定义一个初始化属性(Init-Only Property)
你可以在类的构造函数和初始化器中对它进行初始化,但是之后就无法再修改它了。
你同样可以在init
中做一些额外的操作,或者给它加上访问修饰符:
private int presentCount;
public int PresentCount
{
get{ return presentCount; }
private init
{
// Extra operations
presentCount = value;
}
}