C#学习笔记

1 .NET框架

.NET框架由三部分组成:

  • 编程工具:包括Visual Studio集成开发环境,调试器,.NET兼容的编译器等
  • CLR(Common Language Runtime,公共语言运行库):在运行时管理程序的执行,包括内存管理和垃圾收集、代码安全验证、代码执行线程管理及异常处理等
  • BCL(Base Class Library,基类库):包括通用基础类(文件操作、字符串操作等相关的类)、集合类(列表、字典、散列表)、线程和同步类、XML类。

以下图片说明了3个用不同语言编写的程序的完整编译时和运行的过程

NET框架

2. C#编程概述

一个简单的C#程序,这段程序会输出“Hi there!”

1
2
3
4
5
6
7
8
9
10
//告诉编译器这个程序使用了System命名空间的类型
using System;
//声明一个新命名空间,名称为Simple
namespace Simple{
class Program{
static void Main(){
Console.WriteLine("Hi there!")
}
}
}

在C#中,WirteLine相当于java中的println,Write相当于java中的print

1
Console.WriteLine("Three integers are {1}, {0} and {1}.", 3, 6);

以上语句将在屏幕上显示:
Three integers are 6, 3 and 6.

类型存储和变量

命名空间是一种把相关的类型声明分组并命名的方法。既然程序是一组相关的类型声明,那么通常会把程序声明在你创建的命名空间内部。

预定义类型

C#提供了16种预定义类型,包括13种简单类型和3种非简单类型

简单类型:
| 名称 | 含义 |
| ———– | ———– |
| int | 32位有符号整数 |
| uint | 32位无符号整数 |
| long | 64位有符号整数 |
| ulong | 62位无符号整数 |
| short | 16位有符号整数 |
| ushort | 16位无符号整数 |
| byte | 8位有符号整数 |
| sbyte | 8位无符号整数 |
| float | 单精度浮点数 |
| double | 双精度浮点数 |
|decimal|高精度小数类型|
| bool | 布尔型 |
| char | Unicode字符串 |

非简单类型:

  • object:所有其他类的基类
  • string:多个Unicode字符组成的序列
  • dynamic:在使用动态语言编写的程序集时使用

C#语言是静态的,但基于.NET的一些其他语言却是动态的,也就是说变量的类型直到运行时才会被解析。由于它们是.NET语言,所以C#程序需要使用这些语言编写的程序集。问题是程序集中的类型直到运行时才会被解析,而C#又要引用这样的类型并且需要在编译的时候解析类型。为了解决这个问题,有了dynamic关键字。

在编译时,编译器不会对dynamic类型的变量进行类型检查。相反,它将与该变量及该变量的操作有关的所有信息打包。在运行时会对这些信息进行检查,以确保它与变量所代表的实际类型保持一致性,否则将在运行时抛出异常。

用户定义类型

C#中有6种用户自定义类型

  • 类类型class
  • 结构类型struct
  • 数组类型array
  • 枚举类型enum
  • 委托类型delegate
  • 接口类型interface

3. 方法

类型推断和var关键字

var关键字不是特定类型变量的符号,它是从等号右边推断出的实际类型的速记。

1
2
3
4
5
6
//在下面的第一个声明中,var是int的速记
//第二个声明中,var是MyExcellentClass的速记
static void Main(){
var total = 15;
var mec = new MyExcellentClass();
}

使用var关键字有一些重要的条件:

  • 只能用于本地变量,不能用于字段
  • 只能在变量声明中包含初始化的时候使用
  • 一旦编译器推断出变量的类型,它就是固定且不能更改的

本地常量

用const修饰符来修饰(类似于java中的final)

常量和变量的语法除了以下两点外都相同:

  • 常量在类型之前增加关键字const
  • 常量必须有初始化语句,也就是说初始值不能在编译期确定。因此,它不能是某个对象的引用(但可以是null的引用),因为对象的引用是在运行时决定的。

参数

首先区分下形参实参的概念:

