C# 面向对象

前言

C# 是一种面向对象、类型安全的语言。

❓什么是面向对象

面向对象编程(OOP)是如今多种编程语言所实现的一种编程范式,包括 Java、C++、C#。

面向对象编程将一个系统抽象为许多对象的集合,每一个对象代表了这个系统的特定方面。对象包括函数(方法)和数据。一个对象可以向其他部分的代码提供一个公共接口,而其他部分的代码可以通过公共接口执行该对象的特定操作,系统的其他部分不需要关心对象内部是如何完成任务的,这样保持了对象自己内部状态的私有性。

面向对象和面向过程的区别:

面向对象:用线性的思维。与面向过程相辅相成。在开发过程中,宏观上,用面向对象来把握事物间复杂的关系,分析系统。微观上,仍然使用面向过程。

面向过程:是一种是事件为中心的编程思想。就是分析出解决问题所需的步骤,然后用函数把这写步骤实现,并按顺序调用。

简单来说:用面向过程的方法写出来的程序是一份蛋炒饭,而用面向对象写出来的程序是一份盖浇饭。所谓盖浇饭,就是在米饭上面浇上一份盖菜,你喜欢什么菜,你就浇上什么菜。

这个比喻还是比较贴切的。

❓为什么使用面向对象编程

面向对象编程,可以让编程更加清晰,把程序中的功能进行模块化划分,每个模块提供特定的功能,同时每个模块都是孤立的,这种模块化编程提供了非常大的多样性,大大增加了重用代码的机会,而且各模块不用关心对象内部是如何完成的,可以保持内部的私有性。简单来说面向对象编程就是结构化编程,对程序中的变量结构划分,让编程更清晰。

准确地说,本文所提及到的特性是一种特别的面向对象编程方式,即基于类的面向对象编程(class-based OOP)。当人们谈论面向对象编程时,通常来说是指基于类的面向对象编程。

类 - 实际上是创建对象的模板。当你定义一个类时,你就定义了一个数据类型的蓝图。这实际上并没有定义任何的数据,但它定义了类的名称,这意味着什么,这意味着类的对象由什么组成及在这个对象上可执行什么操作。对象是类的实例。构成类的方法和变量称为类的成员。

类的定义和使用

类中的数据和函数称为类的成员

  • 数据成员
    • 数据成员是包含类的数据 - 字段,常量和事件的成员。
  • 函数成员
    • 函数成员提供了操作类中数据的某些功能 - 方法,属性,构造器(构造方法)和终结器(析构方法),运算符,和索引器

image-20230504210432775

拿控制台程序为例,当我们创建一个空的控制台项目,在Main()函数里编程的时候就是在Program类里面操作的:

image-20230523085106120

而且,我们可以发现,Program类和保存它的文件的文件名其实是一样的Program.cs,一般我们习惯一个文件一个类,类名和文件名一致。当然了,这不是说一个文件只能写一个类,一个文件是可以包含多个类的。

新建一个Customer类来表示商店中购物的顾客:

 class Customer
    {
        public string name;
        public string address;
        public int age;
        public string createTime;   // 加入会员的时间

        public void Show()
        {
            Console.WriteLine("名字:" + name);
            Console.WriteLine("地址:" + address);
            Console.WriteLine("年龄:" + age);
            Console.WriteLine("创建时间:" + createTime);
        }
    }

Customer类里有四个公有字段和一个共有方法Show()来输出顾客信息。

创建Customer类的对象:

static void Main(string[] args)
{
    Customer customer = new Customer();
    customer.name = "Test";
    customer.address = "Test01";
    customer.age = 24;
    customer.createTime = "2023-02-27";
    customer.Show();
    Console.ReadKey();
}

通过类创建的变量被称之为对象,这个过程我们叫他实例化。所有对象在使用之前必须实例化,仅仅声明一个对象变量或者赋值为null都是不行的。到现在看来,其实简单的类在定义和使用起来跟结构体是差不多的,只不过结构体在创建的时候没有实例化的过程,因为结构体是值类型的数据结构,而类是引用类型。

小小练习

推荐大家开发过程中,尽量一个文件里面一个类,当然一个文件可以放多个类,但管理起来不方便,一个类一个文件管理起来方便,如果程序很小,怎么写都无所谓,如果程序大或团队合作,最好一个类一个文件。

而且一个类定义也可以在多个文件中哦 - partial className

定义一个车辆Vehicle类,具有RunStop等方法,具有 Speed ( 速度 ) 、MaxSpeed ( 最大速度 ) 、Weight ( 重量 )等(也叫做字段)。

使用这个类声明一个变量(对象)。

static void Main(string[] args)
{
    Vehicle vehicle = new Vehicle();
    vehicle.brand = "BMW X5";
    vehicle.speed = 90;
    vehicle.maxSpeed = 215;
    vehicle.weight = 32;
    vehicle.Run();
    vehicle.Stop();
    Console.ReadKey();
}
class Vehicle
{
    // 字段
    public string brand;
    public int speed;
    public int maxSpeed;
    public float weight;
    // 方法
    public void Run()
    {
        Console.WriteLine("Run!");
    }
    public void Stop()
    {
        Console.WriteLine("Stop!");
    }
}

定义一个向量Vector类,里面有x,y,z三个字段,有取得长度的方法,有设置属性Set的方法使用这个类声明一个变量(对象)。

class Vector3
{
    // 字段
    private double x;
    private double y;
    private double z;

    // 属性【X】 - SetX为一个普通方法
    public void SetX(double temp)
    {
        x = temp;
    }
    public void SetY(double temp)
    {
        y = temp;
    }
    public void SetZ(double temp)
    {
        z = temp;
    }

    // 方法
    public double GetLength()
    {
        return Math.Sqrt(x * x + y * y + z * z);
    }
}

属性 - 是类的一种成员,它提供灵活的机制来读取、写入或计算私有字段的值。 属性可用作公共数据成员,但它们是称为“访问器”的特殊方法。 此功能使得可以轻松访问数据,还有助于提高方法的安全性和灵活性。

这里先不详细说,后续章节再展开。Vector3类里面的Set*属性是用来给x,y,z赋值的,可以看到与之前的简单类不同的是,Vector3类里的字段是private也就是私有的,这意味着在类的外部是没有办法访问这写字段的,它只在类自己内部是大家都知道的,到外面就不行了。

这里一开始写错了,类Vector3中的SetXSetYSetZ 方法是普通的方法,而不是属性。它们仅仅是修改和访问实例中私有字段的方法。它们需要一个参数才能设置相应的字段值,而属性是通过访问器方法来设置或获取字段的值,并且不需要额外的参数。

