前言
.NET是什么
.NET是一个由微软公司研发的软件运行平台,现在基于Windows平台开发应用程序,大多采用.NET作为核心编程模型。
基于.NET平台开发软件,其中一个特点就是支持混合语言开发,微软官方维护的三种.NET编程语言为:C#、VB、F#。因为无论使用哪种语言开发,都要编译为同一种中间语言。
可以简单地把这些中间语言理解为.NET虚拟计算机所能直接执行的汇编语言。
只要编译器能把某种语言编译为此中间语言,那么他就能在.NET平台上运行。
新的.NET Core可以跑在各种平台上,不只局限于Windows。
新一代的编程模型
Windows的.NET Framework平台构成为 CLR(Common Language Runtime) + 基类库(包容大量可重用软件组件)
Visual Studio编写C#基础知识
计算机运行程序流程
使用0和1表达信息
数值信息:直接使用二进制表达
非数值信息:采用"编码"。比如五笔输入法用一到四个字符对应一到多个汉字,而图像用数字表达每一个像素点和位置等信息,以特定格式写入到文件。
"编码"就是以若干位数码(或符号)的不同组合来表示非数值类型的信息,他人为地为数码(或符号)的每一种组合指定了一种特定的含义
两种不同的内存类型
物理内存:单台计算机上安装的物理储存芯片所提供的内存
虚拟内存:由操作系统在硬盘上划出一块空间所提供的虚拟内存,可以比物理内存大很多。
使用C#所写的.NET程序,无法直接访问物理内存上的特定存储单元,它所访问的是由操作系统(如Windows)所提供的虚拟内存
HelloWorld
1 2 3 4 5 6 7 8 9 10 11 |
using System; //使用.NET基类库中的System命名空间 public class Program { static void Main(string[] args) { Console.WriteLine("Hello World!"); Console.WriteLine("Press any key..."); Console.ReadKey(); } } |
使用记事本时:保存文件为.cs
文件,编码ANSI
,然后对其进行编译:
搜索command
,可以看到VS所提供的命令提示符 如x64 Native Tools Command Prompt for VS 2019
,打开后转到.cs
文件保存的目录
1 2 3 4 5 6 7 8 |
E:\>cd /d C:\Users\LingYun67\source\repos\C_Sharp_HelloWorld\ C:\Users\LingYun67\source\repos\C_Sharp_HelloWorld>csc.exe /target:exe Program.cs Microsoft(R) Visual C# 编译器 版本 3.10.0-4.21269.26 (02984771) 版权所有(C) Microsoft Corporation。保留所有权利。 C:\Users\LingYun67\source\repos\C_Sharp_HelloWorld> |
1 2 |
csc.exe /target:exe Program.cs 调用编译器 将生成exe |
控制台程序输入输出
1 2 3 4 5 6 |
//输出一段提示 Console.Write("请输入字符串"); //字符串变量userInput接收用户输入 string userInput = Console.ReadLine(); //输出提示和变量 Console.WriteLine("User input:" + userInput); |
C#的输出也可以使用占位符的方式,感觉形式上类似于c语言的%s
1 2 3 4 |
//输出提示和变量 Console.WriteLine("User input:" + userInput); //使用{0}占位符输出变量 Console.WriteLine("User input:{0}", userInput); |
占位符的好处在于方便的拼合多个字符串
1 2 3 4 5 |
//userInput.length是string的属性 Console.WriteLine("User input:{0} length:{1}", userInput, userInput.Length); //转义字符也可以用 Console.WriteLine("User input:{0} \nlength:{1}", userInput, userInput.Length); |
例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
using System; namespace SayHello_ { class Program { static void Main(string[] args) { Console.WriteLine("Hello World!"); TestInputAndOutput(); } static void TestInputAndOutput() { Console.Write("请输入一串字符:"); string 一串字符 = Console.ReadLine(); Console.WriteLine("你输入的是{0},它的长度为{1}", 一串字符, 一串字符.Length); } } } |
才发现之前的两篇笔记没有记转义字符表
转义字符 | 意义 | ASCLL码值(十进制) |
---|---|---|
\a | 响铃(BEL) | 007 |
\b | 退格(BS) ,将当前位置移到前一列 | 008 |
\f | 换页(FF),将当前位置移到下页开头 | 012 |
\n | 换行(LF) ,将当前位置移到下一行开头 | 010 |
\r | 回车(CR) ,将当前位置移到本行开头 | 013 |
\t | 水平制表(HT) (跳到下一个TAB位置) | 009 |
\v | 垂直制表(VT) | 011 |
\' | 代表一个单引号 | 039 |
\" | 代表一个双引号字符 | 034 |
\ | 代表一个反斜线字符''' | 092 |
? | 代表一个问号 | 063 |
\0 | 空字符(NUL) | 000 |
\ddd | 1到3位八进制数所代表的任意字符 | 三位八进制 |
\xhh | 十六进制所代表的任意字符 | 十六进制 |
ReadKey和Beep
1 2 3 4 5 6 |
//等待用户输入一个字符,这个字符会显示在控制台里 Console.Write("请输入字符:"); Console.ReadKey(); //加入元素true则不会显示用户输入的字符 Console.Write("\n请输入字符:"); Console.ReadKey(true); |
比如两次readkey都输入一个A控制台内则会如下显示:
1 2 |
请输入字符:A 请输入字符:(此处输入A后程序自动关闭) |
计算机beeeee一下
1 |
Console.Beep(); |
C#基本编程规则
标识符(identtifier,即程序中拥有特殊含义的单词,比如类或者方法):区分大小写
每条语句以分号结尾,多条语句可以使用{}组合为一个语句块
编写的代码放在类(class)中,类是C#编程的基本单元
存放C#类的源代码文件其扩展名为".cs",一个".cs"文件中可以存放多个C#类
Visual Studio中的文件组织方式
一个解决方案可以含有多个项目,项目(Project)是VS编译的最小单元
设置为启动项目的项目在启动的时候会启动

面向对象程序设计概述
windows平台下,可以执行的文件通常是以.exe结尾的。另有一种扩展名为.dll的文件(称为“动态链接库”)它需要被.exe装入后才能执行


.NET运行机理
编译是怎么回事
纯文本形式的源代码—提交—C#编译器编译源代码—生成—二进制形式的可执行文件(HelloWorld.exe)
"编译(compile)"有点类似于"翻译",粗略的说,它负责把人编写的源代码"翻译"为计算机可以识别并执行的二进制代码。
而可执行程序也是分类的:
可执行程序文件也分多种类型,在Windows平台下可分为:

本课程学习的基本上都是托管程序
“托管的”应用程序是什么意思:
.NET应用程序是“托管(Managed)”的,意思是他必须在一个独立的运行环境(即CLR)中运行,并受到这个运行环境的管理与控制。没有环境程序无法运行

CLR:Common Language Runtime,通用语言运行时。是.NET Framework应用程序的运行环境,可以看成是一台专用于运行.NET应用程序的虚拟机(Virtual Machine)。
到了.NET Core时代,.NET虚拟机名字前也加了一个Core,称为“CoreCLR”

.exe和.dll在.NET中统称为 “程序集(Assembly)”,在程序集这种文件中保存的是二进制的指令,在.NET中被称为中间语言指令。
怎样构造求解问题的算法
计算机中的算法,主要指为了解决某个问题而设计的一种解决方案,包容一系列计算机能够执行的有着特定顺序的命令,计算机执行完这些命令,将得到某种结果,意味着某个问题己经有了一个结论。
算法的针对性很强,专用于解决特定的问题。
算法的设计,通常与数学有着很密切的联系,并且是独立于特定的编程语言和软件平台的。这就是说:
可以使用多种编程语言,以多种方式,在不同的平台上实现同一个算法。
从“结构化”到“面向对象”
结构化方法是一种历史悠久的软件开发方法,是面向对象方法的前身。
程序设计可以看成是一种"抽象"的艺术
使用“抽象”的思维方式,构造软件系统的顶层模型

数据结构——对数据进行抽象
先确定一种数据结构 ,然后基于此数据结构设计算法
1 2 3 4 5 |
struct MyDate{ public int Year; //年 public int Month; //月 public int Day; //日 } |
MyDate这个数据结构,封装了 “年”“月”“日”三个基本信息
在程序设计中,依据要解决的特定问题,分析它所涉及的相关数据和其中所蕴含的各种信息,按照特定编程语言所支持的语法特性,将它们转换为特定的数据结构,往往是整个开发中至关重要的一步。
基于数据结构确定算法
将人的计算方法转为计算机算法:
(1) 计算出两个日期之间的整年天数
(2) 计算出两个日期之间的整月天数(去掉中间的整年)
每个算法步骤用(定义)一个函数来实现:
CalculateDaysBetweenTwoYear()
CalculateDaysBetweenTwoMonth()
算法就是一系列的命令,计算机通过执行这些命令,完成特定的数据处理工作。
进一步细化与调整设计方案
需要判断是否是闰年,所以应该添加一个IsLeapYear()函数。
再添加一个顶层主控函数CalculateDaysBetweenTwoDate(),将前面设计得到的函数“装配”起来,从而实现整个算法。
最终的技术设计方案

如何确定开发顺序?
确定开发顺序的基本方法
具体开发时,“从下层到上层”地逐层开发,就象盖楼一样....
①先开发那些被别人调用,自己不调用别人的函数 IsLeapYear()
②↓开发中间层函数,它需要调用底层已经实现好的函数
CalculateDaysBetweenTwoYear()
CalculateDaysBetweenTwoMonth()
③↓↓开发顶层函数,它需要调用中间层已经实现好的函数,通常情况下,避免跨层调用。
CalculateDaysBetweenTwoDate()
在结构化的编程中,有一个基本的编程原则就是尽量的避免出现跨层调用的情况,比如主控函数不要调用判断闰年的函数,而反过来则更不允许,底层的函数绝对不能调用高层的函数。
转为面向对象实现
将函数们移动到一个新的类里——
重大变化:
- 新的类DateCalculator的指责很明确,它负责“计算日期”,除此之外,它什么也不干。
- 外界只能“看到”并调用它所定义的唯一一个“公有(public)”方法CalculateDaysOfTwoDate(),根本就不知道它内部到底是怎么计算出来的。
也就是将除了主函数之外的函数设为私有,仅公开主函数。
面向对象带来的好处:
- 从使用者角度,DateCalculator类因为 “简单”,所以“易用”。
- 具体计算日期的算法被封装到了DateCalculator类的内部,在必要时可以修改算法,外部调用者不会受到影响,其调用代码不需要改变。
一个"偷懒"的方法:
.NET基类库中内置了日期处理的相关功能,可以直接使用它来完成计算两个日期之间相隔天数的问题。
1 2 3 4 |
DateTime d1 = new DateTime(1999, 5, 10); DateTime d2 = new DateTime(2006, 3, 8); //计算结果 double days = (d2 - d1).TotalDays; |
从这个示例中,我们可以看到,如果有一个功能强大的组件库,基于这些组件开发应用程序,可以大大地提升软件开发效率,因为我们可以重用别人的工作,不再需要一切从头开始。
C#结构化编程基础
理解变量
程序运行所需要处理的数据,通常都需要放到“变量(variable)”中。
变量可以看作是一种数据容器。
不同类型的“容器”,适合放置不同类型的数据,这种“类型”,我们称它为“变量的数据类型”。
1 2 |
数据类型名称 变量名 = 变量初始值; int value = 100; |
上述语句定义了一变量,它的类型是“int(整型)”,可以放置一个整数,它最初“装入”的数是“100”。
C#语句以“分号”结束,因此分号不能省,省略了之后,代码将无法编译。
变量“生活”在“内存(memory )”中
应用程序中能使用的内存,是由操作系统提供的“虚拟内存”,其物理载体通常是我们在前一单元视频中介绍过的内存条和硬盘。

内存由多个内存单元构成,每个内存单元都有一个编号,称为“内存地址”。
给定一个“内存地址”,就能找到特定的内存单元。
计算机可以从内存单元中写入或读出数据。
使用比较底层的编程语言,比如“汇编语言(Assembly Language)”,我们可以直接指定地址去存取特定的内存单元,但这么干“太低效了”,为什么?
- 不同的计算机硬件,可以访问的物理内存数量是变化的 比如搭载的内存条不同
- 不同的操作系统,甚至是同一操作系统的不同版本,对内存单元的存取可能都有着不同的要求
例如32位的Windows能够访问的最大内存是4G.
你必须针对特定的计算机去写“特定”的程序,硬件略有变化,如果不作修改,你的程序可能就崩溃了……
因此这种编程方法是行不通而且十分低效的。
计算机科学家给出的解决方案是……
- 应用程序需要存取数据时,不指定具体的内存地址值,而只是给出一个“名字”,这个“名字”引用某块内存区域。
- 这个“名字”具体到底引用的是哪块内存区域,由操作系统(Windows)负责安排,应用程序就不要费这个心了!
- 应用程序根本就不管它要用的数据具体保存在哪个地方,它只知道,我可以“按名”存取数据就够了!
这个内存区域的“名字”,就是我们所讲的“变量”!
通过变量名“间接”地存取内存
int i = 0;

变量名—对应于—某块内存区域的“别名”
- 变量的名字(上面代码中的“i ”),可以看成是特定内存区域的 “别名”,通过它计算机就能找到特定的内存单元存取数据。
- 有了变量名,就不再需要显式指定一长串的地址数值来访问内存单元了。
“给变量赋值”是什么意思?
变量定义好之后,可以随时地通过赋值语句传给它不同的值……
1 2 |
变量名 = 变量新值; i = 100; |
C#代码中的单个等号,称为“赋值符”,与数学中的等号是两回事,它表示要把等号右边的值,传给左边的变量。
给变量赋值,其实就是找到变量所代表的内存区域,把指定的数值写入其中。
注意:写入到内存单元中的数据,都被转换为二进制数值,计算机并不能直接处理我们常用的十进制数。
变量间的相互赋值,本质上是内存单元间的值复制。
在上述代码中,j = i,其实就是把“i”中所保存的数值复制一份,然后保存到“j”所代表的内存单元中。
注意:变量“i ”和“ j”之间是完全独立的,修改“i”的值,不会导致“j”的值同步变化。
数据类型
四种最常用的数据类型
1 2 3 4 |
int intValue = 100; long longValue = 100l; //这里是100L double doubleValue = 100.5d; float floatValue = 100.5f; |
如果希望知道一个变量的数据类型:
调用GetType()方法
1 2 3 4 |
Console.WriteLine(intValue.GetType()); Console.WriteLine(longValue.GetType()); Console.WriteLine(doubleValue.GetType()); Console.WriteLine(floatValue.GetType()); |
输出如下:
1 2 3 4 |
System.Int32 System.Int64 System.Double System.Single |
另外,也可以使用另一种方法tyoepf();用来验证某一个变量是否是特定的类型
1 |
Console.WriteLine(intValue.GetType()==typeof(int)); |
我们因此得知,我们可以用GetType获取变量的类型,用typeof验证变量的类型。
因为intValue是int类型,所以上述WriteLine会输出一个bool:true
由上可以看出C#内置的数据类型是对应着CLR支持的基础数据模型的
int | System.Int32 |
long | System.Int64 |
float | System.Double |
double | System.Single |
我们使用的这些C#的内置数据类型在编译的时候会被转变为CLR支持的基础的数据类型
C# 7中对整数的改进
C# 7之后的版本,对于比较大的整数,可以使用下划线对其进行分割,以方便人阅读:
1 2 3 |
long distanceToSunFromEarth = 149_600_000; //输出:149600000 Console.WriteLine(distanceToSunFromEarth); |
对于二进制数值(以“0b”打头),也可以直接地添加下划线进行分割:
1 2 3 4 |
//定义一个二进制的数值 int b = 0b1010_1011_1100_1101_1110_1111; //输出(十进制):11259375 Console.WriteLine(b); |
String和var
在定义字符串的时候,首字母大写与小写都可以成功定义:
1 2 |
String str1 = "hello" string str2 = "hello" |
如果对他们进行判断 会发现他们是完全相同的:
1 2 3 |
Console.WriteLine(str1.GetType()); Console.WriteLine(str2.GetType()); Console.WriteLine(typeof(String)==typeof(string));//true |
这是因为在编译的时候,编译器会将它们两者都编译为System.String,两者都对应着System.String
所以在定义字符串的时候完全可以混用两种写法。
但还是建议使用小写写法,小写是C#基本数据类型的书写习惯。
隐式类型变量——var
var关键字定义的变量
1 2 3 4 5 6 7 |
var value1 = 100; Console.WriteLine(value1.GetType()); //System.Int32 var value2 = "Hello"; Console.WriteLine(value2.GetType()); //System.String |
C#编译器在编译的时候会依据右值来推断左值的类型
我们把这种方式定义的变量称为隐式类型变量
主要目的是精简代码
比如定义下面的变量的简化
1 2 |
Dicrionary<string, List<int>> dic = new Dicrionary<string, List<int>>; var dic = new Dicrionary<string, List<int>>; |
dynamic——变色龙
C# 4.0引入了一个dynamic类型,它是一条变色龙,可以动态地给它添加数据成员,如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
///<summary> ///可以动态添加成员的数据类型 ///</summary> static void DynamicTypeDemo() { //dynamic是一种“神奇”的数据类型,它所定义的变量,可以动态的添加成员 dynamic obj = new Expandobject(); //给它添加一个int类型的属性 obj.intValue = 100; //给它添加一个方法 Action<int> act = (Value) => Console.WriteLine(Value); obj.printValue = act; //调用新添加的方法输出新添加的属性值 obj.printValue(obj.intValue);//100 } |
dynamic实际上定义了一种“动态”对象,它支持在运行过程中动态地添加新成员,一切是在“运行”时发生的。
而var只是你写代码时可以不指定类型,其实它的类型是明确的,这个类型的成员也是固定的,由它所接收的值所决定。这一切,都是在“编译”时确定。
当前可以对dynamic类型“不求甚解”,知道有它就好。
变量与内存
1 2 3 4 |
sizeof(int) sizeof(long) sizeof(float) sizeof(double) |
返回的是(单位:字节)
1 2 3 4 |
4 8 4 8 |
在C#中较低精度/较小范围类型到较高精度/较大范围类型的转换赋值是可以的,比如
1 2 3 |
longVaule = intVaule doubleVaule = intVaule doubleVaule = floatVaule |
但是反过来从较高精度/较大范围类型到较低精度/较小范围类型的转换时会报错提示无法隐式转换
所以如果需要强制类型转换
1 2 3 |
intVaule = (int)longVaule intVaule = (int)doubleVaule floatVaule = (float)doubleVaule |
一个特殊的点:
1 2 |
floatVaule = intVaule //事实上float比int表达的范围要大 intVaule = (int)floatVaule //不加强制类型转换会报错 |
数据类型转换
字符串"100"向数值转换:
1 2 3 4 |
string = strValue = "100"; int intValue = int.parse(strValue); double doubleValue = double.parse(strValue); |
C#基类库中的一个类也可以实现这个功能:
1 2 |
intValue = Convert.ToInt32(strValue); doubleValue = Convert.ToDouble(strValue); |
反过来希望一个数据转换为字符串
ToString方法很通用、几乎所有的原始数据类型都有这个方法
1 2 3 |
strValue = intValue.ToString(); strValue = doubleValue.ToString(); strValue = 200.ToString(); //因为200是int32所以完全没问题 |
另外一种转字符串的方法是
1 |
strValue = intValue + "";//加上一个空字符 |
虽然后者是完全可用的但是还是建议使用ToString
C#变量两种类型
从内存模型角度,C#变量可分为以下这两种类型

线程堆栈/堆(一种特殊的内存区域)
这两种都是特定的内存区域,有不同的特点。目前只需要知道大致分为两种类型
运算符与表达式
与c一样,除号两边都是整数的时候是按照整除的方式来处理的,是商。
如果给其中一个加上后缀d或者.0都是可以的
加减乘除都一样 递增递减一样
选择结构与逻辑表达式
三种典型的程序代码执行流程

在结构化程序设计中,经常使用一种称为“ 程序流程图 ”的示意图 来表示程序的执行流程 。
虽然在面向对象时代,流程图用得没有以前多了,但由于其拥有直观性强的优点,仍然被广泛使用 ,比如在行业应用软件系统开发时 ,使用流程图表达业务流程的处理步骤,几乎所有人都能看得懂。
if/else选择结构
- 逻辑表达式通常用于表示某种条件是否得到满足。
- 当程序运行时,计算机解析逻辑表达式,会得到一个值,在C#中,这个值只有“true(真)”或“false(假)”两种情况。表达式值为 “true”,表示条件满足,为“false”,表示条件不满足。
- 在 if 语句中,基于逻辑表达式执行的结果执行特定的分支。
- 嵌套在条件语句内部的条件语句,整个语句被当作一个语句块处理,可以看成是一个整体
- 除非使用“{”和“}”为语句划分了块,否则,else总是与它最近的if配套。
- 弄错“if”和“else”的配套关系,有可能会带来严重的问题,推荐加上足够的“{”和“}”给与明确区分。
区分关键字与标识符

用于构建逻辑表达式的运算符
运算符 | 说明 |
> | 大于,实例:“5>10”,值为“false” |
< | 小于 |
== | 等于,实例:“ 9== 100”,值为“false” |
>= | 大于等于 |
<= | 小于等于 |
!= | 不等于,实例:“100 != 101”,值为“true” |
“=>”不是“等于大于”,它在C#程序中有特定的含义,它表示一个Lambda表达式,也可以用于标识“表达式成员”,我们将在后面的课程中对这两种情形进行介绍。
逻辑表达式的组合
使用“&&”组合两个表达式,只有两个条件都满足(都为“true”)时,整个条件才算满足,可以将“&&”与汉语中的“并且”对应上。
&&(And /与)、||(Or/或)、!(Not/非)
与或非
假设A和B都是一个逻辑表达式,对其使用与、或、非组合运算的规律如下:
- A && B:只有A和B都为true,结果才为true
- A || B:只要A和B中有一个为true,结果就为true
- !A:结果总是与A“相反”,比如A为true,则!A为false
多值选择结构 switch_case
为了保证一个值执行一个分支,每个分支后都应该加一个break;语句(最后一个分支可略)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
static void DoYouPass() { Console.Write("请输入你的考试成绩:"); string UserInput = Console.ReadLine(); int score = int.Parse(UserInput); switch (score/10) { case 10: case 9: Console.WriteLine("学霸"); break; case 8: Console.WriteLine("好"); break; case 7: Console.WriteLine("还行"); break; case 6: Console.WriteLine("小心了"); break; default: Console.WriteLine("没及格"); break; } } |
循环
do/while循环的特点:
先做事,再判断循环条件是否满足,满足就继续执行循环体。
所谓“循环结构”,其实就是在特定的场景下,某些语句将被反复地执行多次。
while/do循环的特点:
先判断循环条件,满足了再做事。
当需要执行次数不定的循环时,使用do/while或while/do循环是最自然的选择。
当需要执行次数固定的循环时,使用for循环就变成了最自然的选择。
1 2 3 4 |
for (int i = 0; i<= 100; i++) { Console.WriteLine("Hey! I am Trapped"); } |
执行顺序为 ①[int i = 0] ②[i<= 100] ③[{}内代码] ④[i++]
break:提前结束循环,后面还没有执行的循环也不再执行。
continue:提前结束当前循环(本轮循环中还没有执行的代码不再执行),后面的循环继续执行。
“永远不结束”的“死循环”
在某些场景,无法预知到底要执行多少轮循环,也不知道要运行多长时间
- Windows需要时刻监控鼠标与键盘,以便及时地响应用户操作。
- 某股票软件需要及时地提取股票信息,将涨跌信息及时地通知用户
- 防病毒软件需要在后台监控各种活动,发现危险时向用户报警
在这些场景中,使用“死循环” 是合理的
使用循环结构访问数据集合
在面向对象的软件中,我们会经常遇到“数据集合”这一概念。 “数据集合”,顾名思义,就是“数据的集合”,在实际开发中,有两种类型的数据集合我们经常会遇到……
- 保存int、float等值类型数据的集合,如“List<int>”
- 保存string和自定义类等引用类型数据的集合,如List<MyClass>,又称为“对象集合”
要遍历这两种类型的数据集合,我们可以使用foreach循环……
数据集合的“遍历”
所谓“遍历”,换一个说法,就是“逐个地访问”。
遍历数据集合最自然的方式,就是使用foreach循环。
使用foreach 遍历数据集合时,不要向集合中增删数据。
各种类型的循环语句都是等价的,可以把一种类型的循环语句用另一种类型的循环语句替换而不会影响到程序的功能。
从表面上看,foreach与前面介绍过的for, do/while等循环并没有什么区别,但实际上,两者是有很大区别的。
C#编译器在编译foreach语句时,会将其转换为使用IEnumerable与IEnumerator的相应代码,并且C# 8还引入了序列和异步循环迭代等新特性,这些内容,当前暂不介绍,而安排在后继的其他课程中,等你掌握了相应的知识之后再学习。
控制台应用程序编程小技巧
当我们编写控制台应用程序时,经常需要知道用户是否按了某些特殊的键(比如F1键),于是问题出来了……
(1)我怎么知道用户按的是哪个键?
(2)怎样编程来检测用户的按键?

当一个控制台程序正在运行时,默认情况下,用户可以使用“Ctrl+C”或“Ctrl+Break”强制中止它的运行。
要禁用“Ctrl+C”的功能,可以设置以下属性
Console.TreatControlCAsInput = true;
从语句到方法
在实际开发中,我们经常会发现某些功能在很多程序中都需要。当然,你可以直接地在不同程序中“Copy & Paste”代码,但这么干,麻烦很多:
当你发现了这些代码中有错误时,你必须找出它们被复制过的所有地方,一一更改,这实在太烦人了……
能不能把这些需要重复使用的代码“归作一堆”,给它起个名字,然后在需要调用它们时,只需指定一个名字即可?
把多个语句组合在一起,共同完成一个功能,向外界返回一个结果,再给它起个名字,这样的一个“代码集合”,在面向对象编程领域,称之为“方法(method)”。
在结构化编程领域,面向对象中的“方法(mothod)”被称为“函数(function)”,这两个术语经常混用,可以看成是一回事(虽然有细微的差别)。
在C# 9.0之前,所有方法都必须放到一个“类(class)”中,不存在完全独立的方法,9.0之后,允许你定义一个不放在类中的方法并立即调用它(还包括变量声明、类的定义、普通语句等),这个特性,称为“Top level Statements(顶级语句)”。

调用Add()方法时传入的“100”和“200”,称为方法的“实参(实际参数)”。
注意:C#编程语言所定义的方法名字,首字母大写,其它许多编程语言,比如Java,习惯是小写字母开头,使用不同的编程语言,需要遵循相应的“惯例”。
1 2 3 4 |
static int Add ( int x, int y) { return x + y; } |
注意上面代码的static关键字,它表明这个方法是一个“静态方法(static method)”。
C#中,位于同一个类的静态方法可以通过方法名直接调用,其它类要调用时,需要加上此方法所在的类名,比如:Program.Add(100,200);
如果定义方法时没有加上static关键字,它表明这个方法是一个“实例方法(instance method)”,这种方法依附于特定的对象,外界需要通过对象变量来调用。这部分内容,留待后面课程介绍。
方法的重载
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
static int Add(int x, int y) { return x + y; } static double Add(double x, double y) { return x + y; } static double Add(string x, string y) { double dx = double.Parse(x); double dy = double.Parse(y); return dx + dy; } |
在同一个类中,我们可以定义名字一样的方法,只要它们的参数列表不一样就行了,这种语法特性,叫作“方法的重载(method overload)”
只需要达成以下条件之一,就可以构成方法重载的关系
- 参数个数不一样
- 参数个数相同,但相同位置的参数,其类型不一样
返回值类型不作为方法重载判断的依据。
对于重载方法,到底调用的是哪个,是由其参数决定的。

一个实例:图片浏览器
窗体内有如下元素:
- 按钮button:btnchoosepicture
- 图片pictureBox:pictureBox1
- 文件选择openFileDialog:openFileDialog1

代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
private void button1_Click(object sender, EventArgs e) //关于这个button1按钮的代码 { if(openFileDialog1.ShowDialog() == DialogResult.OK) //openFileDialog1元素的打开情况有ok,canceled等 { pictureBox1.ImageLocation = openFileDialog1.FileName; //图片盒子的 图片地址 是 openFileDialog1 的 选择文件 } else { MessageBox.Show("User canceled."); //否则在消息输出一个字符串 } } |
对openFileDialog1按下"转到定义" 可以看到
1 |
public DialogResult ShowDialog(); |
而DialogResult是一个枚举
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
// // 摘要: // 指定标识符来指示对话框中的返回值。 [ComVisible(true)] public enum DialogResult { // // 摘要: // Nothing 从对话框中返回。 这意味着模式对话框仍继续运行。 None = 0, // // 摘要: // 对话框中的返回值是 OK (通常从一个标有确定按钮发送)。 OK = 1, // // 摘要: // 对话框中的返回值是 Cancel (通常从一个标记为取消按钮发送)。 Cancel = 2, // // 摘要: // 对话框中的返回值是 Abort (通常从标记为中止的按钮发送)。 Abort = 3, // // 摘要: // 对话框中的返回值是 Retry (通常从标记为重试的按钮发送)。 Retry = 4, // // 摘要: // 对话框中的返回值是 Ignore (通常从标记为 Ignore 的按钮发送)。 Ignore = 5, // // 摘要: // 对话框中的返回值是 Yes (通常从一个标记为是按钮发送)。 Yes = 6, // // 摘要: // 对话框中的返回值是 No (通常从一个标记为无按钮发送)。 No = 7 } |
将这个方法整理成函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
private void button1_Click(object sender, EventArgs e) { openPicture(); } private void openPicture() { if (openFileDialog1.ShowDialog() == DialogResult.OK) { pictureBox1.ImageLocation = openFileDialog1.FileName; } else { MessageBox.Show("User canceled."); } } |
递归
递归概述
1 2 3 4 |
static void DonotRunMe() { DonotRunMe(); } |
递归就是“自己调用自己”
但是上述代码执行后会导致报错
小知识:堆栈溢出(Stack Overflow)
程序代码其实是由“线程(thread)”负责执行的。
操作系统在创建线程(thread)时,会给每个线程配套一块内存区域,线程可以用这块区域存储一些数据。
这块内存区域被称为“线程堆栈(thread stack)”
线程堆栈有容量限制,当一个线程要保存的数据超过了这个容量时,就发生了“堆栈溢出”
“递归(recursive)”的算法、
An algorithm is called recursive if it solves a problem by reducing it to an instance of the same problem with smaller input.
一个递归的算法,会将一个难以处理的“大”问题的 “规模”分多次地持续压缩,一直持续到压缩后的问题规模小到可以处理为止。其过程往往体现为代码要处理的数据量或计算量在递归前后不断“递减”。

代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
static void Main(string[] args) { int x = 5; Console.WriteLine("!{0}={1}", x, digui(x)); Console.ReadKey(); } static int digui(int x) { if(x == 1) { return 1; } int ret = digui(x - 1) * x; return ret; } |
小结:递归编程的“套路”
- 每个递归函数的开头一定是判断递归结束条件是否满足的语句(一般是if语句)
- 函数体一定至少有一句是“自己调用自己”的。
- 每个递归函数一定有一个控制递归可以终结的变量(通常是作为函数的参数而存在)。每次自己调用自己时,此变量会变化(通常是变小),并传送给被调用的函数。
小结:递归的特点
- 先从大到小,再从小到大。
- 每个步骤要干的事情都是类似的,只不过其规模 “小一号”。
- 必须要注意保证递归调用的过程可以终结,否则,将导致“堆栈溢出(stack overflow)”。
下面我们再来看看另一种编程技巧——递推。
1 2 3 4 5 6 7 8 9 |
private long Factorial(int n) { if (n == 1) return 1; long ret; ret = Factorial(n - 1) * n; return ret; } |
1 2 3 4 5 6 7 8 9 10 |
private long Factorial2(int n) { long result = 1; for(int i = 1; i <= n; i++) { result *= i; } return result; } |
“递归”是“由后至前再回来”,要求第n项,先求第n-1项,……,倒推到前面的可计算出的某项,然后再返回。
“递推”是“从前到后”,先求第1项,然后,在此基础上求第2项,第3项,直至第n项。
结论:递归与递推(iterative )是等价的。
关于递归,再多说几句……
• 在软件科学中,递归这种思想有着重要的应用,比如,许多计算机算法都是用递归实现的。
• 在具体的软件开发实践中,递归也用得非常多。
• 对于初学者而言,要一下子理解递归比较困难,只能在开发实践中慢慢体会,最终方能灵活地应用递归解决实际问题。
除了递归之外,还有一个非常重要的编程技巧是需要掌握的,那就是“回调”。回调在“多线程编程”场景中,还有在诸如JavaScript这样的编程语言写的程序中,被大量地使用。
大数与浮点数的编程技巧
比如在上一个递归的例子中计算!50就会出现一个非常大的负数
问题的根源:计算机能表示的数是有范围的!
C#中int类型的数值占32位,是有符号的,最高一位是符号位,表示“正负”
正数:000……0 ~ 011……1 即 32个全“0”到 “0”+31个全“1”
负数:100……0 ~ 111……1 即“1”+31个全“0” 到 32个全“1”

类似地,long类型的数值占64位:

示例程序计算阶乘出现错误的原因
由于计算机使用固定的位数来保存数值,因此,能处理的数值大小是有限的,当要处理的数值超过了这一范围时,计算机将会自动截断数值的二进制表示为它所能处理的最多位数,这将导致错误的处理结果。
如果我们的确需要处理很大的整数,其数值大到甚至超过了long类型的最大值263-1,是不是就没有办法了呢?
处理巨大的整数
.NET 基类库中提供了一个BigInteger类,支持大整数的加减乘除运算。
1 2 3 4 |
BigInteger bi = long.MaxValue;//定义变量bi = long类型的最大值 bi *= 2; //bi乘2 Console.WriteLine(bi); //仍然正常输出 Console.WriteLine(bi / 4); |
注意:BigInteger类型定义于System.Numerics中,需要为.NET Framework项目添加对这一程序集的引用,而.NET Core项目则可以直接使用。

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
static void Main(string[] args) { int x = 50; Console.WriteLine("!{0}={1}", x, digui(x)); Console.ReadKey(); } static BigInteger digui(int x) { if(x == 1) { return 1; } BigInteger ret = digui(x - 1) * x; return ret; } |
浮点数的处理技巧
1 2 3 |
double i = 0.0001; double j = 0.00010000000000000001; Console.WriteLine(i==j); //输出:true |
计算机不能精确地表达浮点数(特殊形式的除外),因此,当需要比较两个浮点数是否相等时,应该比较其差的绝对值是否在某个允许范围之内即可,无法做到像数学那样的精确比较。
1 2 3 4 5 |
//计算两个数相减的绝对值 与 1*10的负十次方比较 if ( Math.Abs(i - j) < 1e-10 ) Console.WriteLine("true"); else Console.WriteLine("false"); |
形形色色的方法参数
输出空格或分隔线
在开发.NET控制台程序时,经常需要使用Console.WriteLine()方法输出信息。依据实际情况,有些时候可能希望在连续多个输出中插入一些空行,有些则希望能使用分隔线……

分隔线与空行,都需要使用单独的Console.WriteLine()语句输出,很不方便……
让我们编写一个函数,在输出一个字符串时,可以选择自动添加前后分隔线和空行的功能。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
/// <summary> /// 使用控制台输出一个字符串 /// 可以选择是否在字符串前后输出由28个短划线构成的分隔线 /// 或者自动追加一个空行 /// </summary> /// <param name="Message">要输出的字符串</param> /// <param name="PrintTopDivider">是否输出顶部分隔线</param> /// <param name="PrintBottomDivider">是否输出底部分隔线</param> /// <param name="AppendNewLine">是否追加一个空行</param> static void PrintMessage(string Message, bool PrintTopDivider = false, bool PrintBottomDivider = false, bool AppendNewLine = false) { //顶部分隔线 if (PrintTopDivider) Console.WriteLine(new string('-', 28)); //输出信息本身 Console.WriteLine(Message); //追加一个空行 if (AppendNewLine) Console.WriteLine(); //底部分隔线 if (PrintBottomDivider) Console.WriteLine(new string('-', 28)); } |
注意一下PrintMessage方法定义了三个bool类型的参数,并且都给它们指定了默认值。
指定了默认值的方法参数,在调用时是可省的,所以又被称为“可选参数”。
调用PrintMessage方法时,可以传入一个或多个参数,未传入的参数,取方法定义时指定的默认值:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
private static void PrintPoem() { //输出顶部分隔线,使用了可选参数特性。 PrintMessage("\t 鹿柴", true); //使用命名参数特性,追加一个空行, PrintMessage("\t (唐)王维", AppendNewLine: true); //所有可选参数均为默认值,只输出字符串 PrintMessage("\t空山不见人,"); PrintMessage("\t但闻人语响。"); PrintMessage("\t返景入深林,"); //使用命名参数特性,输出底部分隔线,再追加一个空行, PrintMessage("\t复照青苔上。", PrintBottomDivider: true, AppendNewLine: true); } |
当一个方法中有很多个参数,在调用时可以通过名字直接给特定的参数传值,而不需要按照定义时的位置顺序,这个称为“命名参数赋值特性”
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
//使用命名参数,可以不按照定义顺序传值 var distance = CalculateDistance(x1: 1, x2: 2, y1: 5, y2: 3); //输出:(1,5)到(2,3)的距离为:2.23606797749979 Console.WriteLine($"(1,5)到(2,3)的距离为:{distance}"); //计算平面直角坐标系中(x1,y1)与(x2,y2)两点的距离 static double CalculateDistance(double x1, double y1, double x2, double y2) { var tempXResult = x2 - x1; var tempYResult = y2 - y1; return Math.Sqrt(tempXResult * tempXResult + tempYResult * tempYResult); } |
可变参数
当给一个方法参数前面添加一个params关键字时,就表示它是一个可变参数:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
//此方法可以接收个数可变的参数,将其使用“,”分隔输出, //最后再输出一个空行 static void PrintIntArray(params int[] numbers) { for(int i = 0; i < numbers.Length; i++) { Console.Write(numbers[i]); if(i!=numbers.Length-1) Console.Write(","); } Console.WriteLine(); } |
调用示例:
1 2 3 4 5 |
//输出:1,2,3 PrintIntArray(1, 2, 3); //输出:5,6 PrintIntArray(5, 6); |
可变参数的类型,必须是一维数组。
当方法有多种参数的时候,可变参数必须在最后面。
“输入(in)”型方法参数
正常情况下,在方法内部,是可以修改传入参数的值的:
1 2 3 4 5 6 |
static void MethodUseIn(int number) { number++; Console.WriteLine(number); } |
1 2 |
//输出:101 MethodUseIn(100); |
当一个参数是in类型时,在程序运行时,方法内部访问到的变量,与外部传给它的变量是同一个,其中不存在值复制的情况。
1 2 3 4 5 6 7 8 |
//in是c# 7.2引入的特性,强调其参数值不可改(即只读) //如果编译器发现方法内部有代码修改参数值,将报告错误 static void MethodUseIn(in int number) { //引发编译错误 number++; Console.WriteLine(number); } |
推荐对于那些包容较多成员的struct变量启用in特性

如果方法参数前没有in,则方法接收到的,是外部number变量值的一个“副本”,两者是独立的变量。
in也有助于提高性能,因为不需要再进行一次复制的操作。对于有相当多变量的时候很有用处。
“输出型(out)”方法参数
.NET基类库中,为int类型定义了一个TryParse方法,其声明如下:
1 2 |
//尝试将一个字符串转换为整数,保存在result中,并返回true public static bool TryParse(string? s, out Int32 result); |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
//举例 string numberString = "abc"; int number; //当numberstring的值可以转换为整数时,TryParse()方法返回True //转换的结果(即那个整数),由number变量所接收 if (int.TryParse(numberString, out number)) { Console.WriteLine($"转换得到的整数为:{number}"); } else { Console.WriteLine($"{numberString}无法被转换为整数"); } |
与输入型参数类似,使用输出型参数时,方法内部外部代码中所访问的变量,其实是同一个。
out的意思其实就是“这个实参传进去了,被改变后我仍然需要让这个数再出来”
我们可以很方便地为自己的方法添加out关键字,如下所示:书签

使用例如下:

使用输出型参数,可以直接将特定的数据处理任务的结果直接放到“外界”指定的“存储区域”,无需进行数据的复制工作。
另外,out类型的方法参数,还在Deconstruct这一特性(后面介绍)中起到了关键的作用。
输出型参数的简写
早期版本的C#代码,在使用输出型参数时,必须在方法外部单独定义好变量,再把它们传给方法,如下所示:
1 2 3 |
string info; int value; InitializeOutVariable(out info, out value); |
C# 7之后,可以将变量定义与方法调用“合二为一”
1 |
InitializeOutVariable(out string info, out intvalue); |
“被丢弃(discard)”的参数
当调用包容有输出型参数的方法时,如果对于特定的参数“不感兴趣(用不到)”,可以直接传给它一个下划线:

在方法调用时传入的“下划线”称为“discard”,它不是一个变量,因此,在后面不能使用它。它仅仅只是一个用于通知编译器的“标记”而己,告诉编译器:“这个参数后面没有用到”。
本地方法
本地方法,是指那些定义在一个方法内部的方法,它们仅在方法内部使用,不能被外部方法所调用。

本地方法可以访问并修改外部方法的本地变量
doSomething方法执行完毕之后,它对value变量的修改仍然是生效的。
闭包(closure):
一个本地方法使用了外部定义的变量,它与它所访问的变量构成一个闭包。当其执行结束之后,方法对外部变量的修改结果可以保存。
静态本地方法

使用静态本地方法,可以避免创建内部匿名类型实例的开销,付出的代价就是它不能访问外部方法中所定义的局部变量,这让其的使用场景受限。
本地方法的应用示例-1

通过将特定的功能代码封装为本地方法,可以让代码更为易读,因为本地方法实际上干的事情,就是将代码 “分块打包”。
将所有计数相关的代码都打包为一个整体。
注意,这是一个“递归”的本地方法。
本地方法的应用示例-2

方法体中出现了重复的代码,可以将其抽取为本地方法,以便消除这些重复的代码。

本地方法的应用示例-3

因为使用了本地方法将相关代码“分块”打包,并且起了易懂的名字,现在你看这个包容有数十行代码的方法,它要干的事情是不是特别地清楚,只需要扫一眼就知道了?
小结:
当你发现一个方法中包容太多的代码,或者是这些代码包容着非常类似的大同小异的逻辑,就可以使用本地方法将其重构。从而让代码易读且逻辑清晰。
本地方法不能被外界调用,很好地贯彻了信息隐藏的原则。
Windows Forms软件开发技术基础
使用Visual Studio编写GUI程序
什么叫“GUI”程序?
GUI = Graphic User Interfce(图形用户界面接口)
GUI程序就是拥有“可以看得见”的窗体的应用程序,你可以使用鼠标和键盘去操作它,我们日常所用的Word、QQ之类,都属于这个类别。
这种程序,通常对应着一个“图标(icon)”,人们习惯于将常用的应用程序的图标摆放到Windows的桌面上,因此,人们又将它们称为“桌面应用”。
两种类型的桌面应用:

VS2019编写 GUI程序 的技巧
不同的项目类型有不同的应用场景,要用C#开发桌面应用,选择“Windows窗体应用程序”这个项目模板。
注意:Windows窗体应用程序(简称WinForms)有两种运行环境,.NET Framework与.NET Core,新写的程序建议使用.NET Core的。
关于代码的折叠:
1 2 3 4 5 6 7 |
#region btnSayHelloWorld点击 private void button1_Click(object sender, EventArgs e) { MessageBox.Show("Hello World!"); } #endregion |
窗体文件的构成:

关于:有两个窗体时程序运行如何默认打开第二个
在Program.cs内修改:

关于:自定义程序的图标

- “文件”放在“项目”中,项目归“解决方案”管。
- 编译之后,项目生成一个EXE文件,在放在项目的 /bin目录下。
扩充知识:RAD(快速应用开发)模式
使用Visual Studio编写桌面应用程序,具有 “所见即所得(WYSIWYG:What You See Is What You Get)”的特点,这种开发方式,被称为“RAD(Rapid Application Development)”。
采用RAD方式开发,软件用户能很快地看到可以一个可以跑起来的程序从而给出他的意见,这样一来,软件开发者就能尽早了解到软件是否符合用户的需求,及时调整,开发出“真正满足用户需要”的软件。
RAD开发方式,最适合于规模小,功能简单的带有“演示”性质的程序。

常见WinForm控件的使用方法
按钮控件
“按钮(Button)”是一个“控件(Control)”,它拥有这样的特性:
- 当用户用鼠标点击时,触发一个Click事件,如果程序员事先为这个Click事件写好了代码,那么这些代码将被计算机执行。
- 如果用户始终没有用鼠标点击按钮,Click事件将不会触发,对应的,程序员事先为这个Click事件写好的代码也永无被被计算机执行的机会。
以上为 事件驱动的程序运行方式
按钮的图片:

标签控件

设置BackColor属性值为Transparent,标签将不带有背景颜色。
在程序中使用标签动态显示信息:

编程要点:控件名字与事件
- 放在窗体上的每个控件都是对应控件类的对象,其中,最关键的是它的名字(即Name属性)!
- 在代码中,通过指定控件的名字来设定它的属性值。
- 控件会触发特定的事件,我们可以为这些事件编写事件响应代码,当事件真的发生时,这些响应代码被调用,这就是“事件驱动”的编程模式。
文本框控件
文本框控件主要用于供用户输入,它是TextBox类的对象
问:怎样得到用户输入的字符串?
答:使用它的Text属性
问:如何即时响应用户的输入?
答:响应它的TextChanged事件
1 2 3 4 |
private void txtUserInput_TextChanged(object sender, EventArgs e) { lblShowInput.Text = txtUserInput.Text; } |



在事件名上右击,选“重置” 命令,可以移除本事件的响应代码
需要牢记:
特定的控件,在不同的场景,会触发不同的事件。
在实际开发中,必须特别注意以下三点:
- 控件触发了哪些事件?
- 这些事件触发的顺序是怎么样的?
- 我选择哪些事件进行响应?
进度条控件与小闹钟控件
进度条:
- Maximum(最大值)与 Minimum(最小值)
- Value(当前值):当Value=Maximum时,进度条满格
一个进度条,点击旁边的按钮会使其进度+10,满了之后会清空
1 2 3 4 5 6 7 8 9 10 11 12 |
private void btnjia_Click(object sender, EventArgs e) { if(progressBar1.Value + 10 > progressBar1.Maximum) { progressBar1.Value = progressBar1.Minimum; } else { progressBar1.Value += 10; } } |
时钟控件:

个人实例练习:
时钟软件 https://lingyun67.cn/alarm-clock-demo/
使用容器控件布局窗体(WinForm)

控件的通用属性
控件的激活与禁用:Enabled属性,通过设置其值为True或False,可以激活或禁用特定的控件。
显示与隐藏:visible属性
Anchor属性:响应窗口的大小改变
Dock:黄色的是容器 Dock属性设置之后 按钮会按选项分布在背板上面

控件容器
控件容器是一种特殊的控件,它可以容纳多个“子”控件。

我们己经非常熟悉的“窗体(Form)”,就可以看成是一个容器控件,只不过它是“顶层容器”。
注意:“顶层容器”无法再放入另一个“顶层容器”中。
最简单的容器控件——面板(Panel)
面板中可以放置其他控件,甚至再嵌套一个面板。
使用面板,可以把它所包容的所有控件看成是一个整体,统一地控制它们(比如激活或禁用它们)。

组合框( GroupBox )
组合框功能与面板类似,仅在外观上有所区别。
组合框拥有一个标题和边界,在实际开发中多用于给控件分组。
特别地,当需要在窗体上放置独立的几组“单选钮”时,就可以将它们放在分组框中。
选项卡控件(TabControl)
选项卡控件(或称为多页面控件)能够充分重用有限的屏幕空间,在运行时由用户选择显示哪一组控件。
在程序运行时,我们可以动态地添加或移除选项卡,激活特定的选项卡,在需要的时候,也可以动态地向特定选项卡中添加或移除控件。
设计时添加与移除选项卡:TabControl的TabPages属性包容所有选项卡对象

在设计时可以使用集合编辑器添加或移除选项卡。

分隔条面板(SplitContainer)


FlowLayout控件布局容器
可以动态地添加与移除控件到FlowLayout中,由它负责排列控件。
使用FlowLayout称为“流式布局”,类似于Web网页布局方式,默认情况下,从左到右,从上到下排列其子控件,如果“自动换行(WrapContents)”特性打开,一行摆放不下时,会到下一行排列。如果打开了“自动滚动(AutoScroll)”特性,会在合适时候自动地添加水平或垂直滚动条

TableLayoutPanel



———— C#面向对象编程入门篇————
类和对象(C#)
理解类和对象的概念

“主窗体”和“从窗体”都是窗体,但“地位”不一样。主窗体一关闭,整个程序结束、从窗体关闭,整个程序仍然会继续运行。


学会编写类
所有代码放在类中,类是编程的基本单元。

一个.cs文件包含多个类
C# 9.0之后,代码可以不放到类中,这是C#引入函数式编程特性的结果。
C#使用class关键字定义一个类。类中常见的成员有:

字段和方法组合起来起个名字,就变成了类
C#中的字段与方法,可以加上“public、private、 protected”关键字控制其存取权限。
(公有的、私有的、被保护的)

需要注意的是:在方法体内定义的j是局部变量,类中的同名j是失效的(变量的屏蔽原则)

- 类中的方法,可以直接访问类中的字段。
- 类中的方法定义的局部变量,将屏蔽掉类中的同名字段。
- 有两种最基本的数据存取权限:
- public(公有):通过对象变量外界可以直接访问它
- private(私有):除了类内部的方法,外界无法直接访问它们
实践建议:在设计一个类时,仅仅只有需要被外界访问的成员才设置为public的。
类的“属性(property)”
字段+get/set方法=属性
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
public class MyTestClass { //私有变量用于存储数据 private string _myprop = ""; public string Myprop { get //读访问器 { return _myprop; } set /写访问器 { _myprop = value; //value是一个拥有特殊含义的关键字,在此处,它代表外界传入的值。 } } } |
使用例:
1 2 3 4 |
MyTestClass obj = new MyTestClass(); //向属性赋值 obj.Myprop = "Hello"; //读取属性值 Console.WriteLine(obj.Myprop); |
实现自定义属性的要点:
- 定义一个私有字段用于存储属性数据。
- 设计一个get方法,当读取属性值时,向外界返回私有字段的当前值。
- 设计一个set方法,当向属性赋值时,其自动隐含的value参数保存外界传入的值,应将此值传给前面定义的私有字段。
属性的经典实现方法的弊端:
当一个类中存在有很多的属性,而这些属性又采用类似的方法编写时,这是一个烦人的工作,要敲很多的代码!
C# 3.0的改进——自动实现的属性

“属性”比“字段”多了哪些东西?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
//人的年龄只能是一个正数,因此,当外界传入一个负数时,set方法会自动将其改为0。 public class Person { private int _age = 0; public int Age { get { return _age; } set { if(value < 0) { _age = 0; } else { _age = value; } } } } |
当使用经典写法时,我们可以很容易地在读/写属性时“插入一些”特定的代码,完成诸如“检验数据”、“显示信息”、“触发事件”等工作,这是单纯的字段所不具备的特性。
定义一个属性时,get/set方法并不需要同时存在,它们的存取权限也可以不一样,各种情况的不同组合,将影响到属性的存取特性……

开发建议
在实际开发中,多用属性少用字段,尤其是杜绝“公有(public)”字段,因为过多的公有字段,向外部暴露了内部的数据结构,外界可以随意修改它,有可能引发开发者意料之外的Bug,损害了代码的健壮性和可维护性。
表达式体属性(Expression-bodied property)

注意区分:
1 2 3 4 |
定义一个只读的属性: public int Foo => 0; 定义一个可读可写的公有字段: public int Foo = 0; |
表达式体方法(Expression-bodied method)

表达式体方法和属性,统称为“表达式体成员(Expression-bodied Member)”,这些特性从C# 6开始引入,以后版本不断地完善。
“表达式体成员”是个语法糖,它与后面课程要介绍Lambda表达式,虽然样子很像,但不是一回事,注意区分。
类的初始化过程
当我们通过new关键字创建一个对象时,一个特殊的函数被调用,此函数被称为——构造函数(构造方法)。
1 |
MyClass obj = new MyClass(); |
所谓“构造方法”,就是在创建对象时被自动调用的方法。
构造方法长成什么样?:
构造方法与类名相同,没有返回值。
1 2 3 4 5 6 7 8 9 10 11 12 |
public class MyClass { public MyClass() { Console.WriteLine("无参数构造方法被调用"); } public MyClass(string info) { Console.WriteLine("调用MyClass(string):" + info); } } |
一个类可以有多个构造方法,这些构造方法构成“重载(overload)” 关系。在程序实际运行时,依据参数决定调用哪个构造方法。
1 2 3 4 5 6 7 8 9 |
static void Main(string[] args) { //这样可以调用无参构造方法 MyClass obj = new MyClass(); //这样可以调用有字符串参数的构造方法 obj = new MyClass("Hello world!"); } |
构造方法主要用于在创建对象时给它的相关字段一个有意义的初始值。
定义一个类时,即使你没有显式地定义一个构造方法,C#编译器也会“偷偷”地给你的类加上一个没有参数的“缺省(或称为默认)构造方法”。
创建对象并给其字段/属性相应初始值的基本方法
1 2 3 4 5 6 7 8 9 10 |
public class MyClass { public int IntValue; public string StrValue { get; set; } } |
以下代码创建MyClass的对象并给其字段或属性赋值:
1 2 3 4 5 6 7 8 |
static void Main(string[] args) { MyClass obj = new MyClass(); obj.IntValue = 100; obj.StrValue = "Hello world!"; } |
当一个类定义了多个字段或属性时,代码变得很冗长……
改进方式一:给类添加构造函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
public class MyClass { public int IntValue; public string StrValue { get; set; } //给类添加构造函数 public MyClass(int iValue, string strValue) { IntValue = iValue; StrValue = strValue; } } |
使用代码:
1 |
MyClass obj = new MyClass ( 100 , "Hello" ); |
但是存在的问题就是如果我只想给部分属性和字段一个有效的初始值,需要提供多个重载的构造函数。这就让类的定义变得复杂了。
改进方式二:使用C#3.0所提供的“对象初始值设定项”特性,不需要给MyClass添加重载的构造函数即可实现相同目的:
类的原始定义不变
1 2 3 4 5 6 7 8 9 10 |
public class MyClass { public int IntValue; public string StrValue { get; set; } } |
简化后的字段/属性初始化代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
//对象字段和属性直接使用其默认值 MyClass obj1 = new MyClass(); //设定对象所有的字段和属性初始值 MyClass obj2 = new MyClass { IntValue = 100, StrValue = "Hello" }; //设定对象部分的字段和属性初始值 MyClass obj3 = new MyClass { IntValue = 100 }; |
还可以直接初始化集合对象
直接初始化基本数据类型的集合对象
1 |
List<int> digits = new List<int> { 0, 1, 2, 3, 4, 5, 6 }; |
初始化包含引用类型元素的集合对象
1 2 3 4 |
List<MyClass> objs = new List<MyClass>{ new MyClass{ IntValue=100,StrValue="Hello"}, new MyClass{ IntValue=200,StrValue="World"} }; |
类的组织与管理
分部类与分部方法
分部类(partial class):
把同一个类的代码分散到多个文件中
分部方法(partial method):
在一个文件中声明方法,在另一个文件中实现或调用方法


分部方法与分部类的应用实例:
- 在.NET中,Windows Forms和WPF都使用了分部类将“自动生成”的代码与程序员“手工编写”的代码分隔开来。
- 在实际开发中,我们可以使用分部类将不同程序员对同一个类的修改 “隔离”开来,提升代码的可维护性。
- 利用分部方法,我们可以预先定义好一些“扩展点”(有点类似于在自习室用书本占座),然后在需要这些扩展点发挥作用时,提供其实现代码(人到自习室了),从而在源代码级别让系统易于扩展(无需修改原有代码,即可实现新的特性)。
命名空间
把类比作书的话,命名空间就是书架
命名空间(namespace):可以看成是类的仓库,.NET中所有的功能都由类提供,这些类被分门别类地存放在特定的命名空间中。
有单独的命名空间

也有多层嵌套的命名空间
要点:
- 类放在命名空间中
- 命名空间可以嵌套
在C#中创建和使用命名空间:
C#中使用namespace关键字来定义命名空间

使用using语句来引用命名空间的类


使用“对象浏览器”

命名空间在“对象浏览器” 中使用“{}”展示程序集中的命名空间及其包容的类型
C# 6中的简化
对于一些完整名字很长的类型,可以通过提前“静态导入”的方式,大大缩短其长度,从而允许在代码直接写上方法名。


程序集
如果把程序集比作砖块,那么应用程序就是建筑物
关于“程序集”应该知道的……:
- .NET程序的基本构造块是“程序集(Assembly)”。
- 程序集是一个扩展名为.dll或.exe的文件。
- .NET 中的各个类,存放在相应的程序集文件中。
如何创建一个程序集?
“类库(class library)”项目模板可以用于创建一个DLL程序集

使用程序集
在一个新项目中添加对于特定程序集的“引用(Reference)”,即可使用此程序集中的类:

程序集与命名空间的关系
- 程序集的物理载体是“实实在在可以看得到”的.dll或.exe文件。
- 命名空间是类的一种组织方式,它是一个逻辑上的概念。一个命名空间中的类可以分布在多个程序集文件中。
- 一个程序集至少包含一个命名空间。可以在项目的“属性”面板中直接指定其生成的程序集默认的命名空间(如下图所示)。
基于程序集开发
- 通过将需要复用的代码放到类库项目中,生成二进制的.dll程序集文件,然后在新项目中直接引用此.dll文件,即可以使用其中的类。
- 使用程序集构造软件不需要有类的源代码,有.exe和.dll文件即可。
- 象程序集这种可以复用的软件系统构造单元,被称之为“软件组件”。
在早期的.NET程序中,通常都是直接引用本地程序集的物理文件(.dll或.exe)来重用其内部的代码,后来,为了方便组件的跨互联网重用,以及解决版本管理和组件间依赖的问题,引入了一种新的组件——Nuget包,并且慢慢地成为了主流。
有关Nuget包的相关介绍,参看“.NET Core 软件开发技术导论与自学指南”课程中的相关章节。
积木式的软件开发方式
基于程序集,可以方便地在.NET平台上实现组件化开发,其具体过程为:
- 重用已有的组件
- 开发部分新的组件
- 将新老组件合在一起“搭积木”。
从现在开始,当开发正式的项目时,都应该采用基于程序集的开发方式!
“对象”与“对象变量”那些事儿
对象与对象变量
对象变量与内存模型
“对象变量”与“对象”之间的关系……

对象生存于托管堆(Managed Heap)中,当不用时,CLR会自动回收其内存。
CLR中有一个垃圾回收的一个线程,在合适的时候他会运行,检查哪些对象已经不再使用然后回收其所占用的内存
对象生成的这块区域:托管堆的管理不是程序员负责,是由虚拟机 CLR负责
方法中所定义的对象变量保存在线程堆栈(Thread Stack)中,这两者是不一样的
线程堆栈 vs. 托管堆
- 程序代码其实是由线程负责执行的,每个线程都拥有一个用于保存临时数据的特定内存区域,称为“线程堆栈(Thread Stack)”。
- 保存在线程堆栈中的数据,当它所关联的线程运行结束时,这个线程堆栈会被销毁,导致其中的数据“全没了”。
- 保存在托管堆中的数据,只有当整个程序结束时,才会被全部“销毁”。
C#中的两种主要的变脸类型:引用类型 vs. 值类型
“类”类型的变量属于“引用类型(Reference Type)”,其引用的对象占用的内存位于“托管堆(managed heap)”中。
int之类简单类型(还包括struct等)的变量属于“值类型(Value Type)”,方法内部所定义的值类型的变量,其占用的内存位于“线程堆栈(thread stack)”中。
一个思考:假设MyClass是一个类,请看以下C#代码:
1 2 3 |
MyClass obj1 = new MyClass(); MyClass obj2 = null; obj2 = obj1; |
上述代码执行之后,obj1和obj2引用的是两个不同的对象吗?
对象变量“相互赋值”的真实含义
首先会在托管堆中new一个MyClass对象,紧接着定义好了一个对象变量obj1,这个对象变量负责引用这个MyClass对象,对象变量obj1的内存区域保存的是MyClass对象在托管堆中的地址值。
可以说对象变量obj1是一块用来保存地址的存储区域
第二句 MyClass obj2 = null; 定义了一个新的对象变量obj2=null,null是一个特殊值,表示obj2是一个空引用
当obj1赋值给obj2,obj1没有改变
要注意的是,对象变量的赋值其实是对象变量所关联的内存区域地址的值复制
赋值之后obj2和obj1的保存的值是一样的,所以他们引用相同的对象,在全部过程中,MyClass对象都只有一个。
对象判等
一段代码:
两个值类型变量的判等
1 2 3 4 5 |
int i = 100; int j = 100; Console.WriteLine(i == j); //int类型内的Equals方法 Console.WriteLine(i.Equals(j)); |
int类型其实是一个结构体,这个结构体里是.NET基类库在程序集mscorlib中封装的基本的数据类型,是int32,其中有很多相应方法,比如Equals
Equals方法的摘要是比对int32这个结构体里面的数值是否是一样的,返回一个bool类型
这段代码运行的结果是两个True
另一段代码:
两个对象的判等
1 2 3 4 5 |
MyClass obj1 = new MyClass(); MyClass obj2 = new MyClass(); Console.WriteLine(obj1 == obj2); Console.WriteLine(obj1.Equals(obj2)); |
这个Equals方法与之前的来源不同,这个方法来自object类型
当定义一个类的时候,没有指明他的基类库,那么他的基类库默认为Object。
基类库的作用是完成框架的通用性开发而必须的基础类和常用工具类等
代码的输出结果是两个False,这表明obj1与obj2引用了两个不同的对象,他们内存的地址不同equals方法默认的功能和==一样,但有的时候我们希望比对两个对象的字段值(按照内容进行比较而不是按照引用来比较)
此时就需要重写基类的equals方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
class MyClass { private int Vaule = 100; public override bool Equals(object obj) { //如果要比较的这个对象是空引用那么直接返回false if (obj == null) { return false; } else { //如果传入的这个参数也是MyClass对象 if(obj is MyClass) { //obj as MyClass把这个参数转变为MyClass的类型变量然后取出来其值然后进行对比返回bool return this.Vaule == (obj as MyClass).Vaule; } } return false; } //在.NET规定如果重写了object的Equals方法那必须也对这个GetHashCode()覆写 public override int GetHashCode()//主要目的是为了让对象放在一些以哈希值作为定位的集合中也能正常运作 { return Vaule; } //这个方法有一个基本要求就是当我们的字段值不一样的时候它的返回值一定要不同(? } |
在MyClass内对Equals进行override
代码运行后输出的是False True
因为第二次的比对使用覆写的Equals方法判断的是字段值
override 方法:提供从基类继承的成员的新实现 也就是覆写 重写
两个字符串的判等
1 2 3 4 |
string str1 = "Hello"; string str2 = "Hello"; Console.WriteLine(str1 == str2); Console.WriteLine(str1.Equals(str2)); |
代码输出的是两个true
注意:string类型是引用类型,但是它的等于号和Equals方法是一样的,都是判断这两个字符串的值是否是一样的
string类型 对判等==运算符 进行了重写
注意:此处的结论仅适用于C#,另外的编程语言比如Java,仍然严格区分string类型的==和Equals方法,==比较对象引用,Equals比较对象内容。
结论:
- 当“==”运算符施加于两个值类型变量时,实际上是比对两个变量的内容(值)是否一样。
- 当“==”运算符施加于两个引用类型变量时,实际上是比对这两个变量是否引用同一对象!
- 要“按值比较”对象,需重写其Equals()和GetHashCode()方法。
- String是引用类型,但它的“==”经过了重写,其功能与“Equals()”方法一样,都是比较两个字符串的“内容”是否一样
另一个比较对象引用的方法:
.NET Core和.NET Framework中,都为Object类(它是所有.NET类的最顶层基类)定义了一个ReferenceEquals()方法,可以使用这个方法来比对任意两个对象变量是否引用同一个对象。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
static void objectReferenceDemos() { var a = new MyClass(); var b = new MyClass(); Console.WriteLine(object.ReferenceEquals(a,b));//False b = a; Console.WriteLine(object.ReferenceEquals(a,b));//True var str1 = "abc"; var str2 = new string(new char[] { 'a', 'b', 'c' }); //False Console.WriteLine(object.ReferenceEquals(str1,str2)); //True Console.WriteLine(object.ReferenceEquals(str1,"abc")); } |
this引用
类的实例方法可以直接访问同一个类的实例字段,其中隐藏着一个this引用

C#中的this,是一个特殊的对象引用,它代表对象自身。
This = me
位于同一类内部的成员彼此访问,本质上是通过this这一特殊引用来完成的。只不过这个关键字通常被省略了。
通过对象变量来访问对象的实例成员,是面向对象编程的一个基本准则。
装箱与拆箱
C#是一种“强类型”的编程语言
- 强类型的编程语言,要求变量“先定义后使用”,并且变量要拥有明确的特定的类型。
- 特定类型的变量,只能接收特定类型的值
如果把值类型数值赋给引用类型变量:
1 2 |
int num = 123; object obj = num; |
会在托管堆中创建一个对象 值是num,然后让obj指向这个对象,这个过程的术语就是装箱

对象赋值给值类型变量:
1 2 3 |
int num = 123; object obj = num; int value = (int)obj; |
把对象里面所包容的数值取出来、然后再把它赋值给一个值类型的变量。这个过程就是拆箱

这种装箱/拆箱的特性是面向对象特有的,使用的并不多,会对程序的性能造成影响
方法参数传送方式与ref
两种类型的方法参数



就是一个是复制值过去,一个是传地址过去,跟指针一样
按“引用方式”传递值类型参数

返回ref的函数
不仅可以将方法的参数定义为ref的,甚至连方法的返回值,也可以是ref的!
为此,我们设计一个实例,展示返回ref结果的方法是什么样子的。
编写一个工程师类






- 使用ref,其实就是给保存值类型数据的那块内存区域(注意它在线程堆栈中),关联上不同的名字。通过这些名字,可以很方便地直接“找到”这块内存区域,并进而读取或修改其内容。
- 因此,ref可以用于在两个方法之间共享相同的值类型数据。
- 默认情况下,当方法参数是值类型时,实参按“值”方式传送,也就是外部数据值被复制了一份传给方法,方法内所访问的数据,其实是外部数据的副本。
- 当方法参数是引用类型时,对象的引用被传给了方法,方法内所访问的对象,与方法外部变量所引用的对象,是同一个。
- 当方法参数是值类型时,可以给其加上ref修饰符,在调用时不复制原数据值,而是将其“引用(即地址)”传给了方法,方法内所访问的数据值,与外部所使用的数据值,是同一个。
- 当方法返回值类型数据时,也可以给其加上ref修饰符,将其地址(而不是将数据值复制一份)传给外界。
- 使用ref的好处,在于数据“呆在一个地方不动”,程序运行时只存在“地址值”(一个64位二进制长度的整数值)的复制,而不需要复制整个数据值(有些值类型,比如struct结构可以包容有许多成员),从而减少了不必要的内存存取操作,有助于程序性能的提升。
只读的数据类型
通常情况下,对象的字段值是可以修改的
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
class Program { static void Main(string[] args) { MyClass obj = new MyClass(); obj.add(1);//Vaule+1 obj.printVaule();//输出101 } } class MyClass { private int Vaule = 100; public void add(int step) { this.Vaule += step; } public void printVaule() { Console.WriteLine("Vaule = {0}", Vaule); } } |
但是在.NET基类库中,我们发现
值类型的DateTime不太一样:
1 2 3 4 |
DateTime Date = new DateTime(2022, 5, 7); Date.AddDays(1); //增加一天 Console.WriteLine(Date); //输出仍为2022/5/7 |
字符串类型也不太一样:
1 2 3 4 5 |
string str = "abcd"; str.ToUpper(); //改为大写 Console.WriteLine(str); //输出仍为abcd |
DateTime和string类型变量居然都是只读的!一旦创建之后,内容不可改!
为什么要设计“只读”的类?
- 现在的计算机和手机、平板,都是多核的。
- 为发挥多核CPU的计算能力,应用程序应该是“多线程”的
- 在“多线程”环境下,多个线程访问同一个对象时,因为对象是只读的,无需互斥(一次只允许一个访问,一个在访问时,其他等待),就可以保证数据读取不会出错(想一想,如果不是只读的,一个线程正在读,另一个线程正在写,一切就乱套了)。
所以,在“多线程”环境下,使用只读对象可以提升程序的性能。
设计“只读”的类
当外界期望修改对象的字段值时,不是修改原有对象的字段值,而是新建一个对象,让它的字段值符合要求,然后把这个新对象返回给外界!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
class MyOnlyReadClass { private int Vaule = 100; public MyOnlyReadClass add(int step) { MyOnlyReadClass obj = new MyOnlyReadClass(); obj.Vaule = this.Vaule; obj.Vaule += step; return obj; } public void printValue() { Console.WriteLine("Value = {0}", this.Vaule); } } |
使用例如下
1 2 3 4 5 6 7 |
//只读类的使用例 MyOnlyReadClass readonlyObj = new MyOnlyReadClass(); MyOnlyReadClass readonlyObj2 = readonlyObj.add(1); Console.WriteLine(readonlyObj2 == readonlyObj); //false readonlyObj.printValue(); //输出100 readonlyObj2.printValue(); //输出101 |
readonlyObj2 == readonlyObj 为false 他们引用不同的对象
C# 7以后,允许定义只读的结构体:

如果编译器发现程序中任何的地方有代码尝试修改只读结构体的字段,就会报告编译错误,从而将“错误消灭在萌芽状态”。
只读的结构体方法
在C# 8中,允许你将结构体中的某个方法定义为只读的,编译器会检查这个方法内部有没有代码修改了外部的数据(比如,修改了结构体中的某个字段的值,或者是调用了另一个“非只读”的方法),如果发现了这种情况,编译器就会引发一个“编译错误”,提醒你修正它。

小结:
- 通常情况下,对象的状态是可以更改的,这在多线程环境下可能会引发难以预料的错误,因此,设计只读的数据结构(包括类和结构体)是推荐的做法。
- 从C# 7以后,C#引入了readonly这个关键字,从语法层面引导开发者编写“只读”的代码,让编译器来检查现有的代码,用好这些特性,有助于开发者写出Bug更少,更为健壮的代码。
类的静态成员
在实际开发中,我们可能会有一些“到处都要使用”的功能需要实现,比如各种标准的数学函数以及圆周率等数学常量,在C#中如何定义并实现它们?
.NET基类库中Math类所封装的部分数学常量与函数

使用const定义数学常量
使用static定义数学函数

使用static关键字来定义类的静态成员

两种类型的类成员
静态(static)方法/字段/属性:使用static定义
实例(instance)方法/字段/属性:不使用static定义
实例的类成员想要访问必须要通过一个对象变量,静态的对象成员不需要对象变量,只需要一个类名就可以

类的静态方法可以访问类的静态字段,但是不能直接访问类的实例字段
相应的,实例的方法可以访问静态字段,也可以访问实例字段
另一个实例:


每次创建都各给两个字段+1,dynamicVar字段为实例成员,staticVar字段为静态成员
输出时 dynamicVar = 1 staticVar = 100
事实上:
- 创建了100个MyClass对象,每个对象都拥有一个独立的dynamicVar字段
- 创建了100个MyClass对象,共享同一个staticVar字段
- 类的实例成员只能通过对象来访问。每个对象都有一份自己独享的实例成员,是“个人财产”。
- 类的静态成员归所有对象所共享,是“国有资产”。
类成员的访问规则:
- 类的实例方法可以访问类的实例字段;
- 类的实例方法可以访问类的静态成员;
- 类的静态方法只能访问类的静态字段。
使用静态成员的好处
- 由于静态成员并不依附于特定的对象,而可以直接调用,因此,它使用更方便。
- 编写静态方法时,如果它需要访问静态的字段或属性,则要注意在多线程环境下,有不有可能出现数据存取错误的情况。
- C#提供有一种非常方便的“扩展方法”,能够在不修改源代码的前提下,动态地给特定的类型添加新的方法,这一特性,就是使用“静态类 + 静态方法”实现的,在后面的课程中会介绍这块的内容。
匿名类型
没有名字的“临时”对象
在C#中,如果你只需要“临时”使用一个对象,并且这个对象只在这个地方使用,那么,你可以使用匿名类型达到这个目的:
1 2 3 4 5 6 7 8 |
var user = new { Id = 1, Name = "傻逼一样的不会你妈闭嘴的舍友", Age = 3 }; Console.WriteLine("用户的id为"+user.Id+"名称为"+user.Name+"年龄为"+user.Age+"岁"); |
user对象内的id name age的类型都是编译器推断的
对于匿名类型,C#编译器会将匿名类型转换为一个类,在程序运行时,以这个类为模板,创建一个匿名对象供程序使用。
所以所谓的没有类型其实只是针对于在编写代码的时候的没有。
将匿名对象序列化为Json
将一个对象属性信息提取出来,生成一个Json字符串的过程,称为对象的“Json序列化”。
.NET基类库中有相应的组件,可以直接完成这个工作:

注意,默认情况下,属性中的“中文字符”,会被转义,这个是Json规范所约定的。
不转义中文字符串:
如果希望汉字能保持原样,可以设置JsonSerializer不对中文字符进行转义:

示例中用到的JsonSerializer是.NET Core基类库中的内置组件,在.NET Core项目中可以直接拿来就用。
另有一个历史悠久的第三方Json库——Newtonsoft.Json,无需任何设置就能在序列化汉字时保持原样不动,但必须单独使用NuGet管理器在线安装后,项目中才能用它。
匿名类型的应用场景-1
匿名类型通常只在方法内部使用,主要用于临时表达一些信息,用完就扔……
比如,在ASP.NETCore Web应用中,就经常使用匿名类型封装客户端需要的信息。

匿名类型的应用场景-2
- 匿名类型的另一个典型应用场景,是用在LINQ查询中,将需要的信息动态地装配为一个匿名对象的信息,返回给调用者。
- 在后面的课程学习到LINQ查询时,你会看到相应的示例。
多窗体编程初步


C#中的数组
数组
我们把一组有顺序的数据所构成的整体,称为“数组”。
数组中数据的位置编号,从0开始依次递数组中数据的位置编号,从0开始依次递

数组中的单个数据,称为“元素”,它们具有相同的数据类型。

数组一旦创建之后,尺寸保持不变,元素在内存中连续分布。
数组是一个对象,数组变量(arr)引用这个数组对象。
通过“数组名[索引值]”访问单个的数组元素。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
//定义一个包容 8 个元素的数组,并直接对其初始化 int[] arr = new int 8 [] { 234, 565, 23, 90, 1, 34, 89, 13}; //按照索引访问数组元素 arr[7]++; //最后一个元素自增 1 //使用 for 循环遍历数组元素 for (int i = 0; i < arr.Length ; i++) { Console.WriteLine(arr [{0}]={1}", i , arr[i]); } //也可以使用 foreach 循环遍历数组元素 foreach (var element in arr) { Console.WriteLine(element); } |
数组不允许“越界访问”,否则,会抛出一个IndexOutOfRangeException
对象数组
数组中的元素可以是引用类型的,这种数组俗称为“对象数组”。
1 |
var stringArr = new string[] { "How", "are", "you", "?" }; |
变量stringArr引用一个包容4个元素的数组对象,每个元素又引用一个字符串对象……

数组本身是一个引用类型,因此,如果它被传送给一个方法,则方法内部对数组的修改,会直接作用于“原始”的数组对象。

但是被修改的并不是之前的字符串How are you本身,而是数组所引用的对象被修改了,之前的字符串仍然存在,等待被垃圾回收器回收

- 除了数组,.NET 基类库中还定义有许多的集合,比如List, HashTable,Dictionary等等,它们虽然有各自的特性和适合的应用场景,但同时又与数组有着许多的共性。
- 学习与掌握好数组相关的知识与编程技巧,能为你学习与掌握.NET 基类库中其他的集合对象打好基础。
- 我们将在“LINQ与数据结构”这门课程中,更为系统与深入地学习各种数据集合对象以及相关的数据处理技巧。
继承
继承概述
继承是对现实生活中的“分类”概念的一种模拟。
示例:狮子是一种动物。
狮子拥有动物的一切基本特性,但同时又拥有自己的独特的特性,这就是“继承”关系的重要性质。

形成继承关系的两个类之间,是“IS_A”关系
IS_A译为:"是一种"
在C#中实现继承:
首先定义一个Animal类:
1 2 3 |
class Animal { } |
Animal类被称为父类(parent class) 或者 基类(base class)
然后定义一个类Lion
1 2 3 |
class Lion : Animal { } |
Lion类被称为类(child class)
从外部使用者角度看来,子类“自动”拥有了父类声明为public 和 protected(保护) 的成员,这就是继承的最重要特性之一。
父类:
1 2 3 4 5 6 7 8 9 10 11 12 |
public int Pi = 100; public void Pf() { Console.WriteLine("Parent.Pf"); } protected int Pj = 200; protected void Pg() { Console.WriteLine("Parent.Pg()"); } private int k = 300; |
子类:
1 2 3 4 5 6 7 8 9 10 11 |
class Child : Parent { public void cf() { Pg(); //子类可以直接访问父类的保护成员 Pj += 200; Console.WriteLine("Child.cf()"); Console.WriteLine("Parent.Pj = {0}", Pj); } } |
子类中的代码可以直接访问父类保护级别的成员,但外界不能通过对象变量来直接访问声明为保护级别的类成员。
应用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
class Program { static void Main(string[] args) { Child c = new Child(); //可以通过子类变量访问定义在基类的公有成员 c.Pi = 300; Console.WriteLine("Parent.Pi = {0}", c.Pi); c.Pf(); //c.Pj = 1000; Error!,不能访问保护级别的成员 c.cf(); //可以通过子类定义的公有方法访问基类保护级别的成员 } } |
更进一步:继承环境下的字段访问规则
- 同一类中的实例方法可以访问所有字段。
- 子类实例方法可以访问父类中的protected和public的字段,但不能访问private的字段。
- 变量同名时,“离得最近”、“关系最密切” 的变量起作用。
方法的重载与覆盖

1 2 3 4 5 6 7 |
Animal an = null; Lion lion = new Lion(); an = lion; //正确 lion = an; //编译时错误,父类无法给子类赋值 lion = (Lion)an; //当我们确定an是一个Lion,可以强制类型转换 Monkey m = (Monkey)an; //虽然可以通过编译,因为an是一个Lion,所以会在运行时报错错误。 |
子类对象可以赋值给父类(基类)变量,这实际上是“IS_A”关系的体现。
当子类、父类的方法名相同时,有两种情况:
Overload(重载)
1 2 3 4 5 6 7 8 9 |
class Parent{ public void OverLoadF() { } } class Child : Parent{ public void OverloadF(int i){ } } |
Override(重写/覆盖)
1 2 3 4 5 6 7 8 9 |
class Parent{ public virtual void OverRideF() { } } class Child : Parent{ public override void OverRideF(){ } } |
当方法的元素不同时,会出现Overload(重载),相同时则会进行Override(重写/覆盖)
子类父类方法字段“一模一样”时

测试代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
Parent p = new Parent(); Child c = new Child(); p.HideF(); c.HideF(); p = c; //基类变量引用子类对象 p.HideF(); //输出的还是父类的结果 (p as Child).HideF(); //只有转成子类类型才会运行子类方法 Console.WriteLine(p.Value); //父类 Console.WriteLine((p as Child).Value); //子类字段 Console.ReadKey(); |
开发建议:不要自找麻烦!
在实际开发时,不要在子类中定义与父类一模一样的成员(包括字段、属性和方法)!
提升软件开发效率的法宝——重用
在面向对象思想发展的初期,通过继承复用代码曾经被认为是面向对象最重要的目标之一。
很遗憾,实践中人们发现在开发中滥用继承后患无穷……

代码间强耦合,拥有极深的类型继承树,上层基类一改,所有子类均受影响,并且这种变动所带来的影响很难预计……
- 不要仅仅为了“代码重用”而引入很深层次的继承,想想能不能通过“对象组合”(后面课程介绍)的方式实现相同的目的。
- 当且仅当两个类之间是非常明确的“IS_A”关系,并且程序中希望利用面向对象的“继承多态”特性(后面课程介绍)时,才引入“继承”。
继承是现实世界对事物“类别”关系的一种模拟,在了解了与继承之间的相关知识之后,请观察一下你周围的事物,它们中的哪些可以应用“继承” 构建出一个面向对象的软件模型?
抽象类与接口
抽象类与抽象方法
在一个类前面加上“abstract”关键字,此类就成为了抽象类。
一个方法前面加上“abstract”关键字,此方法就成为了抽象方法。
1 2 3 4 |
abstract class Fruit //抽象类 { public abstract void GrowInArea(); //抽象方法 } |
- 抽象方法不包容任何实现代码。
- 无法使用new关键字直接创建抽象类的对象。
- 包含抽象方法的类一定是抽象类,但抽象类中的方法不一定是抽象方法,抽象类中可以包容“普通的”方法。

“抽象类”怎么用?
不能创建抽象基类的对象,只能用它来引用子类的对象。
1 |
抽象类名 变量名 = new 继承自此抽象类的具体子类名(); |

让我们先从“继承”聊起……
“继承”是对现实世界中“是一种(IS_A)”关系的模拟。

现在试着为以下一个场景建立一个面向对象的编程模型
鸭子是一种鸟,会游泳,同时又是一种食物。

• “会游泳”这个方法放在哪个类中?
- 并不是只有鸭子一种鸟会游泳。
- 并不是所有鸟都会游泳。
因此不知道“会游泳”这个特性应该放到哪个类中
C#/Java等编程语言不支持多继承
解决方案就是使用接口:

接口
C#中接口的特点
使用interface关键字定义接口 接口的名字通常以“I”打头。

接口中的方法只有声明,不包容任何代码
经典的面向对象编程语言,最初接口中除了函数或属性/字段定义,是没有其他语言成份的,但近些年来,这条规则被突破,Java、C#纷纷允许在接口中放入有实现代码的方法,后面课程介绍相关的内容。


“接口”小结
- 与抽象基类相比,接口不包含任何的实现代码接口可以看成是一种“纯”的抽象类。
- 接口实际上可以看成一种约定,对于所有实现了接口的类,可以说“它们看上去都是这样的……”,但到底类类,可以说“它们看上去都是这样的……”,但到底类是如何“遵守”与实现这种规定,完全由类自己来定。
- 接口在面向对象开发实践中用得极广。
理解“多态”
继承多态
“多态性”一词最早用于生物学,指同一种族的生物体虽然具有相同的本质特征,但在不同环境中可能呈现出不同的特性。如东北大米对比泰国香米、各种种类的狗之间对比。
面向对象开发中的多态
在面向对象理论中,多态是:
同一操作施加于不同的类的实例,不同的类将进行不同的解释,最后产生不同的结果。
从编程角度来看,“多态”表现为:
同样的程序语句,在不同的上下文环境中可能得到不同的运行结果。
多态实例:
苹果和菠萝都是一种水果,它们都有“适宜种植区域” 这个信息值得关注。
因此为苹果、菠萝建立面向对象软件模型

水果被定义为“抽象类”,其中定义一个抽象方法GrowInArea(),表示“种植区域”,要求子类必须重写。
苹果和菠萝成为Fruit的子类,分别为其抽象方法GrowInArea()提供了不同的实现代码,这种多态编程方式称为 “子类重写基类的抽象方法”。是一种最常见的多态代码的表现形式。

相同的一句“f.GrowInArea()”,由于 f 引用的对象不同,导致其输出结果不同。
这点就是多态特性的一种体现
“真正的”多态代码:
1 2 3 4 5 |
static void ShowFruitGrowInAreaInfo(Fruit fruit) { fruit.GrowInArea(); } |
此方法中的代码只调用“基类” 中定义的方法,不涉及任何具体的子类,因此,此方法里面全部都是“多态” 代码。
多态代码调用实例
1 2 3 4 5 |
//中国哪儿适宜种苹果? ShowFruitGrowInAreaInfo(new Apple()); //中国哪儿适宜种菠萝? ShowFruitGrowInAreaInfo(new Pineapple()); |
ShowFruitGrowInAreaInfo()方法可以输出任何一种水果(比如桔子)的“适宜种植地”信息,只要程序中有相应的派生自Fruit类的特定水果类(比如Orange)即可。
需要扩充的时候,只需要定义一个比如是Orange,然后继承自Fruit即可
多态的代码,只调用“基类”中定义的方法,存取 “基类”中定义的字段和属性,简单地说,就是:
针对“基类”编程
动物园示例
假设某动物园管理员每天需要给他所负责饲养的狮子、猴子和鸽子喂食。我们用一个程序来模拟他喂食的过程。
面向对象建模中的“名词法”
用人类的自然语言描述出软件要干的事,挑出其中的名词,它们就是“候选”的“类”。
描述:
动物园管理员每天需要给他所负责饲养的狮子、猴子和鸽子喂食。
抽取名词:
管理员、鸽子、狮子、猴子、动物园
上面去掉过于宽泛的动物园名词。
使用“名词法”建立软件模型
三种动物对应三个类,每个类定义一个eat()方法,表示吃饲养员给它们的食物。


再设计一个Feeder类代表饲养员,其name字段保存饲养员名字,三个方法分别代表喂养三种不同的动物,其参数分别引用三种动物对象。

喂食过程:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
static void Main(string[] args) { Monkey m = new Monkey(); Pigeon p = new Pigeon(); Lion l = new Lion(); Feeder f = new Feeder(); f.Name = "小李"; f.FeedMonkey(m); f.FeedPigeon(p); f.FeedLion(l); } |
但是不同的饲养员,应该喂食的动物是不固定的。或者可能饲养员每个月饲养的动物也不相同。
因此这样设计Feed是不合理的
重构:引入继承

设计抽象类Animal,定义抽象方法eat(),每个子类都覆写这个eat

重新设计Feeder类的喂养方法
因此喂食过程变成了:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
static void Main(string[] args) { Monkey m = new Monkey(); Pigeon p = new Pigeon(); Lion l = new Lion(); Feeder f = new Feeder(); f.Name = "小李"; f.FeedAnimal(m); f.FeedAnimal(p); f.FeedAnimal(l); } |
事实上还可以进一步优化
然后我就听不懂了

“多态”的好处
从这个示例中可以看到,通过在编程中应用多态:
可以使我们的代码具有更强的适用性。
当需求变化时,多态特性可以帮助我们将需要改动的地方减少到最低限度。
“多态”具体实现方式有两种:

C#接口新特性
接口中的静态成员
在C# 8中,可以为接口添加静态的字段与静态的方法。

接口中的静态字段多为只读类型的字段,代表与本接口所代表的事物密切相关的数据。
而静态方法多用于封装一些与本接口所代表的事物密切相关的“公用”代码。
接口中的静态成员,需要通过接口名来访问:

通过接口名,可以非常清晰地分辨出——“此信息归属于拥有哪种特性的事物,此功能是由拥有哪种特性的事物所提供的”。
实现接口的类
实现接口的具体类型,可以使用本接口所定义的静态字段:

放在接口中的静态成员,通常封装了相关的信息和代码,它们与接口所抽象出来的“事物特性”密切相关。
实现了接口的对象,它本身就有接口所定义的这些特性,因此,它使用接口中的静态成员就非常自然,这种编程方式,体现出了“将相关的东西(或代码)集中放置以便于管理和维护”这样一种推荐的做事方法。
接口默认方法
我们也可以在接口中定义一个“普通的”方法:
1 2 3 4 5 6 7 8 9 |
interface MyInterfaceWithDefaultMethods { void PringTypeInfo() { //在此方法中可以使用this引用那些实现了本接口的对象 Console.WriteLine($"本对象的类型为{this.GetType()}"); } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 |
class Myclass : MyInterfaceWithDefaultMethods { } class MyOtherCalss : MyInterfaceWithDefaultMethods { public void PrintTypeInfo() { Console.WriteLine("我可以提供我自己的实现方案"); } } |
实现接口的类,如果不提供此方法的自己的实现代码,那就使用接口所定义的,这就是接口所定义的方法为什么被称为“默认方法”的原因。
MyClass和MyOtherClass在定义好之后,使用起来和其他类是没有区别的。
模式匹配
- 所谓“模式(pattern)”,其实就是一个“判断标准”,你可以用它来判断某些事物是否符合这个标准,当符合时,称为“匹配(Match)”。
- 在软件开发中,模式通常使用一个返回bool值的表达式来表达,将特定的数据(比如对象)传给它进行判断,然后依据判断的结果(即是否匹配),决定下一步的工作。
- 模式匹配代码,可以看成是“增强型”的if/switch语句结构。
- 大多数模式匹配特性是C# 7.0开始加入的,然后C# 8.0及以后版本继续增强这一特性。
基本语法:
常量匹配模式(constant pattern)
所谓“常量匹配”,就是把一个变量直接与一个具体的数值或对象比较。
1 2 3 4 5 6 7 8 9 10 11 12 |
static void ConstantMatch(object input) { if (input is "Hello") Console.WriteLine("int是一个字符串,其值为Hello"); else if (input is 5L) Console.WriteLine("input是一个长整型数值,其值为5"); else if (input is 10) Console.WriteLine("input是一个整型数值,其值为10"); else Console.WriteLine("input不满足上述的所有条件"); } |
比如
1 2 |
if(input is 10) ..... |
就等价于
1 2 |
if(input is int && (int)input == 3) ..... |
类型匹配模式(Type Pattern)
所谓“类型匹配”,就是判断一个对象(或数值),是否是某个类型的实例。
1 2 3 4 5 6 7 8 9 10 11 12 |
static void TypeMatch(object input) { if (input is string str) Console.WriteLine($"int是一个字符串,其值为{str}"); else if (input is long longValue) Console.WriteLine($"input是一个长整型数值,其值为{longValue}"); else if (input is int intValue) Console.WriteLine($"input是一个整型数值,,其值为{intValue}"); else Console.WriteLine($"input不满足上述的所有条件,其类型为{input.GetType()}"); } |
在“类型匹配”表达式的后部,可以追加定义一个局部变量,此变量具有本分支所对应的类型,可以用于此分支后继的表达式或语句中。
使用类型匹配构建复杂的逻辑表达式
类型匹配,可用于构建复杂的逻辑表达式:
1 2 3 4 |
if (input is int x && x >100) { cw($"Input :{x}"); } |
上述模式匹配表达式所引入的变量x,可以用于构建更复杂的表达式,或者直接用于分支所包容的语句中,省去了进行类型转换的麻烦。
类型匹配表达式,也可以用于switch语句
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
static void TellMeTheType(object o) { switch (o) { case string s: Console.WriteLine($"o是一个字符串,其值为{s},包含{s.Length}个字符"); break; case int i: Console.WriteLine($"o是一个整数,其值为{i},其平方数为{i * i}"); break; default: Console.WriteLine($"o的类型为{o.GetType()}"); break; } } |
if和switch语句,是使用模式匹配表达式的主要应用场景。
属性匹配模式(property pattern)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
static void PropertyPattern() { object input = "Hello!"; //对象属性匹配测试,后面可以引入,也可以不引入一个的新变量 if (input is string { Length: 5 } str) { Console.WriteLine($"str是一个字符串,其长度为5,转为大写之后{str.ToUpper()}"); } else { Console.WriteLine($"input不是一个长度为5的字符串"); } } |
在表达式中的“{”和“}”内部,可以放入多个对象定义的属性,当前版本只支持“判等”运算。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
static void PropertyPattern2() { object testObj = new MyClass() { Value = 100, Info = "Hello" }; //可以判断多个属性的值 if (testObj is MyClass { Value: 100, Info: "Hello" } myClassObj) { Console.WriteLine($"str是一个MyClass的实例,其属性值为:{myClassObj.Value}:{myClassObj.Info}"); } //可以使用逻辑连接符定义复杂的条件,前面所定义myClassObj2变量,可以用在后面的逻辑表达式中 if (testObj is MyClass { Value: 100 } myClassObj2 && myClassObj2.Info.Contains("o")) { Console.WriteLine($"str是一个MyClass的实例,Info属性值包容字符'o'"); } } |
When子句
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
static void WhenClause() { object testObj = new MyClass() { Value = 100, Info = "Hello" }; switch (testObj) { case MyClass obj when obj.Value < 0: Console.WriteLine("MyClass实例,其Value<0"); break; case MyClass obj when obj.Value >= 100: Console.WriteLine($"MyClass实例,其Value={obj.Value}"); break; //使用“discard”表示不引入额外的变量 case MyClass _: Console.WriteLine("MyClass实例"); break; default: Console.WriteLine("testobj不是一个MyClass实例"); break; } } |
在模式匹配表达式中,可以添加一个when子句,对本分支的 “判断规则”进行进一步的 “补充”。
when子句主要用于switch结构,
注意分支的排列顺序。如果不小心,有些分支可能永远执行不到。
Switch表达式(C# 8)
可以将整个switch结构转换为一个表达式,并将其结果传给一个变量: