As a beginner

11/1/2024 12:30:00 PM

在开头

事实上,我想强调的从来不是 “编程语言中的OOP”, 而是 OOP 本身。

OOP,即面向对象编程,是一个编程范式,一种软件开发的方法。

无论你先前🈶🈚️接触过OOP这个概念,请时刻记住下面这几个规则:

在熟练掌握你所使用的技术或编程语言的前提下:

从编码开始

好叭,我知道有些人可能会问,“为啥不从分析开始学起🤔”。

事实上,大学里边授课也先是从某一两门确切的编程语言开始学起,而非直接教大家捕获需求,因为分析需求是一个异常困难的步骤😰,许多有经验的、熟练掌握技术的程序员往往也不具备准确捕获需求的能力。

以及,对于初学者来说,以我们当下的能力能解决的问题实在少之又少。我们不妨假设一个不那么复杂的小问题,尝试着通过它来先学习编码过程。

当然,为了完整地践行上述规则,我也会引导大家尝试了解分析设计相关的内容,带着大伙走一遍这个过程。

小问题

这一段问题描述可以看看我的 B站视频 😋

我们的主角Aru要前往QP家里,在路上,她会不断地分发🎁。
她想知道,自己的口袋里边每时每刻还剩下多少🎁。

分析与设计

对于不那么复杂的问题,我有一个非常非常好用的小方法😋

  1. 找出和你需要解决的问题相关的 动宾短语。这个 动宾短语 就是对象中的 行为
  2. 再看看这个问题中是否还存在其它需要关注的内容,按照步骤3抽取出 行为
  3. 通过 行为 来设计你的 类对象
  4. 查看这些 行为 所依赖的 数据 是否存在共性。将有共性的 数据 抽取出来,设计成对象的 状态

通过分析上面的问题,显然易得:

这便是我们分析得到的两个 行为
为了让这两个行为有实际意义,它们明显需要一个状态来支撑:🎁数量。

编码

通常来说,状态往往用变量来表示,而行为则用函数来表示。

⚠️ 并不绝对,请遵照具体设计

据此,我们可以将上述设计得到的俩仨个玩意变成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语言中认识的 intfloatchar 一样。
但是,类型实质上涉及到的东西远远不止这些,例如 结构体枚举接口……

有的同学可能会问了,“我能不能把这个的名字改成别的呢?比如改成Pocket之类的?🤔”

事实上,在对象的设计中,的命名其实并不十分关键,真正重要的是这个所涵盖的内容。因为这些内容决定了这个“职责”
你每次往一个中增删改内容时,都要思考它的合理性:

当然,的命名从某种意义上来说也是值得思考的,它能够让你和其他人更好地识别与理解这个职责

值得一提的是:

当然,我个人还是更倾向在设计时管它们叫状态行为😋

那我咋用这个类呢?

在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个🎁了。

我明白了,但这么做有啥好处呢?

其实我们上述的代码中,已经体现出了封装的概念。

对象的封装性有以下几个重要作用:

  1. 数据安全:封装可以保护对象的内部数据,防止外部直接修改或破坏。例如,可以通过private关键字隐藏属性,只允许通过定义好的方法(如getter和setter)访问或修改数据。这有助于防止意外错误和恶意操作。

  2. 易维护性:封装让类的内部实现细节对外部不可见。当需要修改对象的内部逻辑时,不影响外部代码。例如,改变类的实现时,只需保持外部接口不变即可。

  3. 控制访问:通过封装,可以精确控制哪些数据和方法对外可见,哪些仅供内部使用。这使得对象的使用更加清晰和安全,减少了可能的误用。

  4. 提高灵活性:封装可以提供受控的访问方式,比如验证输入数据或在获取/修改数据时进行额外的处理,灵活应对需求变化。

总结来说,封装性使得程序更安全、稳定且更易于维护。

⬆️这些个玩意儿是GPT帮我生成的,我知道它可能不是那么很好懂,所以我给大伙提炼成俩需要关注的词儿:

整体密封

那么,C#带来了哪些🍬呢?

如果现在,需求发生了变更:

Aru不发🎁了,她打算在路上选择让自己口袋里边装多少🎁,并且查看里边有多少🎁。

我们可以很轻松地在Aru这个中增删一些行为

class Aru
{
    int presentCount;

    public void SetPresentCount(int count)
    {
        presentCount = count;
    }

    public int GetPresentCount()
    {
        return presentCount;
    }
}

有的同学可能会问,“既然我都把对presentCountGetSet都暴露出去了,那我为啥不直接让presentCount公开呢?🤔”

这个问题其实很有意思,因为你发现presentCount这个字段其实在一些时候是需要被外部访问的。但是,如果按照封装的约定,我们好像只能通过方法调用来访问这个状态

其实叭,我们管中的字段与围绕它做访问控制的这么一对GetSet方法叫做属性(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; }

这样,你就只能在构造函数中初始化这个属性,之后就无法再修改它了。

对于属性

我们还能够在属性getset中做一些额外的操作,就像你在方法中做的那样。

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;
    }
}