1
2
3
4
//以下函数的参数声明中,x和y均为形参
public void PrintSum(int x, float y){

}
1
2
3
4
//以下函数的调用中,5和someInt均为实参,实参的值用于初始化形参
PrintSum(5, someInt){

}

1.值参数

Java中的参数传递类型(值传递),即:值参数是把实参的值复制给形参,二者在栈中的不同位置。

  • 在方法被调用前,用作实参的变量a2已经在栈中了
  • 在方法开始时,系统在栈中为形参分配空间,并从实参复制值
    • 因为a1是引用类型,所以a1的值(即指向对象的地址)被复制,形参和实参都指向堆中的同一个对象
    • 因为a2是值类型的,所以值被复制,产生了一个独立的数据项
  • 在方法中,f2和对象f1的字段都被加上了5
  • 方法结束后,形参从栈中弹出

值参数

2.引用参数

对于引用参数,系统不会在栈中为形参分配新的空间,形参的参数名将作为实参的别名,指向相同的内存位置

  • 使用引用参数时,必须在方法的声明和调用中都使用ref修饰符
  • 实参必须是变量,在用作实参前必须被赋值
1
2
3
4
5
6
7
8
//方法声明中要使用ref修饰符
void MyMethod(ref int val){

}

int y = 1;
MyMethod(ref y) //使用y前必须赋值
MyMethod(ref 3 + 5) //会报错,因为引用参数作为实参必须是变量,不能是表达式
  • 在方法调用前,将要被用作实参的变量a1和a2已经在栈里了
  • 在方法的开始,形参名被设置为实参的别名。引用相同的内存位置
  • 在方法结束后,f2和f1的对象的字段都被加上了5

引用参数

对比将引用类型对象作为值参数和引用参数传递的两种情况:

  • 将引用类型对象作为值参数传递:如果在方法内创建一个对象并赋值给形参,将切断形参和实参之间的关联,并且在方法调用结束后,新对象将不复存在
  • 将引用类型对象作为引用参数传递:如果在方法内创建一个新对象并赋值给形参,会让实参也引用该新对象,并且在方法结束后该对象仍然存在。

3.输出参数

输出参数用于从方法体内把数据传出到调用代码,修饰符为out。和引用参数非常类似

和引用参数一样,输出参数的形参担当实参的别名,方法内对形参的任何改变在方法完成后通过实参变量都是可见的。

唯一和引用参数不同的是:方法内的代码在读取输出参数之前必须先对其写入

1
2
3
4
public void Add(out int outValue){
//以下这句会报错,因为输出参数outValue在方法中被读取前没有被赋值
int var1 = outValue + 2;
}

4.参数数组

参数数组允许0个或多个实参对应一个特殊的形参,修饰符为params

1
2
3
4
//形参inVals可以代表0个或多个实参
void ListInts(params int[] inVals){

}
  • 在参数列表中只能有一个参数数组,并且是列表中的最后一个
  • 由参数数组表示的所有参数必须具有相同的类型

参数数组在方法声明中需要params修饰符,而在调用时不需要(不同于引用参数和输出参数,它们在以上两个地方都需要修饰符)

可以有如下两种方式为参数数组提供实参:

  1. 用一个逗号分隔的该数据类型元素的列表,使用这种方法时,编译器做如下的事:
    • 接收实参列表,用它们在堆中创建并初始化一个数组
    • 把数组的引用作为形参保存在栈中
      1
      ListInts(10, 20, 30)
  2. 用数组作为实参
    在这种情况下,编译器会直接使用传入的数组,也就是说栈中的形参指向内存中intArray的位置
    1
    2
    int[] intArray = {1, 2, 3};
    ListInts(intArray);

    5.命名参数

    在使用命名参数时,需要在方法调用中包含参数名。而方法的声明无需任何改变
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    class MyClass{
    //方法中的参数声明一如平常
    public int Calc(int a, int b, int c){
    return a + b + c;
    }
    static void Main(){
    MyClass mc = new MyClass();
    int result = mc.Calc(c: 2, a: 4, b: 3);
    }
    }

    6.可选参数

    所谓可选参数就是在调用方法的时候可以包含这个参数,也可以忽略它。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    class MyClass{
    //b为可选参数,默认值为3
    public int Calc(int a, int b = 3){
    return a + b;
    }
    static void Main(){
    MyClass mc = new MyClass();
    int ro = mc.Calc(5, 6);
    int r1 = mc.Calc(5);
    Console.WriteLine("{0}, {1}", ro, r1);
    }
    }
    上述代码会输出11,8

