友友们好,本文为是 C# 入门的学习知识,包括笔者C#学习过程中的认识和心得。
这篇笔记整理花费一周时间,可以收藏拿来当作实际开发的 “划重点” 。由于内容中并不包含传统C语言本身的基础特性和基本语法,因此本文更适合有 C/C++ 基础的同学食用。或是有 C# 经验的小伙伴进行自查。
如果你对 C/C++ 感兴趣,或者在学习 C# 前需要想要了解大概的学习路线,可以参考我的另一篇博文:《C、C++、C# 我全都要!无痛学习经验分享》
以下部分内容属于笔者初学时期略微难啃一点的内容,帮你挑出来了。建议同学们重点照顾:
5、命令行输入输出:Write相关内容
6、数据格式转换:Parse法,把字符串转化为对应的类型(比较容易忘)
9、二维数组(C#不同于 C/C++ 的表示方法)
11、值类型和引用类型
“Xiaochou x1” 不会调用构造,“Xiaochou x1 = new Xiaochou()” 才会
14、可变长参数 params 关键字函数
21、“只外部获取,不外部修改” 的成员变量——成员属性(成员变量的加强版)
这个章节要注意名为 name 和 Name 成员变量的区别,文章正文会体现
name:受保护的普通成员变量
Name:对外可访问的成员属性“接口”
23、索引器(属于高级语法)
public Person this [int index]
27、扩展方法
static class Add {
public static int IntAddOne(this int thisval) {
return thisval + 1;
}
}
其中,对 Add 类有 static 要求,对 IntAddOne 有 public static 的要求。
其中的 this int, int 是扩充的那个类,任意变量名取什么都行。
29、分部类:partial,把一个类分成几部分声明 partial class Person
36、密封类 / 抽象类
笔者使用的是VS2019,实测2015以上版本也可以兼容本篇内容。
进入修改,安装.NET桌面应用 → .NET SDK,之后再打开VS2019
注意不是新建项=.=
是新建项目!项目!每个解决方案下可以新建多个项目
进入到如下示例程序,运行显示HelloWorld,则环境配置完毕。
ctrl K+C 注释
ctrl K+U 去注释
F12 跳转到 函数/类/命名空间 的声明
这个代码用来输出Hello World
引用了一个工具包 System,其中 System 是通过:
namespace System{ ... }
这种方式定义的一个巨大的工具包,内部是各种系统自带的函数的声明。
当我们想查看一个函数或类的定义,可以选择它,按 F12 进行跳转。
使用 region 指令,可以将一段代码折叠成一个独立的区域,这个区域可以包含多个代码块,例如类定义、属性、方法等。在 Visual Studio 中,可以通过单击左侧的减号来折叠或展开区域,也可以使用快捷键 Ctrl+M+M 来切换区域的折叠状态。
Console.WriteLine("Hello World!");
string sin = Console.ReadLine(); // 等效于 cin <<
Console.WriteLine("OK"); // 等效于 cout <<
Console.ReadKey(); // 监听一个按键消息。如果有任何按键立刻释放阻塞
Console.WriteLine(" Detected.");
数据类型转换的本质不仅仅是将相同长度的数据放在不同的容器中,而且是根据数据类型的范围和精度要求进行的。在C#中,数据类型分为值类型和引用类型。
通常情况下,值类型的数据(例如 int、double、struct 等)可以直接进行类型转换。而引用类型的数据(例如class、interface、delegate)不建议用强制类型转换,因为转换的结果可能引发空指针。标准的转换方式是通过容器 as 成其他类型,这点在后续进阶过程中会学到。
具体的转换方法:
隐式转换:由编译器自动实现的默认转换。
显示转换:
1)强转,同C语言强转方法(在C#中较少使用)
2)Parse法,把字符串转化为对应的类型
语法: vartype.Parse("str") , eg: int.Parse("123");
string inp = Console.ReadLine(); // 等于 cin
Console.WriteLine(inp);
Console.WriteLine("result is:" + (int.Parse(inp)+1));
//输入123字符串,Parse成int,返回这个输入数+1的结果
3)Convert 方法,普适方法,可以更精确乱转,还可以四舍五入
语法: Convert.ToXXX(data)
string和其他类型的互相转换:
string i = "6";
int a = Convert.ToInt32(i);
Console.WriteLine("result is:" + a);
float f = 1.233f; //注意C#要求必须写f作为小数后缀
string ret = Convert.ToString(f);
Console.WriteLine("result is:" + ret);
4)其他变量转字符串: .ToString() 大法
float f = 1.233f;
Console.WriteLine("result :" + f.ToString());
甚至于你可以写:
1.ToString()
这是因为 C#提供了一种简化的语法来调用 ToString()
方法
最基本的:
以下代码中,当 try 中的代码执行异常时,会跳出 try 代码,转而执行 catch 的代码。
在没有使用 throw() 的情况下,try中的异常一般不会导致程序异常关闭。
try{
这里写我们要执行的
}
catch{
这里写如果异常,那么捕获异常的代码
}
进一步的,我们可以使用进阶版的异常捕获代码。当发生异常时,exception e 可以被捕捉下来,编程者可以决定是打印输出或者存留到日志里。
try{
这里写我们要执行的
}
catch(exception e (用来捕获异常内容)) {
这里写如果异常,那么捕获异常的代码
throw; // 加上这句的效果是异常发生的时候抛出。
}
finally{
这句对不对都会执行,初步使用可以不用 finally
}
1)自动转换规则:如果一个string+int出现时,这个int 会自动 Tostring
str += 1; //不报错,结果是 str 的后面添1
2)字符串拼接规则
str += " " + 1 + 2 + 3; //结果是 123
str += 1 + 2 + 3 + " "; //结果是 6
str += 1 + 2 + " " + 3; //结果是 3 3
原因是 先计算运算符右边的,算完的部分会进行类型合并。X+string就是合并成string类型
3)Format拼接
string str = string.Format("我是{0},我今年{1}岁", a, b);
这部分定义的格式较为灵活,推荐大伙儿只掌握五种中的一种方法。
一维数组:
int[] arr1 = new int[] { 1, 2, 3 };
int[] arr2 = { 1, 2, 3, 4 };
//这两种都可以,感觉比较自由,注意不用C风格就好了
二维数组:(注意只能用new进行预分配空间)
int[,] arr2 = new int[2,2];
arr2[0,0] = 1;
C#在一维和二维数组的定义上采用了与C和C++不同的形式,主要是为了提供更好的类型安全性和可读性,并简化语法。
// 获取数组的个数
int[] arr = new int[] { 1, 2, 3 };
int len = arr.Length;
// 获取数组某个维度的个数,入参dimension,一般用于高维数组的kkjk
int[] arr = new int[] { 1, 2, 3 };
int len = arr.GetLength(0);
值类型:基本数据类型+结构体,值类型保存在栈空间里,在内存中的角色相当于“房间”
引用类型:类+数组。保存在堆空间,在内存中的角色相当于 “门牌号”
引用类型的变量在 arr2=arr1 之后,由于操作的是“地址引用的”
在改arr2[x]的时候,再去读arr1会发现改变了。
这是因为操作的具体内存是一个地址。
特殊的引用类型:string
其实在使用感受上,string认为是值类型其实也可以。
在申明string类型时候,系统会自动用临时变量分配一个string容器,不用担心门牌号问题。
遇到问题了怎么办?可以利用调试功能逐步看内存。
调试的功能比较庞大,初学我们只学习使用最简单的办法
调试四部曲:1)设置断点 2)运行 3)关注左下角自动/监视窗口 4)F10逐步
13、引用型变量的函数操作
下面这个函数在C#里,由于数组是引用规则,作为入参其结果居然会变(这点直观上与C/C++很大不同,但其实内因是一样的)
// Change函数
static void change(int[] arr)
{
arr[0] = 233;
return;
}
...
int[] arr1 = { 1, 2, 3 };
change(arr1);
//这个操作的结果是会把 arr1 的首项值 = 233;
本质原理:
图-引用类型的传参过程
笔者初学时,一时tm难以接受!经验证,值传递型不会有这个问题。
以下是值传递的验证代码:
static void change(int[] arr) {
arr = new int[] { 233, 233, 233 }; //不直接修改,而是new了一个!
return;
}
...
int[] arr1 = { 1, 2, 3 };
change(arr1);
由于在函数内部重新对arr分配了一个新的 int[ ] 空间,导致原有的关系被破坏
这个和 int *p = malloc() 有所区别,但其内部设计思想类似。
ref 等效于C++中函数参数的引用,相当于&,需要注意的是函数定义和使用都要加ref。eg:
static void change(ref int value)
{
value = 233;
return;
}
...
int origin = 0;
change(ref origin);
ref 和 out的区别:out的传入变量必须在内部赋值,就是说函数 默认会对out参数修改值
ps:你要用out,就一定要改他; ref 的变量则是必须要经过初始化。
必须是数组类型注意
int func(int a, char b, params int[] c)
然后params只能有一个,且放在最后
如果入参一样,尽管返回值不同仍然会有错误
注意一点,ref int 和 int 算是两种不同的入参类型,但是 ref int 和 out int 算是一种。
需要注意的几个点:
结构体:
MyStruct stu1 = new MyStruct { "qingxiu", 23 }; // 这种初始化方法不可用
MyStruct stu1 = new MyStruct {age=23}; // 这种对劲儿,需要赋值时说明属性
MyStruct stu1 = new MyStruct(); // 这种也对劲,注意有一个(),初学者很容易忘
清晰的逻辑关系,更高的效率、复用性和开发效率。
这部分与编程的语言学习关系不大,有兴趣的同学自行检索即可。
这部分知识多归纳于 软件工程 和 设计模式 这两门课程,分布于国内大学的本科和研究生阶段。感兴趣的同学们可以从 MOOC 上补充这些知识。
+ :this() 继承无参构造函数的代码,以完成复用
同样的,可以是:有参构造函数 + :this(age+1) //继承有参构造函数的代码,且入参自定
eg:
class Person
{
private string name = "Default";
public Person() {
Console.WriteLine(this.name + " Created.");
}
public Person(string Name):this(){
this.name = Name;
Console.WriteLine(this.name + " Created.Append");
}
}
最下方的 Person(string Name) 构造函数会继承 Person() 中的内容。
调用方法如下:
Person p1; // 注意,这只是申明变量,并没有new操作,所以不会调用构造
Person p1 = new Person(); // 调用无参构造
Person p1 = new Person("blabla"); // 调用无参构造+有参构造, 因为上文追加了:this()
垃圾的定义:没有任何被引用的内容,需要被回收和释放
垃圾回收的过程:遍历每代堆上分配的所有对象。
从1代开始存,如果当前第n代存满就会触发GC,回收&重排前1~n代。
具体机制:
实际上是多级优先反馈队列的一个应用。GC运用了局部性原理。最终经常被引用的变量会升代,而大内存一开始就会在2代上。
手动触发GC:GC.Collect();
“只外部获取,不外部修改” 的成员变量(属于高级语法)
背景:3P(private、protected、public)方式不便在外面定义一种约束。
private: 仅内访问
protected: 仅内、内子类访问
public: 内外访问
然后并没有那种 “仅外访问” 的,为了便于灵活的权限管理,产生了成员属性的需求。
成员属性的几个tips:
1)关键特征:对私有成员变量的专用 get{} 和 set{}。 这两个东西叫做“访问器”。
2)value关键字:代表入参
3)get和set可以只实现一个,二者也可以声明访问权限。
4)get和set可以被设计成 加密、控制访问权限等应用。
基本操作:
class Person
{
private string name = "Default";
public string Name{
get {
return name;
}
set {
name = value; // value关键字:简化代表为入参
}
}
....
调用:
p1.Name = "Yooo!";
Console.WriteLine(p1.Name);
扩展操作:存储加密
get{
return name - asdafs; // 这样在内存里就找不到存入的值了
}
set{
name = value + asdafs;
}
扩展操作:把变量的访问权限设置为【单纯只想让外部访问,而不可修改】
public string Name{
get{
return name;
}
private set { }
}
或者简写为:
public string Name
{
get;
private set;
}
效果:
可以用以下代码表示:
class Person{
private string name;
private Person[] friends;
}
让类对象可以像数组一样访问其中元素,观念上类似于重载了 "[ ]"
注意这里说是观念上。实际上C#不支持中括号的重载,并不能像C++那样广泛的重载。
class Person
{
public string name = "Default";
private Person[] friends;
public Person this[int index]
{
get {
return friends[index]; // p[0]:定义为第一个朋友
}
set {
if (friends == null) {
friends = new Person[10];
friends[0] = value; // value:入参
}
}
}
}
调用方法:
p2.name = "Amy";
p1[0] = p2; // 将p1的第0个朋友设置为 Name="Amy"的人
tips:
1) public Person this [int index] // 这个格式是固定搭配:public 返回类型 this [params]
2) public Person this [int index, int age] // 索引器可以重载
C#中的关键字 static,较为本质的理解可以说是 “将对应的成员放进静态区,从而在程序启动的开始进行加载”。
如果读者恰好学习过操作系统,也可以把 static 的成员想成是 “程序运行之初就已经分配好内存” 的成员。之所以可以这么理解,是因为static成员在被读取加载之后便没有办法动态操作,也不可以通过GC进行回收。
实际上,实例化的对象并不拥有静态成员,静态成员的所有者是他所在的类。我们通常把这部分变量直接用类名点出来使用,而不需要实例化对象!
static 变量会在程序运行时,由程序直接分配内存空间,二者同生共死,全生命周期
static 变量特性:1)全局性 2)直接 “.” 访问 3)程序运行时始终在特定内存段。
eg : 在 class TestClass 中,可以有如下变量:
1)
静态成员变量: static public float PI=3.1415926f; 可以 TestClass.PI
普通成员变量: public float PI=3.1415926f;
2)
静态成员方法: static public void TestFunc(); 可以 TestClass.TestFunc()
普通成员方法: public void TestFunc();
注意:static Func() 之中,不能使用 普通成员变量(非常重要,要懂原理)。因为 static Func() 在程序运行时就拥有了自己的内存,而普通成员变量必须进行实例化后才被分配内存。所以,是生命周期的问题导致了这个特点,实在要用就先实例化new一个。
同样,static 变量不会被 GC!静态分配会一直占着内存,分配大块内存要慎用static。
static 变量作用:1)常用于唯一量 2)想绕过实例化给别人用
static 方法的典型例子:1)不会被改变,且定义唯一的数学公式等
具有很多相同的性质
1)不可实例化
2)具有唯一性
3)静态构造函数:第一次使用时调用,之后不再调用
eg:Console
可以将一个 非static 的类中已有的方法进行外部拓展
比如可以给 Int32 类加一个你自己的方法。
特点:一定是个static类中的static函数,并且第一个参数是this修饰
class Person // 其中Person类没有任何除构造以外的成员方法
{}
// 定义扩展方法
static class AddFunc
{
// 意在对 Person 进行扩展
public static void Func(this Person obj, int val)
{ // obj:名字可以乱起,调用时是自动传入的,代表实例化的对象
Console.WriteLine("AddFunc:" + val);
}
}
// 调用
Person p1 = new Person();
p1.Func(233);
在C#中,扩展方法必须声明为 static 才能正常工作。这是因为,扩展方法是一种静态方法,它不依赖于类实例,而是在整个应用程序中都可以使用的方法。
简言之,这个方法可以在不创建类的实例的情况下直接使用,如:1.ShowIntValue();
这里的1不需要我们进行 new int 操作,拿来就可以用。而且是在整个exe中使用。
注意,operator重载必须要将重载方法设置为类内static,且有返回值。
以下代码示例将二位数 (x,y) 添加相加规则:
class Point //
{
public int x;
public int y;
public static Point operator +(Point p1, Point p2) //记住格式!
{
Point temp = new Point(0, 0);
temp.x = p1.x + p2.x;
temp.y = p1.y + p2.y;
return temp;
}
public Point(int x, int y)
{
this.x = x;
this.y = y;
}
}
//调用如下:
Point po1 = new Point(1, 1);
Point po2 = new Point(3, 4);
Point po3 = po1 + po2;
Console.WriteLine("{0}, {1}", po3.x, po3.y);
C# 不能重载的:[ ] && || . ?: () =
在 C# 中,可以为一个类实现多个运算符的重载,包括新的运算符。但是,为了使这些运算符重载生效,必须确保它们被声明为 public 和 static。
之所以用 public,是为了确保这些运算符重载被其他类访问和调用。public 访问修饰符告诉编译器,这些运算符可以从任何地方访问,而不仅仅是从类的内部访问。如果运算符不是 public,那么只能从类的内部访问,这会导致编译错误。
之所以用 static,是为了确保这些运算符重载生效。static 访问修饰符告诉编译器,这些运算符不是实例化的,而是类级别的运算符,可以在整个应用程序中使用。如果运算符不是 static,那么它们只能在类的内部使用,这会导致编译错误。
怎么理解呢?其实很简单,我们用的是 p1+p2,而不是类似 p1.+() 以及 p1.+ 这种错误形式。
很显然p1+p2这种写法,并不属于通常用点的方式进行链接式调用。
这种 p1+p2 的可行的前提,肯定是在程序运行之初就要支持的。
内部类:
调用:Person.Body.ShowBody();
ps:就是类中类,使得两个类的从属关系显示的更为亲密
class Person
{
public string name = "Default";
private Person[] friends;
public class Body{
public static void ShowBody(){
Console.WriteLine("ShowBody.");
}
}
}
分部类:
关键词是 partial,把一个类分成几部分声明
属于不同的模块化子系统,然后不得不这样。
总之弹幕有人说这个很有用,先mark一下。
partial class Person{
public string name;
public int age;
}
partial class Person {
public string nation;
}
class PersonMan : Person {
public string nation;
}
C#的继承:只能有唯一父类;C++的继承则可以有多个父亲;
所以有个梗,在C++中,可以有:class 吕布 : 丁原,董卓
任何父类出现的地方,子类都可以替代。优秀的设计应该是:用父类容器 装载子类对象。
eg:
Person p1 = new Student();
Person p2 = new Teacher();
eg:
GameObject[] objects = new GameObject[] { new Boss(), new Monster() ,\
new Player() };
遍历这个数组,可以用is和as来进行不同类型的对象的处理。
is和as:对象的类型检查与转换(服务于里氏替换原则)
is:(返回值是bool型)
eg: if (p is Player) ...
as: (返回值:转换成功时返回指定类型对象,转换失败时返回null)
eg: Player p = object as Player
常规操作——二者配合使用:
// 前置定义
class PersonMan : Person{
public int len;
}
class PersonWoman : Person{
public int dep;
}
Person p1 = new PersonMan() { name = "A", age = 18, len = 18 };
Person p2 = new PersonWoman();
if (p1 is PersonMan) { // 判断p1是否属于PersonMan类,如果是,则...
PersonWoman p3 = p2 as PersonWoman; // 令p2转换成PersonWoman类型
Console.WriteLine("Show:" + p3.name + " " + p3.dep);
}
如果转换后的类型与目标类型相同,则转换成功。当Person有子类PersonMan时,一个PersonMan既是PersonMan,又是Person
先调用父类构造,再调用子类构造
由于默认调用是父类的无参构造,约定父类的无参构造必须存在!(因为默认找无参构造)
如果想默认调用有参构造,需要用 base 关键字,这个时候允许没有声明父类无参构造
eg 伪代码:
class {
func() : this(可传参) // 去找对应参数的构造函数
func() : base(可传参) // 去找对应参数的父类构造函数
}
// 申明类和构造函数
class Father
{
public Father() { // 默认调用的无参父类构造函数
Console.WriteLine("Father NULL");
}
public Father(int i) {
Console.WriteLine("Father:" + i);
}
}
class Son : Father
{
public Son() { }
public Son(int i) : base(i) //base是Father(),base(i)是Father(i)
{
Console.WriteLine("Son: i");
}
public Son(int n, int m) : this(n) // this(..) 都是指自己的构造
{
Console.WriteLine("Son: n, m");
}
}
// 调用:
Son s = new Son(2, 3);
--控制台打印结果:
Father:2
Son: i
Son: n, m -解析:new Son(int, int) 的操作:1、先构造父类
2、再构造 this(int)
3、最后构造自己
简言之,就是从老祖宗开始,逐步往各后代去调用
object对象由于是引用类型,他被设计成可以装任何种类对象的容器。
注意:object不能随便强转或者as,如:object w = (Son)f; 这种语法正确,但会出问题。
object val = 1.23f; // 装箱
Console.WriteLine(val);
float v = (float)val; // 拆箱
引用类型:建议永远用as去转换,而不是强转:
object NewArray = new int[10];
int[] ar = NewArray; // 这句代码会报错,因为不能将object型隐式转换成int[]型
应该这么做:
object NewArray = new int[10];
int[] ar = NewArray as int[ ]
这里一个自己的小tip:
new和as的区别:new一定是用来分配内存的,而as是用来转换类型的
值类型用引用类型存储,保存地点可以由栈迁移到堆
好处:不确定类型时,可以方便参数先行存储和转换
缺点:内存迁移带来性能损耗
eg:object [ ] 可以在不知道成员是什么类型时暂时 “吃下一切”,可以用来装任何类型。
but,还是尽量少用,因为性能损耗和设计规范都有点问题。
sealed类:让类无法再被继承(断子绝孙类,可以保证安全类和规范性)
sealed函数:该函数不能被override(不可被重写)
背景:我们试图解决一个问题:
class Father{
public void Speak() {
Console.WriteLine("Father");
}
}
class Son : Father{
public new void Speak()
{
Console.WriteLine("Son");
}
}
当进行以下语句时,会有不同的表现:
Father s = new Son();
s.Speak(); // 执行Father的Speak函数,因为使用的是Father容器
(s as Son).Speak(); // 执行Son的Speak函数
这对继承产生了一定的困扰,为了避免继承合逻辑,我们之后引入了 virtual, overide
virtual 虚函数:用来给子类重写的。
override:声明重写,然后补全这个函数的时候可以自动补全成带有一个base.Speak() 的子类重写函数。
class Father{
virtual public void Speak() {
Console.WriteLine("Father");
}
}
class Son : Father{
public override void Speak(){
base.Speak(); // base代表父类,可以用base保留父类的行为
}
}
调用:
当我们再进行
s.Speak();
这个操作的时候,虽然s还在使用Father的容器,但是其Speak函数已经被override,所以会执行Son.speak() 方法
一类class的统称,如:abstract class Things.
现实世界中不存在一类东西叫做:Things,但是我们可以把他们以后通过不同类继承出来。
这么做类似“蓝图”:
1、抽象类就只负责声明应该出现的具有共性的属性和方法,但不做具体实现
2、抽象类不可以被实例化
eg:
//抽象类
abstract class Father{
//抽象方法(只能写在抽象类里,且必须是public的,因为以后必须override)
public abstract void Speak(); // 不用写函数体,类似纯虚函数
}
namespace 具有以下特点:
1、像是工具包;相比之下 ,class像工具
2、可以分开写诶,像是之前那个分部类,可以写成好几段。
3、命名空间必须通过using引用或者点出使用,不然即使在同一个文件里一样会找不到。
4、可以嵌套:
namespace 工具包{
namespace 螺丝 { ... }
namespace 螺母 { ... }
}
引用时可以是:using 工具包.螺母;using Game.UI
5、namespace的类默认是internal(只能在程序集中使用),而不是private,要注意!
笔记还有部分适读性上的小毛病,目前赶着上新,待我有空一定来修!
友友们一起加油 owo