public 和 private 访问修饰符

  • 访问修饰符(C# 编程指南)
  • public修饰的数据成员和成员函数是公开的,所有的用户都可以进行调用。
  • private修饰词修饰的成员变量以及成员方法只供本类使用,也就是私有的,其他用户是不可调用的。

publicprivate这两个修饰符其实从字面意思就可以理解,没什么不好理解的,前者修饰的字段大家可以随意操作,千刀万剐只要你乐意,而后者修饰的字段就不能任你宰割了,你只能通过GetSet进行一系列的访问或者修改。

举个例子,生活中每个人都有名字、性别,同时也有自己的银行卡密码,当别人跟你打交道的时候,他一般会先得知你的名字,性别,这些告诉他是无可厚非的,但是当他想知道你的银行卡密码的时候就不太合适了对吧。假设我们有一个类Person,我们就可以设置Name,Sex等字段为公有的public,大家都可以知道,但是银行卡密码就不行,它得是私有的,只有你自己知道。但是加入你去银行ATM机取钱,它就得知道你的银行卡密码才能让你取钱对吧,前面我们已经了密码是私有的,外部是没办法访问的,那该怎么办呢,这个时候就用到属性了。我们用Get获取密码,用Set修改密码。

放在代码里面:

static void Main(string[] args)
{
    Vector3 vector = new Vector3();
    vector.w = 2;
    vector.SetX(1);
    Console.WriteLine(vector.GetX()); 
    Console.ReadKey();
}
class Vector3
{
    // 字段
    private double x;
    public double w;
    // 属性
    public void SetX(double temp)
    {
        x = temp;
    }
    // ......
    public double GetX()
    {
        return x;
    }
}

w字段在类外部可以直接操作,x只能通过GetSet来操作。

日常开发推荐不要把字段设置为共有的,至少要有点访问限制,当然了除了这两个修饰符,还有其他的,比如internalprotect等等,以后的文章可能会专门来写(❓)。

使用private修饰符除了多了一堆属性(访问器)有什么便利吗?显然得有,public的字段你在设置的时候说啥就啥,即使它给到的内容可能不适合这个字段,在后者,我们可以在属性里设置一些限制或者是操作。比如,Vector3类的x字段显然长度是不会出现负值的,这时候我们就可以在SetX里面做些限制:

public void SetX(double temp)
{
    if (temp<0)
    {
        Console.WriteLine("数据不合法。");
    }
    x = temp;
}

对于不想让外界访问的信息我们可以不提供Get属性以起到保护作用。

构造函数

构造函数(C# 编程指南)

构造函数 - 也被称为“构造器”,是执行类或结构体的初始化代码。每当我们创建类或者结构体的实例的时候,就会调用它的构造函数。大家可能会疑惑,我们上面创建的类里面也没说这个构造函数这个东东啊,那是因为如果一个类没有显式实例构造函数,C#将提供可用于实现实例化该类实例的无参构造函数(隐式),比如:

public class Person
{
    public int age;
    public string name = "unknown";
}

class Example
{
    static void Main()
    {
        var person = new Person();
        Console.WriteLine($"Name: {person.name}, Age: {person.age}");
        // Output:  Name: unknown, Age: 0
    }
}

默认构造函数根据相应的初始值设定项初始化实例字段和属性。 如果字段或属性没有初始值设定项,其值将设置为字段或属性类型的默认值。 如果在某个类中声明至少一个实例构造函数,则 C# 不提供无参数构造函数。

回到开头,构造函数有什么作用呢?

我们构造对象的时候,对象的初始化过程是自动完成的,但是在初始化对象的过程中有的时候需要做一些额外的工作,比如初始化对象存储的数据,构造函数就是用于初始化数据的函数。 使用构造函数,开发人员能够设置默认值、限制实例化,并编写灵活易读的代码。

构造函数是一种方法。

构造函数的定义和方法的定义类似,区别仅在于构造函数的函数名只能和封装它的类型相同。声明基本的构造函数的语法就是声明一个和所在类同名的方法,但是该方法没有返回类型。

拿之前的Customer类为例,我们来给他写一个简单的构造函数:

static void Main(string[] args)
{
    Customer customer = new Customer();
    // Output :我一个构造函数。
    Console.ReadKey();
}

class Customer
{
    public string name;
    public string address;
    public int age;
    public string createTime;   // 加入会员的时间

    public Customer()
    {
        Console.WriteLine("我一个构造函数。");
    }

    public void Show()
    {
        Console.WriteLine("名字:" + name);
        Console.WriteLine("地址:" + address);
        Console.WriteLine("年龄:" + age);
        Console.WriteLine("创建时间:" + createTime);
    }
}

当我们创建Customer类的实例的时候就会调用我们写无参的构造函数,虽然这个目前这个函数是没什么实际意义的,我们一般使用构造函数中实现数据初始化,比如我们来实现对顾客信息的初始化:

static void Main(string[] args)
{
    Customer customer = new Customer();
    Customer customer2 = new Customer("光头强", "狗熊岭", 30, "2305507");
    customer2.Show();
    // Output:
    // 我一个构造函数。
    // 名字:光头强
    // 地址:狗熊岭
    // 年龄:30
    // 创建时间:2305507
    Console.ReadKey();
}

class Customer
{
    public string name;
    public string address;
    public int age;
    public string createTime;   // 加入会员的时间

    public Customer()
    {
        Console.WriteLine("我一个构造函数。");
    }

    public Customer(string arg1, string arg2, int arg3, string arg4)
    {
        name = arg1;
        address = arg2;
        age = arg3;
        createTime = arg4;
    }

    public void Show()
    {
        Console.WriteLine("名字:" + name);
        Console.WriteLine("地址:" + address);
        Console.WriteLine("年龄:" + age);
        Console.WriteLine("创建时间:" + createTime);
    }
}

有参的构造函数相当于无参构造函数的重载,在创建实例时,运行时会自动匹配对应的构造函数。这是时候输出的内容里面”我是”我一个构造函数“是在创建实例customer的时候调用的无参构造函数,customer2在创建的时候调用的时对应四个参数的有参构造函数。进行有参构造的实例时一定注意对应的参数列表:类型、数量等必须一致,否则就不能成功创建实例。

当我们注释掉Customer类里的无参构造函数后,Customer customer = new Customer();就会报错,这就是我们上面所说的,如果在某个类中声明至少一个实例构造函数,则 C# 不提供默认的无参数构造函数。

我们例子中的四个参数的构造函数在使用起来是很不方便的,参数arg1在我们创建实例的时候可能会混淆,不清楚哪个参数代表哪个字段,假入你现在使用的是Visual Studio 2022,你在创建类以后,IntelliSense代码感知工具可能会给你生成一个和类中字段匹配的构造函数:

public Customer(string name,string address,int age,string createTime)
{
    this.name = name;
    this.address = address;
    this.age = age;
    this.createTime = createTime;
}

你会发现这个构造函数的参数和Customer的字段是一样的,类型、变量名都一样,这个时候就需要用到this关键字了,如果这个时候我们还写成name = name;就会出错,虽然我们可能知道前面name是字段,后面的是传递进去的参数,但是编译器是不认识的,咱们这样写完它的CPU就冒烟了,这是干啥呢,谁是谁啊。

简单概述,后面会有章节展开说。this 关键字指代类的当前实例,我们可以通过this访问类中字段来区分变量。

属性

为了保护数据安全,类里面的字段我们一般都设置为私有的,之前的Vector3类中我们是通过编写GetSet方法来访问或者修改字段的数据,这样在实际开发中是很麻烦的,会降低我们的效率而且使用起来我们必须通过调用这两个方法来实现对私有字段的操作:

static void Main(string[] args)
{
    Customer customer = new Customer();
    customer.SetAge(24);
    Console.WriteLine(customer.GetAge());
    // Output: 24
    Console.ReadKey();
}
class Customer
{
    public string name;
    public string address;
    public int age;
    public string createTime;
    public void SetAge(int age)
    {
        this.age = age;
    }
    public int GetAge()
    {
        return this.age;    // 这里 this 可加可不加
    }
    public void Show()
    {
        Console.WriteLine("名字:" + name);
        Console.WriteLine("地址:" + address);
        Console.WriteLine("年龄:" + age);
        Console.WriteLine("创建时间:" + createTime);
    }
}

我们可以通过属性来快捷实现对私有字段的访问以及修改,通过getset访问器操作私有字段的值。

❓什么是属性呢

  • 属性是一种成员,它提供灵活的机制来读取、写入或计算私有字段的值。 属性可用作公共数据成员,但它们是称为“访问器”的特殊方法。 此功能使得可以轻松访问数据,还有助于提高方法的安全性和灵活性。

  • 属性允许类公开获取和设置值的公共方法,而隐藏实现或验证代码。

  • 属性可以是读-写属性(既有 get 访问器又有 set 访问器)、只读属性(有 get 访问器,但没有 set 访问器)或只写访问器(有 set 访问器,但没有 get 访问器)。 只写属性很少出现,常用于限制对敏感数据的访问。

  • 不需要自定义访问器代码的简单属性可以作为表达式主体定义或自动实现的属性来实现。

上面的SetAgeGetAge方法我们用属性替换掉就是:

static void Main(string[] args)
{
    Customer customer = new Customer();
    customer.Age = 10;
    Console.WriteLine(customer.Age);
    // Output: 10
    Console.ReadKey();
}
class Customer
{
    private string name;
    private string address;
    private int age;
    private string createTime;
    // 属性
    public int Age
    {
        get
        {
            return this.age;
        }
        set // value 参数
        {
            this.age = value;
        }
    }
    public void Show()
    {
        Console.WriteLine("名字:" + name);
        Console.WriteLine("地址:" + address);
        Console.WriteLine("年龄:" + age);
        Console.WriteLine("创建时间:" + createTime);
    }
}

属性的时候就像访问一个公有的字段一样方便,我们在可以像是一个普通的公有的数据成员一样使用属性。只不过我们通过属性Age进行赋值的时候,在类的内部会调用set访问器,这是我们给属性Age赋的值就会被当作value参数传递进去,实现赋值;同理,我们在使用属性Age的时候也是通过get访问器来实现的。

上面属性Age里的关键字可以不写也没问题的。

除了进行简单数据访问和赋值,我们有一个实现属性的基本模式: get 访问器返回私有字段的值,set 访问器在向私有字段赋值之前可能会执行一些数据验证。 这两个访问器还可以在存储或返回数据之前对其执行某些转换或计算。

比如我们可以验证顾客的年龄不为负值:

static void Main(string[] args)
{
    Customer customer = new Customer();
    customer.Age = -10;
    // 引发 ArgumentOutOfRangeException 异常
    Console.ReadKey();
}
class Customer
{
    private string name;
    private string address;
    private int age;
    private string createTime;
    // 属性
    public int Age
    {
        get
        {
            return this.age;
        }
        set // value 参数
        {
            if (value < 0)
            {
               throw new ArgumentOutOfRangeException(nameof(value), "The age must be greater than 0.");
            }
            this.age = value;
        }
    }
}

同时呢,我们一个定义访问器的访问权限,如果在Age属性的set访问器前面加上private修饰符,那我们就没办法使用 customer.Age = -10;来进行赋值了,编译器会告知错误set访问器无法访问。

image-20230507185705903

此外,我们可以通过get 访问器和 set 访问器的有无来控制属性是读 - 写、只读、还是只写,只写属性很少出现,常用于限制对敏感数据的访问。

class Customer
{
    private string name;
    private string address;
    private int age;
    private string createTime;
    // 属性
    public int Age  // 读 - 写
    {
        get
        {
            return this.age;
        }
        set // value 参数
        {
            if (value < 0)
            {
                throw new ArgumentOutOfRangeException(nameof(value), "The age must be greater than 0.");
            }
            this.age = value;
        }
    }
    public string Name  // 只读
    {
        get { return this.name; }
    }
    public string Address   // 只写
    {
        set { this.address = value; }
    }
}

表达式属性

C# 6开始,只读属性(就像之前的例子中那样的属性)可简写为表达式属性。它使用双箭头替换了花括号、get访问器和return关键字。

class Customer
{
    private string name;
    private string address;
    private int age;
    private string createTime;
    
    public int Age => age;	// 表达式属性 只读属性
}

C# 7进一步允许在set访问器上使用表达式体:

class Customer
{
    private string name;
    private string address;
    private int age;
    private string createTime;
    
    public int Age => age;
    public string Name { get => name; set => name = value; }
    public string Address{ set => address = value; }
}

自动实现的属性

当属性访问器中不需要任何其他逻辑时,自动实现的属性会使属性声明更加简洁。

自动实现的属性是C# 3.0引入的新特性,它可以让我们在不显式定义字段和访问器方法的情况下快速定义一个属性。具体来说,一个属性包含一个字段和两个访问器方法,其中getset访问器方法都是自动实现的。

static void Main(string[] args)
{
    Customer customer = new Customer();
    customer.name = "光头强";
    customer.address = "狗熊岭";
    customer.age = 30;
    customer.createTime = "2305507";
    customer.Show();
    // output:
    // 名字:光头强
    // 地址:狗熊岭
    // 年龄:30
    // 创建时间:2305507
    Console.ReadKey();
}
class Customer
{
  	// 自动实现的属性
    public string name { get; set; }
    public string address { get; set; }
    public int age { get; set; }
    public string createTime { get; set; }

    public void Show()
    {
        Console.WriteLine("名字:" + name);
        Console.WriteLine("地址:" + address);
        Console.WriteLine("年龄:" + age);
        Console.WriteLine("创建时间:" + createTime);
    }
}

属性初始化器

C# 6开始支持自动属性的初始化器。其写法就像初始化字段一样:

public int age { get; set; }=24;

上述写法将``age`的值初始化为24。拥有初始化器的属性可以为只读属性:

public string sex { get; } = "male";

就像只读字段那样,只读自动属性只可以在类型的构造器中赋值。这个功能适于创建不可变(只读)的对象。

匿名类型

匿名类型提供了一种方便的方法,可用来将一组只读属性封装到单个对象中,而无需首先显式定义一个类型。 类型名由编译器生成,并且不能在源代码级使用。 每个属性的类型由编译器推断,是一个由编译器临时创建来存储一组值的简单类。如果需要创建一个匿名类型,则可以使用new关键字,后面加上对象初始化器,指定该类型包含的属性和值。例如:

var dude = new { Name = "Bob", Age = 23 };

编译器将会把上述语句(大致)转变为:

internal class AnonymousGeneratedTypeName
{
    private string name;  // Actual field name is irrelevant
    private int     age;    // Actual field name is irrelevant

    public AnonymousGeneratedTypeName (string name, int age)
    {
        this.name = name; this.age = age;
    }

    public string  Name { get { return name; } }
    public int      Age  { get { return age;  } }

    // The Equals and GetHashCode methods are overridden (see Chapter 6).
    // The ToString method is also overridden.
}
...

    var dude = new AnonymousGeneratedTypeName ("Bob", 23);

匿名类型只能通过var关键字来引用,因为它并没有一个名字。

堆、栈

程序在运行时,内存一般从逻辑上分为两大块 - 堆、栈。

  • 堆栈(Stack - 因为和堆一起叫着别扭,所以简称为栈):栈是一种先进后出(Last-In-First-Out,LIFO)的数据结构。当你声明一个变量时,它会自动地被分配到栈内存中,并且它的作用域仅限于当前代码块。在方法中声明的局部变量就是放在栈中的。栈的好处是,由于它的操作特性,栈的访问非常快,它也没有垃圾回收的问题。栈空间比较小,但是读取速度快。
  • 堆(Heap):堆是一种动态分配内存的数据结构。堆内存的大小不受限制,而且程序员可以控制它的生命周期,也就是说,在堆上分配的内存需要手动释放。堆空间比较大,但是读取速度慢。

堆和栈就相当于仓库和商店,仓库放的东西多,但是当我们需要里面的东西时需要去里面自行查找然后取出来,后者虽然存放的东西没有前者多,但是好在随拿随取,方便快捷。

栈是一种先进后出(Last-In-First-Out,LIFO)的数据结构。本质上讲堆栈也是一种线性结构,符合线性结构的基本特点:即每个节点有且只有一个前驱节点和一个后续节点。

  • 数据只能从栈的顶端插入和删除
  • 把数据放入栈顶称为入栈(push)
  • 从栈顶删除数据称为出栈(pop)

image-20230508204630967

堆是一块内存区域,与栈不同,堆里的内存可以以任意顺序存入和移除。

image-20230508205509910

GC

GC(Garbage Collector)垃圾回收器,是一种自动内存管理技术,用于自动释放内存。在.NET Framework中,GC.NET的运行时环境CLR自动执行。在公共语言运行时 (CLR) 中,垃圾回收器 (GC) 用作自动内存管理器。 垃圾回收器管理应用程序的内存分配和释放。 因此,使用托管代码的开发人员无需编写执行内存管理任务的代码。 自动内存管理可解决常见问题,例如,忘记释放对象并导致内存泄漏,或尝试访问已释放对象的已释放内存。

通过GC进行自动内存管理得益于C#是一种托管语言C#会将代码编译为托管代码。托管代码以中间语言(Intermediate Language, IL)的形式表示。CLR通常会在执行前,将IL转换为机器(例如x86或x64)原生代码,称为即时(Just-In-Time, JIT)编译。除此之外,还可以使用提前编译(ahead-of-time compilation)技术来改善拥有大程序集,或在资源有限的设备上运行的程序的启动速度。

托管语言是一种在托管执行环境中运行的编程语言,该环境提供了自动内存管理、垃圾回收、类型检查等服务。

托管执行环境是指由操作系统提供的一种高级运行时环境,例如Java虚拟机、.NET Framework、.NET Core 等。这种执行环境为程序提供了许多优势,例如:

  1. 自动内存管理:托管执行环境为程序管理内存分配和释放,程序员无需手动管理内存,避免了内存泄漏和越界等问题。
  2. 垃圾回收:托管执行环境提供了垃圾回收服务,自动回收不再使用的内存,提高了程序的性能和可靠性。
  3. 类型检查:托管执行环境提供了强类型检查,防止了类型错误等问题。
  4. 平台无关性:托管语言编写的程序可以在不同操作系统和硬件平台上运行,提高了程序的可移植性。

CLR中:

  • 每个进程都有其自己单独的虚拟地址空间。 同一台计算机上的所有进程共享相同的物理内存和页文件(如果有)。
  • 默认情况下,32 位计算机上的每个进程都具有 2 GB 的用户模式虚拟地址空间。
  • 作为一名应用程序开发人员,你只能使用虚拟地址空间,请勿直接操控物理内存。 垃圾回收器为你分配和释放托管堆上的虚拟内存。
  • 初始化新进程时,运行时会为进程保留一个连续的地址空间区域。 这个保留的地址空间被称为托管堆。 托管堆维护着一个指针,用它指向将在堆中分配的下一个对象的地址。

既然垃圾回收是自动进行的,那么一般什么时候GC会开始回收垃圾呢?

  • 系统具有低的物理内存。内存大小是通过操作系统的内存不足通知或主机指示的内存不足检测出来的。
  • 由托管堆上已分配的对象使用的内存超出了可接受的阈值。 随着进程的运行,此阈值会不断地进行调整。
  • 调用 GC.Collect 方法。几乎在所有情况下,你都不必调用此方法,因为垃圾回收器会持续运行。 此方法主要用于特殊情况和测试。

我们开发人员可以使用new关键字在托管堆上动态分配内存,不需要手动释放,GC会定期检查托管堆上的对象,并回收掉没有被引用的对象,从而释放它们所占用的内存。

❗❗❗需要注意的是,栈内存无需我们管理,同时它也不受GC管理。当栈顶元素使用完毕以后,所占用的内存会被立刻释放。而堆则需要依赖于GC清理。

值类型、引用类型

文章之前部分已经提到过C#是托管语言,在托管执行环境中运行的编程语言,该环境提供了强类型检查,所以与其他语言相比,C#对其可用的类型及其定义有更严格的描述 ———— C#是一种强类型语言,每个变量和常量都有一个类型,每个求值的表达式也是如此。 每个方法声明都为每个输入参数和返回值指定名称、类型和种类(值、引用或输出)。

所有的C#类型可以分为以下几类:

  • 值类型

  • 引用类型

  • 泛型类型

    C#泛型可以是值类型也可以是引用类型,具体取决于泛型参数的类型。

    如果泛型参数是值类型,那么实例化出来的泛型类型也是值类型。例如,List<int>就是一个值类型,因为int是值类型。

    如果泛型参数是引用类型,那么实例化出来的泛型类型也是引用类型。例如,List<string>就是一个引用类型,因为string是引用类型。

    需要注意的是,虽然泛型类型可以是值类型或引用类型,但是泛型类型的实例总是引用类型。这是因为在内存中,泛型类型的实例始终是在堆上分配的,无论它的泛型参数是值类型还是引用类型。因此,使用泛型类型时需要注意它的实例是引用类型。

  • 指针类型

    指针类型是C#中的一种高级语言特性,允许程序员直接操作内存地址。指针类型主要用于与非托管代码交互、实现底层数据结构等。指针类型在普通的C#代码中并不常见。

撇去指针类型,我们可以把C#中的数据类型分为两种:

  • 值类型 - 分两类:structenum,包括内置的数值类型(所有的数值类型、char类型和bool类型)以及自定义的struct类型和enum类型。
  • 引用类型 - 引用类型包含所有的类类型、接口类型、数组类型或委托类型。和值类型一样,C#支持两种预定义的引用类型:objectstring

❗❗❗ object类型是所有类型的基类型,其他类型都是从它派生而来的(包括值类型)。

各自在内存中的存储方式

在此之前,我们需要明白Windows使用的是一个虚拟寻址系统,该系统把程序可用的内存地址映射到硬件内存中的实际地址上,这些任务完全由Windows在后台管理。其实际结果是32位处理器上的每个进程都可以使用4GB的内存————不管计算机上实际有多少物理内存。这4个GB的内存实际上包含了程序的所有部分,包括可执行的代码、代码加载的所有DLL,以及程序运行时使用的所有变量的内容。这4个GB的内存称为虚拟地址空间、虚拟内存,我们这里简称它为内存。

我们可以借助VS在直观地体会这一特性,任意给个断点,把变量移到内存窗口就可以查看当前变量在内存中的地址以及存储的内容:

image-20230509212950792

例举一些常用的变量:

// 值类型
int a = 123;
float b = 34.5f;
bool c = true;

// 引用类型
string name = "SiKi";
int[] array1 = new int[] { 23, 23, 11, 32, 4, 2435 };
string[] array2 = new string[] { "熊大", "熊二", "翠花" };
Customer customer = new Customer("光头强", "狗熊岭", 30, "2305507");

它们在内存中是怎么存储的呢?

image-20230510214116231

  • 值类型就直观的存储在堆中。
  • array1在栈中存储着一个指向堆中存放array1数组首地址的引用,array2customer同理
  • name字符串,尽管它看上去像是一个值类型的赋值,但是它是一个引用类型,name对象被分配在堆上。

关于字符串在内存中的存储,虽然它是引用类型,但是它与引用类型的常见行为是有一些区别的,例如:字符串是不可变的。修改其中一个字符串,就会创建一个全新的string对象,而对已存在的字符串不会产生任何影响。例如:

static void Main(string[] args)
{
    string s1 = "a string";
    string s2 = s1;
    s1 = "another string";
    Console.ReadKey();
}

借助VS的内存窗口:

image-20230510214649927

s1也就是存储着a string字符串的地址是0x038023DC,再执行你就会发现s2的内存地址也是0x038023DC,但是当s1中存储的字符串发生变化时,s1的内存地址也会随之变化,但是s2的内存地址还是之前a string所在的位置。

image-20230510214853689

也就是说,字符串的值在发生变化时并不会替换原来的值,而是在堆上为新的字符串值分配一个新的对象(内存空间),之前的字符串值对象是不受影响的【这实际上是运算符重载的结果】。

To sum up,值类型直接存储其值,而引用类型存储对值的引用。这两种类型存储在内存的不同地方:值类型存储在栈(stack)中,而引用类型存储在托管堆(managed heap)上。

在这里插入图片描述

  • 值类型只需要一段内存,总是分配在它声明的地方,做为局部变量时,存储在栈上;假如是类对象的字段时,则跟随此类存储在堆中。
  • 引用类型需要两段内存,第一段存储实际的数据【堆】,第二段是一个引用【栈】,用于指向数据在堆中的存储位置。引用类型实例化的时候,会在托管堆上分配内存给类的实例,类对象变量只保留对对象位置的引用,引用存放在栈中。

对象引用的改变

因为引用类型在存储的时候是两段内存,所以对于引用类型的对象的改变和值类型是不同的,以Customer类的两个对象为例:

static void Main(string[] args)
{
    Customer c1 = new Customer("光头强", "狗熊岭", 30, "2305507");
    Customer c2 = c1;
    c1.Show();
    c2.Show();
    Console.WriteLine();
    c2.address = "团结屯";
    c1.Show();
    c2.Show();
    Console.ReadKey();
}

执行结果为:

名字:光头强
地址:狗熊岭
年龄:30
创建时间:2305507
名字:光头强
地址:狗熊岭
年龄:30
创建时间:2305507

名字:光头强
地址:团结屯
年龄:30
创建时间:2305507
名字:光头强
地址:团结屯
年龄:30
创建时间:2305507

可以发现当我们修改了对象s2中的address字段以后s1也跟着发生了变化,之所以这样和引用类型在内存中的存储方式是密不可分的:

image-20230510220704501

在创建s2时并没有和创建s1一样通过new来创建一个全新的对象,而是通过=赋值来的,因为引用类型存储是二段存储,所以赋值以后s2在栈中存储的其实是s1对象在堆中的存储空间的地址,所以修改s2的时候s1也会随之变化,因为二者指向的是同一块内存空间。如果你通过new关键字来实例化s2,那s2就是存储的一个全新的Customer对象了。感兴趣可以看看不同方式创建的s2对象在内存中的地址一不一样。

static void Main(string[] args)
{
    Customer c1 = new Customer("光头强", "狗熊岭", 30, "2305507");
    Customer c2 = new Customer("大熊", "东京", 14, "2309856");
    Console.ReadKey();
}

这里面的s1s2就存储在两段不同的内存中。

继承

本篇文章的标题是“C# 面向对象”,但是,C#并不是一种纯粹的面向对象编程语言,C#中还包含一些非面向对象的特性,比如静态成员、静态方法和值类型等,还支持一些其他的编程范式,比如泛型编程、异步编程和函数式编程。虽然但是,面向对象仍然是C#中的一个重要概念,也是.NET提供的所有库的核心原则。

面向对象编程有四项基本原则:

  • 抽象:将实体的相关特性和交互建模为类,以定义系统的抽象表示。
  • 封装:隐藏对象的内部状态和功能,并仅允许通过一组公共函数进行访问。
  • 继承:根据现有抽象创建新抽象的能力。
  • 多形性:跨多个抽象以不同方式实现继承属性或方法的能力。【多态性】

在我们学习和使用类的过程中都或多或少在应用抽象、封装这些概念,或者说这些思想,我们之前都是在使用单个的某一个类,但在开发过程中,我们往往会遇到这样一种情况:很多我们声明的类中都有相似的数据,比如一个游戏,里面有Boss类、Enermy类,这些类有很多相同的属性,但是也有不同的,比方说BossEnermy都会飞龙在天,但是Boss还会乌鸦坐飞机这种高阶技能等等,这个时候我们可以如果按照我们之前的思路,分别编写了两个类,假如飞龙在天的技能被“聪明的”策划废弃了或者调整了参数,我们在维护起来是很不方便的,这个时候就可以使用继承来解决这个问题,它有父类和子类,相同的部分放在父类里就可以了。

继承的类型:

  • 由类实现继承:

    表示一个类型派生于一个基类型,它拥有该基类型的所有成员字段和函数。在实现继承中,派生类型采用基类型的每个函数的实现代码,除非在派生类型的定义中指定重写某个函数的实现代码。在需要给现有的类型添加功能,或许多相关的类型共享一组重要的公共功能时,这种类型的继承非常有用。

  • 由接口实现继承:

    表示一个类型只继承了函数的签名,没有继承任何实现代码。在需要指定该类型具有某些可用的特性时,最好使用这种类型的继承。

细说的话,继承有单重继承和多重继承,单重继承就是一个类派生自一个基类(C#就是采用这种继承),多重继承就是一个类派生自多个类。

派生类也称为子类(subclass);父类、基类也称为超类(superclass)。

一些语言(例如C++)是支持所谓的“多重继承”的,但是关于多重继承是有争议的:一方面,多重继承可以编写更为复杂且较为紧凑的代码;另一方面,使用多重继承编写的代码一般很难理解和调试,也会产生一定的开销。C#的重要设计目标就是简化健壮代码,所以C#的设计人员决定不支持多重继承。一般情况下,不使用多重继承也是可以解决我们的问题的,所以很多编程语言,尤其是高级编程语言就不支持多重继承了。

image-20230514165045302

虽然C#不支持多重继承,但是C#是允许一个类派生自多个接口的,这个后面章节再展开论述。

只需要知道,C#中的类可以通过继承另一个类来对自身进行拓展或定制,子类可以继承父类的所有函数成员和字段(继承父类的所有功能而无需重新构建),一个类只能有一个基类(父类),而且它只能继承自唯一一个父类❗但是,一个类可以被多个类继承,这会使得类之间产生一定的层次,也被称为多层继承C#支持,并且很常用)。到这,你可能会想到,我们之前写的声明Customer类啊或者Vehicle啊它们有父类嘛❓答案当然是有的。就像在值类型、引用类型所说的,所有类型都有一个基类型就是Object类,当然了Object可没有基类,不能套娃嘛不是😅

实现继承

接着上面的游戏案例:

  • 基类:敌人类 - hp speed AI() Move()
  • 派生类:bosstype1enemytype2enemy

基类(父类):

class Enemy
{
    protected int hp;
    protected int speed;
    public void AI() { Console.WriteLine("AI"); }
    public void Move() { Console.WriteLine("Move"); }
}

protected:仅允许在包含类或者子类中访问

派生类Boss(子类):

class Boss : Enemy
{
    private int attack;	// Boss的攻击力比普通小兵攻击力高
    public Boss(int attack)
    {
        this.attack = attack;
    }
    public void Skill() { Console.WriteLine("Boss Skill"); }
    public void Print()
    {
        Console.WriteLine("HP:"+hp);
        Console.WriteLine("Speed:" + speed);
        Console.WriteLine("Attack:" + attack);
    }
}

创建一个Boss对象,看一下:

static void Main(string[] args)
{
    Boss boss = new Boss(100);
    boss.Print();
    //Output:
    //HP:0
    //Speed:0
    //Attack:100
    Console.ReadKey();
}

虽然可以访问基类的字段,但是在创建对象的时候是没有赋值的,使用的是默认值,那怎样才能在创建对象的时候也给基类的字段赋值呢❓

this 关键字指代类的当前实例嘛:

class Boss : Enemy
{
    private int attack;

    public Boss(int attack,int hp,int speed)
    {
        this.attack = attack;
        this.hp = hp;
        this.speed = speed;
    }

    public void Skill() { Console.WriteLine("Boss Skill"); }
    public void Print()
    {
        Console.WriteLine("HP:"+hp);
        Console.WriteLine("Speed:" + speed);
        Console.WriteLine("Attack:" + attack);
    }
}

this当然是而可行的,但是除了this之外,还有一个专门的关键字来帮助我们从派生类中访问基类成员 - base

this 和 base 关键字

this:代指当前实例本身,可避免字段、局部变量或属性之间发生混淆。this引用仅在类或结构体的非静态成员中有效。

base:用于从派生类中访问基类成员,它有两个重要作用:

​ - 调用基类上已被其他方法重写的方法。

​ - 指定创建派生类实例时应调用的基类构造函数。

使用base关键字的构造函数:

  private int attack;

    public Boss(int attack,int hp,int speed)
    {
        this.attack = attack;
        base.hp = hp;
        base.speed = speed;
    }

有什么好处呢,加入派生类中的字段和基类中的字段一样时,就可以通过这种方式来避免混淆。假设我们的派生类Boss里面也有个hp的字段:

class Boss : Enemy
{
    private int attack;
    private int hp;
    public Boss(int attack,int hp,int speed)
    {
        this.attack = attack;
        //this.hp = hp;
        //this.speed = speed;
        base.hp = hp;
        base.speed = speed;
    }
    public void Skill() { Console.WriteLine("Boss Skill"); }
    public void Print()
    {
        Console.WriteLine("HP:"+hp);
        Console.WriteLine("Base.HP:"+base.hp);
        Console.WriteLine("Speed:" + speed);
        Console.WriteLine("Attack:" + attack);
    }
}

我们再创建一个实例:

image-20230514211314068

传进去的hp的值是根据 base.hp = hp;给到了基类中的hp字段。但是一般情况下,不推荐派生类中和基类重名的字段,一般在子类重写父类方法时通过base关键字区分,就是上面说的第一种应用。

通过base关键字是没办法访问attck字段的,base.attack会报错。

base所访问的基类是类声明中指定的基类,不能是多级访问。

重载和重写

重载(Overloading):指的是在同一个类中定义多个具有相同名称但参数列表不同的方法。通过重载,可以在同一个类中创建多个方法,它们执行相似的操作但接受不同类型或数量的参数。编译器会根据调用时提供的参数类型和数量来确定调用哪个重载方法。重载可以提高代码的可读性和灵活性,允许在不同的情况下使用相同的方法名进行不同的操作。

例如,在一个名为Calculator的类中可以定义多个名为add的方法,如下所示:

class Calculator
{
  public int Add(int a, int b)
  {
    return a + b;
  }
  public double Add(double a, double b)
  {
    return a + b;
  }
}

上述代码中,Calculator类定义了两个重载的add方法,一个用于整数相加,另一个用于浮点数相加。根据提供的参数类型,编译器会选择适合的重载方法。

🧨🧨🧨注意

方法签名由方法名、参数数量和参数类型共同决定,方法的返回类型不计入签名。两个同名方法如果获取相同的参数列表,就说它们有相同的签名,即使它们的返回类型不同。

重写(Overriding):指的是子类重新实现(覆盖)了从父类继承的方法,以改变方法的行为。当子类需要修改父类中的方法实现时,可以使用方法重写。在重写过程中,子类需要使用相同的方法名称、相同的参数列表和相同或更宽松的访问修饰符来重新定义父类的方法。重写方法可以提供特定于子类的实现。

以下是一个简单的例子,展示了父类Animal和子类Cat之间的方法重写:

class Animal
{
    public virtual void MakeSound()
    {
        Console.WriteLine("Animal makes sound");
    }
}

class Cat : Animal
{
    public override void MakeSound()
    {
        Console.WriteLine("Cat meows");
    }
}

在上述代码中,Animal类定义了一个名为MakeSound的方法,而Cat类继承自Animal类并重写了MakeSound方法。当调用MakeSound方法时,如果对象是Cat类的实例,将执行Cat类中的重写方法,输出"Cat meows";否则,将执行父类Animal的原始方法,输出"Animal makes sound"。

总结来说,重载用于在同一个类中定义多个具有相同名称但参数列表不同的方法,以便在不同情况下执行不同的操作。而重写用于子类重新实现(覆盖)继承自父类的方法,以改变方法的行为。

我感觉这俩兄弟八竿子打不着,但是我一开始理解错了,所以Mark一下🎈🎈🎈

隐藏成员

编程最困难的地方之一是为标识符想一个独特的、有意义的名称,如果基类和派生类同时声明了两个具有相同签名的方法,编译时会显示一个警告。比如下面的例子中,当我们在Boss类中重新定义一个Move方法,编译器会显示警告消息指出Boss.Move()隐藏了继承的成员Enemy.Move(),也就是说Boss继承的基类EnemyMove方法就会被隐藏掉:

image-20230514213744753

这个时候创建一个Boss对象并调用Move方法会是在Boss类中重新定义的方法:

image-20230514213926861

虽然代码能编译并运行,但是应该严肃对待该警告。如果有另一个派生自Boss类并调用了Move方法,它想要调用的可能是Enemy类实现的Move方法,但是这被Boss类中的Move隐藏了,所以实际调用的是后者。大多数时候,像这样的巧合会成为混乱之源。应重命名方法以免冲突。但如果确实希望两个方法具有相同签名,从而隐藏EnemyMove方法,可明确使用new关键字消除警告,告诉编译器我知道自己在干什么:

class Boss : Enemy
{
  public int attack; // Boss的攻击力比普通小兵攻击力高
  public Boss(int attack, int hp, int speed)
  {
    this.attack = attack;
    base.hp = hp;
    base.speed = speed;
  }
  public new void Move()
  {
    Console.WriteLine("Boss Move");
  }
  ...

虚方法

什么是虚方法?

虚方法(Virtual method)是一种允许子类重写(覆盖)父类以实现方法的方法。通过声明方法为虚方法,可以在父类中定义一个方法,并允许子类通过重写该方法来提供自己的实现。在父类中,可以使用 virtual 关键字来声明一个方法为虚方法。虚方法允许子类通过使用 override 关键字来重写该方法,以便子类可以根据自身的需要改变方法的行为。

为什么需要虚方法?

为了隐藏方法在基类中的实现🥝这么说可能比较不好理解,我们举个例子,在开始学习C#的时候大家可能会注意到C#中万物都可ToString(),任何类型的实例都可以通过调用ToString方法将自身转换为一个字符串,这得益于C#拥有统一的类型系统,其所有类型都共享一个公共的基类Object,因为它很有用所以设计者把它作为Object的成员自动提供给所有类。我们看看boss实例在字符串之后会输出什么:

image-20230517212619595

👻输出内容并不尽如人意,那ToString是如何将实例转换成字符串的呢?派生类(指的是所有类都派生于基类System.Object)中可能包含任意数量的字段,这些字段包含的值应该是字符串的一部分,但是System.Object中实现的ToString太过于简单,它唯一能做的就是将对象转化成其类型名称的字符串,就像图片中输出的那样CSharpTutorial_01.Program+Boss,这种转换是毫无意义的,那为什么要提供一个没有用的方法呢?

显然,ToString是一个很好的概念,所有类都应当提供一个方法将对象转换成字符串,以便于查看和调试。事实上,System.Object中实现的ToString只是一个“占位符”,我们应该在每一个自定义类中提供自己的ToString方法 ———— 重写基类System.Object中的ToString方法。

我们选中ToString进入到定义可以发现,在基类System.ObjectToString故意设计成要被重写的带有virtual的虚方法。

image-20230517164945714

接下来我们在Boss中实现我们自己的ToString方法(当你在VS中拼完override`关键字后智能提示会显示当前可重写的方法列表,并且选中后会有一个默认实现):

image-20230517214330504

我们通过该方法显示当前对象的hpattackspeed

class Boss : Enemy
{
  private int attack;
  private int hp;
  public Boss(int attack, int hp, int speed)
  {
    this.attack = attack;
    base.hp = hp;
    base.speed = speed;
  }
  // . . .
  public override string ToString()
  {
    return "HP: " + base.hp + " " + "Attack: " + this.attack + " " + "Speed: " + base.speed;
  }
}

故意设计成要被重写的方法称为虚(virtual)方法。“重写方法”和“隐藏方法(隐藏成员)”的区别现在应该很明显了。重写是提供同一个方法的不同实现,这些方法有关系,因为都旨在完成相同的任务,只是不同的类用不同的方式。但隐藏是指方法被替换成另一个方法,方法通常没关系,而且可能执行完全不同的任务。对方法进行重写是有用的编程概念:而如果方法被隐藏,则意味着可能发生了一处编程错误(除非你加上new强调自己没错)。

Object.ToString 方法

虚方法和多态性

多态性(Polymorphism)是面向对象编程中的一个重要概念,指的是在运行时能够根据对象的实际类型来执行不同的操作。

在多态性中,重要的概念是虚方法和方法重写。通过在父类中声明虚方法,并在子类中使用 override 关键字重写该方法,可以实现多态性。当使用基类类型的引用变量引用派生类对象并调用虚方法时,实际上会根据对象的实际类型来选择执行哪个重写方法。

多态性允许使用基类类型的引用变量来引用派生类对象,并根据对象的实际类型来调用相应的方法。这使得我们可以在不同的对象上执行相同的操作,而不需要针对每个具体的对象类型编写单独的代码。这样做的好处是,在编写代码时,我们不需要针对每个具体的对象类型编写单独的代码。我们可以使用一个通用的代码块来处理所有派生类对象,只需要使用基类类型的引用变量来引用它们,并调用相同的方法。

接着上面的游戏案例,除了boss不是还有type1enemytype2enemy两个派生类吗,假如说bosstype1enemytype2enemy都有各自不同于基类Enemy的行动方式,也就是Move方法,这时候就可以用到多态性():

class Enemy
{
  protected int hp;
  protected int speed;
  public void AI() { Console.WriteLine("AI"); }
  public virtual void Move() { Console.WriteLine("Move"); }
}
class Boss : Enemy
{
  public int attack; // Boss的攻击力比普通小兵攻击力高
  public override void Move()
  {
    Console.WriteLine("Boss Move");
  }
}
class Type1enemy : Enemy
{
  public override void Move()
  {
    Console.WriteLine("type1enemy Move");
  }
}
class Type2enemy : Enemy
{
  public override void Move()
  {
    Console.WriteLine("type2enemy Move");
  }
}

通过多态性,我们可以使用基类类型的引用变量来引用不同派生类的对象,然后调用它们的Move()方法:

internal class Program
{
  static void Main(string[] args)
  {
    Enemy boss = new Boss();
    boss.Move();
    Enemy enemy1 = new Type1enemy();
    enemy1.Move();
    enemy1 = new Type2enemy();
    enemy1.Move();
    Console.ReadKey();
  }
}

执行结果为:

Boss Move
type1enemy Move
type2enemy Move

这种方式使得我们的代码更加灵活和可扩展。当我们新增一个派生类时,只需要让它继承自基类并重写基类中的方法,然后我们就可以在通用的代码中使用基类类型的引用变量来引用新的派生类对象,并调用相同的方法,无需修改通用的代码块。

总结来说,多态性通过使用基类类型的引用变量来引用派生类对象,并根据对象的实际类型来选择执行相应的方法,实现了在不同的对象上执行相同操作的便利性,减少了代码的重复和冗余。

抽象方法和抽象类

抽象类(Abstract class)是一种特殊的类,它不能被实例化,只能被用作其他类的基类。抽象类用于定义一组相关的类的公共结构和行为,并可以包含抽象成员(抽象方法、抽象属性等)和非抽象成员

抽象类通过在类定义前面加上 abstract 关键字来声明。抽象类可以包含普通方法的实现和抽象方法的定义。抽象方法是没有具体实现的方法,只有方法的签名(返回类型、方法名和参数列表),并且在派生类中必须进行重写。显然,抽象方法本身也是虚拟Virtual的(虽然不需要提供virtual关键字,事实上,如果写了该关键字,程序会产生一个语法错误😅)。如果类包含抽象方法,那么这个类就是抽象的,并且必须声明为抽象类。

那么什么时候用到抽象类呢?继续之前的游戏案例,敌人有很多种,都会攻击,但是每个人的攻击方式都不一样,在父类Enemy中声明的Attack来表示攻击,这个时候我们在父类里阐述清楚关于Attack的详细定义是没有用处的,因为我们都知道每个敌人的攻击方式是不一样的,即使我们声明定义好也需要在子类中根据子类的种类来定义不同的攻击方式,这个时候,我们只需要在父类中有一个“攻击方式”的占位符就行了,不必要具体实现,就和上面说的ToString方法类似(当然也是有些细微区别的哈)。

如果一个方法在抽象类中提供默认实现没有意义,但有需要派生类提供该方法夫人实现,那这个方法就适合定义成抽象方法:

 public abstract void Attack();

这样写了之后我们会发现编译器会报错:

image-20230520195425226

这就是上面说的,如果类包含抽象方法,那么这个类就是抽象类,并且必须声明为抽象类:

abstract class Enemy
{
    public abstract void Attack();
}

可以认为,抽象方法是不完整的,因为它包含的抽象成员是不完整的,需要在每个派生类中完成定义。当创建抽象类的实例时是错误的,因为类中的成员是不完整的,当然了声明一个Enemy对象是可以的,但是实例化不可以,可以通过它的派生类来完成构造。

我们完善一下Enemy类内容:

abstract class Enemy
{
    private int hp;
    private int speed;
    public void Move() { Console.WriteLine("Move"); }
    public abstract void Attack();
}

接着来实现一个Boss派生类,按照以往的继承方法Boss在继承基类Enemy后会报错:

image-20230520200426205

这个时候我们可以通过那个小灯泡💡快速实现抽象类,然后实现各自的方法就可以了:

image-20230520200604337

class Boss : Enemy
{
    public override void Attack()
    {
        Console.WriteLine("Boss Attack");
    }
}

以面向对象思想,重复的代码是警告的信号,应该重构以免重复并减少维护开销。

密封类和密封方法

实际开发中使用较少,但是语法简单。/

如果不想一个类作为基类,可以使用sealed(密封)关键字防止类被用作基类。如果你不想让子类重写某个方法,可以添加sealed(密封)来防止子类重写该方法。

  • 防止代码混乱
  • 商业原因

派生类的构造函数

前面章节中我们介绍过单个类的构造函数如果定义以及如何工作的,还有在继承的时候如果通过base关键字访问基类字段完成初始化,我们知道,继承除了得到方法派生类还会自动包含来自基类的所有字段,这写字段通常需要初始化。此外,所有类都至少有一个构造器(没有显示声明的话,编译器会自动生成一个无参的默认构造器),当我们在派生类定义构造函数的时候可以通过base关键字调用基类的构造函数:

class BaseClass
{
    public BaseClass()
    {
        Console.WriteLine("基类 构造函数");
    }
}
class DrivedClass : BaseClass
{
    public DrivedClass() :base()
    {
        Console.WriteLine("派生类 构造函数");
    }
}

初始化一个DrivedClass实例后先后调用,注意是先调用基类的构造函数:

image-20230520203551548

如果我们不写base()也会调用基类的构造函数的,因为你继承了。。。

上面是无参的情况,那有参的情况呢?

class BaseClass
{
    private string name;
    private string description;
    public BaseClass()
    {
        Console.WriteLine("基类 构造函数");
    }

    public BaseClass(string name, string description)
    {
        this.name = name;
        this.description = description;
    }
}
class DrivedClass : BaseClass
{
    private int age;

    public DrivedClass(int age,string name,string description):base(name,description)
    {
        this.age = age;
    }

    public DrivedClass() :base()
    {
        Console.WriteLine("派生类 构造函数");
    }
}

image-20230520204304216

修饰符

前面遇到过很多修饰符,或修饰类、类成员,或指定方法得到可见性,又或是指定其本质 - virtualabstractC#有许多修饰符。

访问修饰符

  • public:同一程序集中的任何其他代码或引用该程序集的其他程序集都可以访问该类型或成员。 某一类型的公共成员的可访问性水平由该类型本身的可访问性级别控制。
  • private:只有同一 classstruct 中的代码可以访问该类型或成员。
  • protected:只有同一 class 或者从该 class 派生的 class 中的代码可以访问该类型或成员。
  • internal:同一程序集中的任何代码都可以访问该类型或成员,但其他程序集中的代码不可以。 换句话说,internal 类型或成员可以从属于同一编译的代码中访问。
  • protected internal:该类型或成员可由对其进行声明的程序集或另一程序集中的派生 class 中的任何代码访问。
  • private protected:该类型或成员可以通过从 class 派生的类型访问,这些类型在其包含程序集中进行声明。

image-20230520210549077

publicprivate修饰字段和方法的时候,表示该字段或者方法能不能通过对象去访问,只有public的才可以通过对象访问,private(私有的)只能在类内部访问。protected保护的,当没有继承的时候,它的作用和private是一样的,当有继承的时候,protected:表示可以被子类访问的字段或者方法

其他修饰符

  • abstract:使用abstract修饰的类为抽象类,抽象类只能是其他类的基类,不能与sealedstatic一起使用。

    abstract可以修饰抽象类中的方法或属性,此时,方法或属性不能包含实现,且访问级别不能为私有。

    抽象类不能被实例化。

  • sealed:使用sealed修饰的类为密封类,密封类无法被继承,不能和abstractstatic一起使用。

    sealed用于方法或属性时,必须始终与override一起使用。

  • static:使用static修饰的类为静态类,静态类所有成员都必须是静态的,不能与abstractsealed一起使用。

    static可以修饰方法、字段、属性或事件,始终通过类名而不是实例名称访问静态成员,静态字段只有一个副本。

    静态类不能被实例化。

  • const:使用const关键字来声明某个常量字段或常量局部变量,必须在声明常量时赋初值。

    不能与static一起使用,常量默认是static的,常量字段只有一个副本。

  • readonly:使用readonly关键字来声明只读字段。

    只读字段可以在声明或构造函数中初始化,每个类或结构的实例都有一个独立的副本。

    可以与static一起使用,声明静态只读字段。

    静态只读字段可以在声明或静态构造函数中初始化,静态常量字段只有一个副本。

  • virtual:virtual关键字用于修饰方法、属性、索引器或事件声明,并使它们可以在派生类中被重写。

    默认情况下,方法是非虚拟的。 不能重写非虚方法。

    virtual修饰符不能与staticabstractprivateoverride修饰符一起使用。

  • override:要扩展或修改继承的方法、属性、索引器或事件的抽象实现或虚实现,必须使用override修饰符。

    重写的成员必须是virtualabstractoverride的。

C#常用修饰符

关于static

C#中,static关键字用于声明静态成员,这意味着它们与类相关而不是与类的实例(对象)相关。以下是static关键字的一些常见用法:

  1. 静态字段(Static Fields): 静态字段是与类相关联的字段,而不与类的实例相关联。它们在类的所有实例之间共享相同的值。静态字段可以通过类名直接访问,而无需创建类的实例。下面是一个静态字段的示例:

    class Counter
    {
        public static int Count;  // 静态字段
    
        public Counter()
        {
            Count++;
        }
    }
    
    Console.WriteLine(Counter.Count);  // 输出:0
    
    Counter counter1 = new Counter();
    Console.WriteLine(Counter.Count);  // 输出:1
    
    Counter counter2 = new Counter();
    Console.WriteLine(Counter.Count);  // 输出:2
    
  2. 静态方法(Static Methods): 静态方法是属于类而不是类的实例的方法。它们可以直接通过类名调用,无需创建类的实例。静态方法通常用于执行与类相关的任务,而不需要访问实例的状态。下面是一个静态方法的示例:

    class MathUtils
    {
        public static int Add(int a, int b)  // 静态方法
        {
            return a + b;
        }
    }
    
    int result = MathUtils.Add(5, 3);  // 调用静态方法
    Console.WriteLine(result);  // 输出:8
    

    🔜在静态方法中只能访问和使用静态成员

  3. 静态类(Static Classes): 静态类是一种特殊类型的类,它只包含静态成员,并且不能被实例化。静态类通常用于提供一组相关的静态方法和工具函数,不如Math类。下面是一个静态类的示例:

    static class StringUtils
    {
        public static bool IsNullOrEmpty(string str)
        {
            return string.IsNullOrEmpty(str);
        }
    }
    
    bool isEmpty = StringUtils.IsNullOrEmpty("");  // 调用静态方法
    Console.WriteLine(isEmpty);  // 输出:True
    

请注意,静态成员只能访问其他静态成员,不能直接访问实例成员。而实例成员可以访问静态成员。

静态字段在内存中存储在特定的位置,这取决于它们的访问修饰符和作用域。

对于静态字段,它们的存储位置有两种情况:

  1. 静态字段存储在静态数据区(Static Data Area): 当静态字段是类的静态成员时,它们存储在静态数据区。静态数据区在程序启动时分配,并在整个程序执行期间保持不变。静态字段的内存分配在程序开始运行时进行,当程序结束时,静态数据区的内存会被释放。
  2. 静态字段存储在元数据区(Metadata Area): 当静态字段是类的常量成员(使用const修饰符)时,它们存储在元数据区。元数据区是用于存储类型信息和常量的地方,它在程序编译时就被确定,并随着程序的执行一直存在。

无论静态字段存储在静态数据区还是元数据区,它们都具有全局可见性,可以在程序的任何地方访问。

需要注意的是,静态字段是与类相关联的,而不是与类的实例相关联。这意味着所有类的实例共享相同的静态字段,它们在内存中只有一份副本。

此外,静态字段的生命周期和应用程序的生命周期一致。它们在应用程序启动时初始化,并在应用程序关闭时销毁。

接口

接口(Interface)是一种在C#中定义协定(Contract)的方式,它描述了类或结构体应该具有的成员(方法、属性、事件等)。接口定义了一组公共行为,但不提供实现细节。

接口在很多方面和抽象类类似,声明接口在语法上和声明抽象类完全相同,但是在接口中不允许提供任何成员的实现方式,它是纯抽象的。此外,接口既不能有构造函数也不能有字段,接口的定义也不允许有运算符的重载,接口成员总是隐式Public的,也不能声明成员的修饰符,比如Virtual。如果需要的话,应该由实现的类来完成。

🎑接口和抽象类在构造函数上是不同的,虽然二者都不能创建其对应的实例,但是抽象类可以定义构造函数,只不过只能在创建子类实例时才可以调用。

C#中,接口使用 interface 关键字定义,可以包含方法、属性、事件和索引器等成员的声明。接口中的成员默认是公共的,并且不能包含字段或实现代码。类可以实现一个或多个接口,表示类承诺实现接口中定义的所有成员。

接口名称前面一般添加 "I" 来表示接口的特殊性,这是一种常见的命名约定。例如,IShape 表示一个形状接口。

以下是一个简单的接口示例,另外很多操作我们都可以借助VS的Intelligence快速完成:

show
namespace CSharpTutorial_01
{
    internal class Program
    {
        static void Main(string[] args)
        {
            Eagle eagle = new Eagle();
            eagle.Fly();
            eagle.FlyAttack();

            IFly fly = new Eagle();
            fly.Fly();
            fly.FlyAttack();
            fly = new Bird();
            fly .Fly();
            fly.FlyAttack();

            Console.ReadKey();
        }
    }
    interface IFly
    {
        void Fly(); // 飞翔
        void FlyAttack();   // 虚空打击
    }
    class Eagle : IFly
    {
        public void Fly()
        {
            Console.WriteLine("飞鹰展翅");
        }

        public void FlyAttack()
        {
            Console.WriteLine("龙卷风摧毁停车场");
        }
    }
    class Bird : IFly
    {
        public void Fly()
        {
            Console.WriteLine("怒鸦飞行");
        }

        public void FlyAttack()
        {
            Console.WriteLine("乌鸦坐飞机");
        }
    }
}

IFly相当简单的接口,只定义了两个方法。大多数接口都包含许多成员,执行结果:

image-20230522205907534

某个模块需要包含若干个功能,这个时候就可以将这些功能放在一个接口中,如果某个类想要拥有这个功能的话就自行去实现这个接口就可以了。

接口的继承

接口可以彼此继承,其方式与类的继承方式相同。哦,对了,记得C#里派生类只能继承自一个基类,但是可以继承多个接口也就是多接口实现,只要老老实实实现每个接口就行啦。接口继承语法很简单,继承哪个接口就:Ixxxxx就可以了,只要在实现这个接口的那个类里面需要把继承的接口也实现:

internal class Program
{
    static void Main(string[] args)
    {
        Pterosaur pterosaur = new Pterosaur();
        pterosaur.Run();
        pterosaur.Fly();
        Console.ReadKey();
    }
}
interface IFly
{
    void Fly(); // 飞翔
    void FlyAttack();   // 虚空打击
}
interface IRun:IFly
{
    void Run();
}
class Pterosaur : IRun
{
    public void Fly()
    {
        Console.WriteLine("Fly");
    }

    public void FlyAttack()
    {
        Console.WriteLine("FlyAttack");
    }

    public void Run()
    {
        Console.WriteLine("Run");
    }
}

接口继承不太常用。

索引器

不太常用

索引器(Indexer)允许通过类实例的类似数组的语法来访问对象的元素。索引器允许在类内部定义一个特殊的访问器(GetterSetter),通过索引参数来获取或设置对象中的元素。

使用索引器,可以像使用数组一样通过索引访问对象中的元素,这使得对象可以按照一定的顺序组织和访问数据。索引器提供了一种方便的方式来访问和操作对象的元素,增加了代码的可读性和易用性。

以下是一个使用索引器的简单示例:

class MyList
{
    private int[] data;

    public MyList()
    {
        data = new int[10];
    }

    // 索引器的定义
    public int this[int index]
    {
        get
        {
            return data[index];
        }
        set
        {
            data[index] = value;
        }
    }
}

// 使用索引器访问对象的元素
MyList myList = new MyList();
myList[0] = 1;  // 设置索引为0的元素的值
int value = myList[0];  // 获取索引为0的元素的值

在上面的示例中,MyList 类定义了一个索引器,它允许通过整数索引访问内部的 data 数组。索引器的访问器使用 getset 关键字来定义获取和设置元素的逻辑。

通过使用索引器,可以通过类似 myList[0] 的语法来访问 MyList 对象中的元素,就像访问数组元素一样。在索引器的背后,实际上是调用了索引器的访问器方法。

需要注意的是,索引器可以具有多个参数,以便实现多维索引或具有其他复杂的访问逻辑。此外,一个类可以定义多个索引器,只要它们的参数类型和个数不同即可。

运算符重载

不太常用

运算符重载(Operator Overloading)允许我们为自定义的类或结构体定义运算符的行为。通过运算符重载,我们可以对自定义类型的对象执行类似于内置类型的操作,使代码更具表达力和易读性。

C#中,可以对很多运算符进行重载,例如算术运算符(+、-、*、/等)、关系运算符(==、!=、>、<等)、逻辑运算符(&&、||等)等。

以下是一个简单的示例,展示了如何重载等号运算符:

开始之前,先看一下传统的==运算符,定义一个Student类:

class Student
{
    private int age;
    private string name;
    private long id;

    public Student(int age, string name, long id)
    {
        this.age = age;
        this.name = name;
        this.id = id;
    }
}

创建n个实例进行比较:

image-20230522214606888

s1s2一样但是为什么不相等呢?这是因为==运算符比较的两个变量s1s2分别是存储的在堆中的s1s2的内存地址,虽然内容一样,但是它们指向的内存地址是不一样,所以就出现了上面的输出结果。

那如果想在判断是否相等的时候判断的时候比较的是存储的字段而不是变量地址怎么办呢?这个时候就可以重载==运算符:

internal class Student
{
    private int age;
    private string name;
    private long id;

    public Student(int age, string name, long id)
    {
        this.age = age;
        this.name = name;
        this.id = id;
    }
    public static bool operator==(Student a, Student b)
    {
        if (a.age == b.age && a.name == b.name && a.id == b.id) return true;
        return false;
    }
    public static bool operator !=(Student a, Student b)
    {
        bool result = a == b; return result;
    }
}

这个时候再执行上面的比较:

image-20230522215737881

对于其他的运算符重载大家可自行尝试。

posted @ 2023-05-23 09:00  PixelKiwi  阅读(1471)  评论(10编辑  收藏  举报