只要值类型的默认值在编译的时候可以确定,就可以使用值参数作为可选参数。而只有在默认值为null的时候,引用参数才可以作为可选参数。

总结下来,一个方法的声明中,参数要按照必填参数、可选参数、params参数的先后顺序声明。

可以忽略最后一个可选参数,或者最后n个可选参数,但是不可以随机选择省略任意的可选参数,省略必须从最后开始。

参数类型总结:

参数类型 修饰符 是否在声明时使用 是否在调用是使用 执行
值参数 系统把实参的值复制给形参,二者在栈中位置不同
引用参数 ref 形参是实参的别名,二者在栈中位置相同
输出参数 out 在读取输出参数前必须对其写入,除此之外和引用参数类似
参数数组 params 允许传递可变数目的实参到方法

栈帧

在调用方法的时候,内存从栈的顶部开始分配,保存和方法关联的一些数据项。这块内存叫做方法的栈帧

栈帧保存如下的内容:

  • 返回地址

  • 为参数分配的内存

  • 各种和方法调用相关的其他管理数据项

    在方法调用的时候,整个栈帧都会压入栈。在方法退出的时候,整个栈帧都会从栈上弹出。

栈帧

4.类

类成员包括数据成员(保存数据)和函数成员(执行代码)
其中数据成员包括:

  • 字段
  • 常量(用const修饰,包括本地常量和成员常量,本地常量声明在方法内,成员常量声明在类中)

常量

成员常量表现的和静态量相似,但唯一不同的是,成员常量没有自己的存储位置,而是在编译时被编译器替换。此外,不能将成员常量声明为static。与const有着相同作用的是readonly,不同的是,const字段只能在字段的声明语句中初始化,而readonly也可以在构造函数中初始化。因此const字段的值必须在编译时确定,而randonly字段的值可以在运行时决定。

函数成员包括:

  • 方法
  • 属性
  • 构造函数、析构函数
  • 运算符
  • 索引
  • 事件

属性

属性是一组称为访问器的方法(set访问器为属性赋值,get访问器从属性中获取值)。它是类中的函数成员,因此不需为属性分配内存。

写入和读取属性的代码和访问字段一样。属性会根据是写入还是读取,来隐式地调用适当的访问器

属性通常和字段关联,一种常见的方式是在类中将字段声明为private以封装字段,并声明一个public属性用get和set访问器来控制对该字段的访问。和属性关联的字段成为后备字段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class C1{
private int TheRealValue = 10; //后备字段:分配内存
public int MyValue{ //属性:不分配内存
set{
TheRealValue = value; //设置字段的值
}
get{
return TheRealValue; //获取字段的值
}
}
}

class Program{
static void Main(){
//对属性的读和写如同对字段的读和写
C1 c = new C1();
Console.WriteLine("MyValue: {0}", c.MyValue);

c.MyValue = 20;
Console.WriteLine("MyValue: {0}", c.MyValue);
}
}

此外,属性也可以只有get访问器(只读属性),或者只有set访问器(只写属性)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class RightTriangle{
public double A = 3;
public double B = 4;
//只读属性,计算直角三角形的第三边
public double Hypotenuse{
get{
return Math.Sqrt((A * A) + (B * B));
}
}
}

class Program{
static void Main(){
RightTriangle c = new RightTriangle();
Console.WriteLine("Hypotenuse: {0}", c.Hypotenuse);
}
}

上述代码将输出5

索引器

可以认为索引器是为类的多个数据成员提供get和set访问器的属性。

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
class Class1{
private int Temp0;
private int Temp1;
//和属性不同的是,索引器有参数(索引参数),并且使用this而不是名称
//索引器声明
public int this [int index]{
get{
return (index == 0) ? Temp0 : Temp1;
}
set{
if(index == 0)
Temp0 = value; //value为set访问器的隐式变量
else
Temp1 = value;
}
}
}

