前言
C#
是一种面向对象、类型安全的语言。
❓什么是面向对象
面向对象编程(OOP)是如今多种编程语言所实现的一种编程范式,包括 Java、C++、C#。
面向对象编程将一个系统抽象为许多对象的集合,每一个对象代表了这个系统的特定方面。对象包括函数(方法)和数据。一个对象可以向其他部分的代码提供一个公共接口,而其他部分的代码可以通过公共接口执行该对象的特定操作,系统的其他部分不需要关心对象内部是如何完成任务的,这样保持了对象自己内部状态的私有性。
面向对象和面向过程的区别:
面向对象:用线性的思维。与面向过程相辅相成。在开发过程中,宏观上,用面向对象来把握事物间复杂的关系,分析系统。微观上,仍然使用面向过程。
面向过程:是一种是事件为中心的编程思想。就是分析出解决问题所需的步骤,然后用函数把这写步骤实现,并按顺序调用。
简单来说:用面向过程的方法写出来的程序是一份蛋炒饭,而用面向对象写出来的程序是一份盖浇饭。所谓盖浇饭,就是在米饭上面浇上一份盖菜,你喜欢什么菜,你就浇上什么菜。
❓为什么使用面向对象编程
面向对象编程,可以让编程更加清晰,把程序中的功能进行模块化划分,每个模块提供特定的功能,同时每个模块都是孤立的,这种模块化编程提供了非常大的多样性,大大增加了重用代码的机会,而且各模块不用关心对象内部是如何完成的,可以保持内部的私有性。简单来说面向对象编程就是结构化编程,对程序中的变量结构划分,让编程更清晰。
准确地说,本文所提及到的特性是一种特别的面向对象编程方式,即基于类的面向对象编程(class-based OOP)。当人们谈论面向对象编程时,通常来说是指基于类的面向对象编程。
类 - 实际上是创建对象的模板。当你定义一个类时,你就定义了一个数据类型的蓝图。这实际上并没有定义任何的数据,但它定义了类的名称,这意味着什么,这意味着类的对象由什么组成及在这个对象上可执行什么操作。对象是类的实例。构成类的方法和变量称为类的成员。
类的定义和使用
类中的数据和函数称为类的成员:
- 数据成员
- 数据成员是包含类的数据 - 字段,常量和事件的成员。
- 函数成员
- 函数成员提供了操作类中数据的某些功能 - 方法,属性,构造器(构造方法)和终结器(析构方法),运算符,和索引器
拿控制台程序为例,当我们创建一个空的控制台项目,在Main()
函数里编程的时候就是在Program
类里面操作的:
而且,我们可以发现,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
类,具有Run
、Stop
等方法,具有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
中的SetX
、SetY
和 SetZ
方法是普通的方法,而不是属性。它们仅仅是修改和访问实例中私有字段的方法。它们需要一个参数才能设置相应的字段值,而属性是通过访问器方法来设置或获取字段的值,并且不需要额外的参数。
public 和 private 访问修饰符
- 访问修饰符(C# 编程指南)
public
修饰的数据成员和成员函数是公开的,所有的用户都可以进行调用。private
修饰词修饰的成员变量以及成员方法只供本类使用,也就是私有的,其他用户是不可调用的。
public
和private
这两个修饰符其实从字面意思就可以理解,没什么不好理解的,前者修饰的字段大家可以随意操作,千刀万剐只要你乐意,而后者修饰的字段就不能任你宰割了,你只能通过Get
、Set
进行一系列的访问或者修改。
举个例子,生活中每个人都有名字、性别,同时也有自己的银行卡密码,当别人跟你打交道的时候,他一般会先得知你的名字,性别,这些告诉他是无可厚非的,但是当他想知道你的银行卡密码的时候就不太合适了对吧。假设我们有一个类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
只能通过Get
、Set
来操作。
日常开发推荐不要把字段设置为共有的,至少要有点访问限制,当然了除了这两个修饰符,还有其他的,比如internal
、protect
等等,以后的文章可能会专门来写(❓)。
使用private
修饰符除了多了一堆属性(访问器)有什么便利吗?显然得有,public
的字段你在设置的时候说啥就啥,即使它给到的内容可能不适合这个字段,在后者,我们可以在属性里设置一些限制或者是操作。比如,Vector3
类的x
字段显然长度是不会出现负值的,这时候我们就可以在SetX
里面做些限制:
public void SetX(double temp)
{
if (temp<0)
{
Console.WriteLine("数据不合法。");
}
x = temp;
}
对于不想让外界访问的信息我们可以不提供Get
属性以起到保护作用。
构造函数
构造函数 - 也被称为“构造器”,是执行类或结构体的初始化代码。每当我们创建类或者结构体的实例的时候,就会调用它的构造函数。大家可能会疑惑,我们上面创建的类里面也没说这个构造函数这个东东啊,那是因为如果一个类没有显式实例构造函数,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
类中我们是通过编写Get
、Set
方法来访问或者修改字段的数据,这样在实际开发中是很麻烦的,会降低我们的效率而且使用起来我们必须通过调用这两个方法来实现对私有字段的操作:
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);
}
}
我们可以通过属性来快捷实现对私有字段的访问以及修改,通过get
、set
访问器操作私有字段的值。
❓什么是属性呢
-
属性是一种成员,它提供灵活的机制来读取、写入或计算私有字段的值。 属性可用作公共数据成员,但它们是称为“访问器”的特殊方法。 此功能使得可以轻松访问数据,还有助于提高方法的安全性和灵活性。
-
属性允许类公开获取和设置值的公共方法,而隐藏实现或验证代码。
-
属性可以是读-写属性(既有
get
访问器又有set
访问器)、只读属性(有get
访问器,但没有set
访问器)或只写访问器(有set
访问器,但没有get
访问器)。 只写属性很少出现,常用于限制对敏感数据的访问。 -
不需要自定义访问器代码的简单属性可以作为表达式主体定义或自动实现的属性来实现。
上面的SetAge
和GetAge
方法我们用属性替换掉就是:
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
访问器无法访问。
此外,我们可以通过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
引入的新特性,它可以让我们在不显式定义字段和访问器方法的情况下快速定义一个属性。具体来说,一个属性包含一个字段和两个访问器方法,其中get
和set
访问器方法都是自动实现的。
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)
堆
堆是一块内存区域,与栈不同,堆里的内存可以以任意顺序存入和移除。
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 等。这种执行环境为程序提供了许多优势,例如:
- 自动内存管理:托管执行环境为程序管理内存分配和释放,程序员无需手动管理内存,避免了内存泄漏和越界等问题。
- 垃圾回收:托管执行环境提供了垃圾回收服务,自动回收不再使用的内存,提高了程序的性能和可靠性。
- 类型检查:托管执行环境提供了强类型检查,防止了类型错误等问题。
- 平台无关性:托管语言编写的程序可以在不同操作系统和硬件平台上运行,提高了程序的可移植性。
在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#
中的数据类型分为两种:
- 值类型 - 分两类:
struct
和enum
,包括内置的数值类型(所有的数值类型、char
类型和bool
类型)以及自定义的struct
类型和enum
类型。 - 引用类型 - 引用类型包含所有的类类型、接口类型、数组类型或委托类型。和值类型一样,
C#
支持两种预定义的引用类型:object
和string
。
❗❗❗
object
类型是所有类型的基类型,其他类型都是从它派生而来的(包括值类型)。
各自在内存中的存储方式
在此之前,我们需要明白Windows
使用的是一个虚拟寻址系统,该系统把程序可用的内存地址映射到硬件内存中的实际地址上,这些任务完全由Windows
在后台管理。其实际结果是32位处理器上的每个进程都可以使用4GB
的内存————不管计算机上实际有多少物理内存。这4个GB的内存实际上包含了程序的所有部分,包括可执行的代码、代码加载的所有DLL
,以及程序运行时使用的所有变量的内容。这4个GB的内存称为虚拟地址空间、虚拟内存,我们这里简称它为内存。
我们可以借助VS在直观地体会这一特性,任意给个断点,把变量移到内存窗口就可以查看当前变量在内存中的地址以及存储的内容:
例举一些常用的变量:
// 值类型
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");
它们在内存中是怎么存储的呢?
- 值类型就直观的存储在堆中。
array1
在栈中存储着一个指向堆中存放array1
数组首地址的引用,array2
和customer
同理name
字符串,尽管它看上去像是一个值类型的赋值,但是它是一个引用类型,name
对象被分配在堆上。
关于字符串在内存中的存储,虽然它是引用类型,但是它与引用类型的常见行为是有一些区别的,例如:字符串是不可变的。修改其中一个字符串,就会创建一个全新的string
对象,而对已存在的字符串不会产生任何影响。例如:
static void Main(string[] args)
{
string s1 = "a string";
string s2 = s1;
s1 = "another string";
Console.ReadKey();
}
借助VS的内存窗口:
s1
也就是存储着a string
字符串的地址是0x038023DC
,再执行你就会发现s2
的内存地址也是0x038023DC
,但是当s1
中存储的字符串发生变化时,s1
的内存地址也会随之变化,但是s2
的内存地址还是之前a string
所在的位置。
也就是说,字符串的值在发生变化时并不会替换原来的值,而是在堆上为新的字符串值分配一个新的对象(内存空间),之前的字符串值对象是不受影响的【这实际上是运算符重载的结果】。
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
也跟着发生了变化,之所以这样和引用类型在内存中的存储方式是密不可分的:
在创建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();
}
这里面的s1
和s2
就存储在两段不同的内存中。
继承
本篇文章的标题是“C# 面向对象”,但是,C#
并不是一种纯粹的面向对象编程语言,C#
中还包含一些非面向对象的特性,比如静态成员、静态方法和值类型等,还支持一些其他的编程范式,比如泛型编程、异步编程和函数式编程。虽然但是,面向对象仍然是C#
中的一个重要概念,也是.NET
提供的所有库的核心原则。
面向对象编程有四项基本原则:
- 抽象:将实体的相关特性和交互建模为类,以定义系统的抽象表示。
- 封装:隐藏对象的内部状态和功能,并仅允许通过一组公共函数进行访问。
- 继承:根据现有抽象创建新抽象的能力。
- 多形性:跨多个抽象以不同方式实现继承属性或方法的能力。【多态性】
在我们学习和使用类的过程中都或多或少在应用抽象、封装这些概念,或者说这些思想,我们之前都是在使用单个的某一个类,但在开发过程中,我们往往会遇到这样一种情况:很多我们声明的类中都有相似的数据,比如一个游戏,里面有Boss
类、Enermy
类,这些类有很多相同的属性,但是也有不同的,比方说Boss
和Enermy
都会飞龙在天,但是Boss
还会乌鸦坐飞机这种高阶技能等等,这个时候我们可以如果按照我们之前的思路,分别编写了两个类,假如飞龙在天的技能被“聪明的”策划废弃了或者调整了参数,我们在维护起来是很不方便的,这个时候就可以使用继承来解决这个问题,它有父类和子类,相同的部分放在父类里就可以了。
继承的类型:
-
由类实现继承:
表示一个类型派生于一个基类型,它拥有该基类型的所有成员字段和函数。在实现继承中,派生类型采用基类型的每个函数的实现代码,除非在派生类型的定义中指定重写某个函数的实现代码。在需要给现有的类型添加功能,或许多相关的类型共享一组重要的公共功能时,这种类型的继承非常有用。
-
由接口实现继承:
表示一个类型只继承了函数的签名,没有继承任何实现代码。在需要指定该类型具有某些可用的特性时,最好使用这种类型的继承。
细说的话,继承有单重继承和多重继承,单重继承就是一个类派生自一个基类(C#
就是采用这种继承),多重继承就是一个类派生自多个类。
派生类也称为子类(subclass);父类、基类也称为超类(superclass)。
一些语言(例如C++
)是支持所谓的“多重继承”的,但是关于多重继承是有争议的:一方面,多重继承可以编写更为复杂且较为紧凑的代码;另一方面,使用多重继承编写的代码一般很难理解和调试,也会产生一定的开销。C#
的重要设计目标就是简化健壮代码,所以C#
的设计人员决定不支持多重继承。一般情况下,不使用多重继承也是可以解决我们的问题的,所以很多编程语言,尤其是高级编程语言就不支持多重继承了。
虽然C#
不支持多重继承,但是C#
是允许一个类派生自多个接口的,这个后面章节再展开论述。
只需要知道,C#
中的类可以通过继承另一个类来对自身进行拓展或定制,子类可以继承父类的所有函数成员和字段(继承父类的所有功能而无需重新构建),一个类只能有一个基类(父类),而且它只能继承自唯一一个父类❗但是,一个类可以被多个类继承,这会使得类之间产生一定的层次,也被称为多层继承(C#
支持,并且很常用)。到这,你可能会想到,我们之前写的声明Customer
类啊或者Vehicle
啊它们有父类嘛❓答案当然是有的。就像在值类型、引用类型所说的,所有类型都有一个基类型就是Object
类,当然了Object
可没有基类,不能套娃嘛不是