class Example{
static void Main(){
Class1 a = new Class1();
//使用索引参数0或1读取数据成员
Console.WriteLine("T0: {0}, T1 : {1}", a[0], a[1]);
//使用索引参数0或1对数据成员进行写入
a[0] = 15;
a[1] = 20;
Console.WriteLine("T0: {0}, T1 : {1}", a[0], a[1]);
}
}

以上代码会输出:
T0: 0, T1: 0
T0: 15, T1: 20

5.继承

如果类OtherClass继承自SomeClass,则应按如下表示

1
2
class OtherClass : SomeClass{
}

一个类只能继承自一个基类,所有的类都是Object类的派生类

屏蔽基类的成员

虽然派生类不能删除它继承的任何成员,但可以用与基类成员名称相同的成员来屏蔽基类成员(如果是函数成员,则要求签名相同,签名指名称和参数列表,不包括返回类型)。此外还要使用new修饰符来告诉编译器我正在故意屏蔽继承的成员。

另外,即使派生类屏蔽了基类的成员,也可以使用基类访问表达式访问隐藏的继承成员。

1
2
3
4
5
6
7
8
9
10
11
12
13
class SomeClass{    //基类
public string Field1 = "Field1--In the base class";
}
class OtherClass : SomeClass{ //派生类
//使用new修饰符隐藏基类中的Field1字段
new public string Field1 = "Field1--In the derived class";
public void PrintField1(){
//访问派生类中的Field1,会输出"Field1--In the derived class"
Console.WriteLine(Field1);
//使用基类访问来访问基类中的Field1,会输出"Field1--In the base class"
Console.WriteLine(base.Field1);
}
}

使用基类的引用

1
2
MyDerivedClass derived = new MyDerivedClass();   //创建一个派生类对象
MyBaseClass mybc = (MyBaseClass)derived; //让基类引用指向派生类对象

对于如上代码,派生类的引用derived可以看到完整的MyDerivedClass对象,而基类引用mybc只能看到对象的MyBaseClass部分(只能看到基类成员)
使用基类的引用

另外,也可以使用基类引用调用派生类的方法,但要满足如下条件:

  • 派生类的方法和基类方法有着相同的签名和返回类型
  • 基类的方法用virtual标注
  • 派生类的方法用override标注
    在这种情况下,当使用基类引用(mybc)调用方法时,方法会被传递到派生类执行

注意:

  • 覆写(override)和被覆写的方法应该有相同的访问性
  • 不能覆写static方法和非虚(virtual)方法

当使用对象的基类引用调用一个覆写的方法时,方法的调用被沿着派生层次上溯执行,一直到标记为override的方法的最高派生版本。
如果在更高派生级别有该方法的其他声明,但没有被标记为override,那么它们不会被调用。

构造函数

构造函数初始化语句

两种形式:

  • 关键字base:指明使用哪一个基类的构造函数
  • 关键字this:指明使用哪一个当前类的构造函数
    以下构造函数使用了构造函数初始化语句,构造函数初始化语句指明了要使用第一个参数是string,第二个参数是int型的那个基类构造函数

当声明一个不带构造函数初始化语句的构造函数时,它实际上是使用了无参数的基类构造函数。

1
2
3
public MyDerivedClass(int x, string s) : base(s, x){

}

如下代码中的MyClass类包含一个有一个int型参数的构造函数,这个构造函数使用了同一个类中具有两个参数的构造函数,并为第二个参数提供了一个默认值

1
2
3
public MyClass(int x) : this(x, "Using Default String"){

}

如果一个类有好几个构造函数,并且它们都需要在构造对象的过程中执行一些公共代码。这时可以把公共代码提取出来作为一个构造函数,被其他所有的构造函数作为构造函数初始化语句使用。

访问级别

类有两种访问级别:

  • public:可以被任何程序集中的代码访问
  • internal:默认的访问级别,仅可以被自己所在的程序集中的类看到

类中的成员有5种访问级别:

  • 私有的(private):只能被自己类中的成员访问,不能被其他的类访问,即使是继承自它的类也不行
  • 公有的(public):所有的类都可以自由访问
  • 受保护的(protected):和private类似,唯一不同的是,它允许该类的派生类来访问
  • 内部的(internal):对程序集内部的所有类可见,对程序集外部的所有类不可见
  • 受保护内部的(protected internal):相当于internal与protected的并集,即对程序集内部的类可见,也对继承自该类的类可见。

抽象成员

类似于Java中的抽象方法。它使用abstract标记,并且必须是函数成员(方法、属性、事件、索引)。不能有实现代码块,抽象成员的实现用分号表示。即每一个抽象成员的声明后都要带一个分号

如:以下声明了两个抽象成员,一个名为PrintStuff的抽象方法和一个名为MyProperty的抽象属性

1
2
3
4
5
abstract public void PrintStuff(string s);
abstract public int MyProperty{
get; //分号代替实现
set;
}
  • 抽象类:只能被继承,不能用来创建实例,用abstract修饰符标注
  • 密封类:与抽象类相反,只能被用来创建实例,不能被继承。用sealed修饰符标注

语句

操作符重载

如果面对一个用户自定义的类或结构,运算符就会不知道如何取处理它。运算符重载允许用户自己定义C#运算符来操作自定义类型的操作数。

  • 为类或结构重载一个运算符x,可以声明一个名称为operator x的方法并实现它的行为(如operator +operator -等)。一元运算符的重载方法带有一个单独的class或struct类型的参数,二元运算符重载的方法带有两个参数,其中至少有一个是class或struct类型。
  • 声明必须同时使用static和public的修饰符
  • 运算符必须要是要操作的类或结构的成员

如下代码声明了LimitedInt类的两个重载的运算符:一个是加运算符,另一个是取负运算符

1
2
3
4
5
6
7
8
9
10
11
12
13
class LimitedInt Return{
public static LimitedInt operator + (LimitedInt x, double y){
LimitedInt li = new LimitedInt();
li.TheValue = x.TheValue + (int)y;
return li;
}

public static LimitedInt operator - (LimitedInt x){
LimitedInt li = new LimitedInt();
li.TheValue = 0;
return li;
}
}

标签语句

标签语句由一个标识符后面跟着一个冒号再跟着一条语句组成,它有如下的形式:Identifier: Statement。这条语句在执行时与只有Statement的语句相同,加一个标签的目的只是为了允许程序从其他位置跳转到这个标签所在的位置。

  • 因为标签有自己的声明空间,所以标签语句中的标识符可以是任意有效的标识符(可以与本地变量名相同)。
  • 标签的作用域仅在块内部

goto语句可以跳到它本身所在的块中的任何标签语句,或跳出到任何它被嵌套的块内的标签语句。goto Indentifier

数组

一维数组和矩形数组

1
2
3
4
5
6
int[] intArr1 = new int[15];   //声明一维数组
int[,] intArr2 = new int[5, 10]; //声明二维数组
int var2 = intArr[2, 3]; //从二维数组中读值

int[] intArr = new int[]{10, 20, 30, 40}; //初始化一维数组
int[,] intArr2 = new int[,]{{0, 1, 2}, {10, 11, 12}}; //初始化二维数组

交错数组

交错数组是数组的数组,与矩阵数组不同,交错数组的子数组的元素个数可以不同

1
2
3
4
5
6
//实例化顶层数组,不能在声明语句中初始化顶层数组之外的数组长度
int[][] Arr = new int[3][];
//实例化子数组
Arr[0] = new int[]{1,2,3};
Arr[1] = new int[]{4,5,6};
Arr[2] = new int[]{7,8,9};

foreach语句

注意:迭代变量item是只读的,不能修改。

1
2
3
int[] arr1 = new int[]{1,2,3};
foreach(int item in arr1)
Console.WriteLine("Item Value: {0}, item");

参考

  • Daniel M.Solis. Illustrated C# Fourth Edition [M]. 人民邮电出版社, 2013.