C#是微軟開發的簡單、快捷、通用的物件導向程式設計語言。C#語言被廣泛應用於微軟.NET應用程式的開發。2022年,微軟整合了原來半死不活的Xamarin.Forms,正式釋出了.NET MAUI多平台開發Framework。.NET MAUI在官方和社區的雙重支援下,已經能夠做到全端運行。目前已經包括了iOS、Android、Windows、macOS、Samsung Tizen、Linux(社區)的支援等。
這篇文章不建議程式設計初學者來看,需要你有一定的C++和Java程式設計經驗。
.NET安裝和IDE選擇
儘管.NET是由微軟釋出的框架,肯定在Windows上開發要來得更好,但無奈筆者是一位忠誠的蘋果fans,手上只有一台MacBook Pro。但是沒有關係,我們仍然可以在這上面正常安裝.NET的環境,因為.NET是全平台通用的。
.NET安裝器官方下載:https://dotnet.microsoft.com/en-us/download。筆者寫這篇文章的時候版本是8.0。
按照安裝器的步驟去安裝即可。
因為我們是要聚焦.NET MAUI開發,所以我們尚需安裝MAUI套件。打開你的終端機,首先檢查電腦上安裝的dotnet:
1 | dotnet --version |
然後使用root權力安裝.NET MAUI套件:
1 | sudo dotnet workload install maui |
關於IDE的選擇,如果你使用的是Windows,那麼我自然是推薦你使用微軟的官方工具Visual Studio。
但是如果你和我一樣使用的是Mac,雖然也有Mac版本的Visual Studio可以選,但是Mac版本的軟體將會在2024年8月份徹底失去支援,所以這時候我一定不會推薦你使用它。那麼在Mac下,我推薦使用Rider,這是由大名鼎鼎的Jetbrains公司開發的專為.NET開發者打造的IDE。喔對了,作為開發人員,我也是Jetbrains家族的無腦fans喔!
言歸正傳啦。
這篇文章主要講的是C#的文法基礎,因此我們暫時用不到MAUI,我們安裝好後就把它放到一邊就好。我們打開Rider,創建一個Console App的Solution即可。
C# 程式結構
C#程式檔的副檔名為.cs
。一個C#程式主要包括以下部分:
- 命名空間(Namespace)的聲明
- 一個class
- Class 方法
- Class
- Main方法
- 語句、表達式、註解等
有沒有覺得很熟悉?好像C++和Java某天晚上喝醉了⋯⋯
的確,C#借鑒了許多C++和Java的設計理念,像我們這樣的C家族程式設計師會很快入門。
我們來看一段最簡單的C#程式:
1 | using System; // 使用System命名空間 |
像啊!太像了!這簡直就是C++和Java的完美愛情結晶。
我們來看一下這段程式碼:
using System;
:使用System
命名空間。關於命名空間,C++開發人員應該很熟悉,Java開發人員的話,你可以暫時將其類比於我們的“套件”(package)。之後會有詳細的介紹。class HelloWorld
:這個應該都不會陌生,定義型別。因為C#是物件導向的,所以和Java十分相似。static void Main(string[] args)
:真的,我哭了。這和Java有什麼區別?甚至你真的能在前面加上public
關鍵字。主函式,程式的唯一入口。Console.WriteLine()
:終端機列印語句。
C# 資料類型
收收心思。儘管C#和Java的確很像,但是畢竟是不同的兩門程式語言。所以我們還是忍耐學下去。
C#的資料類型分為Value和Reference兩個種類。Value類型,包括傳統的bool
(布林)、byte
(8位元無符號整數)、char
(16位元Unicode字元)、decimal
(128位元精確的十進位值,28-29有效位數)、double
(64位元雙精度浮點)、float
(32位元單精度浮點)、int
(32位元有符號整數)、long
(64位元有符號整數)、sbyte
(8位元有符號整數)、short
(16位元有符號整數)、uint
(32位元無符號整數)、ulong
(64位元無符號整數)、ushort
(16位元無符號整數)這幾種類型。都是十分常見的類型,我就不做解釋。
Reference類型等同於Java中的Reference類型,不包含變數的實際資料,而包含變數的引用。C#內建的Reference類型一共只有三種:object
、dynamic
和string
。
object
地位等同於Java中的Object
型別,是C#中所有型別的終極祖宗型別。其他所有型別都是object型別的子型別。
dynamic
動態類型變數,可以接受任何型別的資料,也可以變更為任何型別。所以別再爭論到底動態型別系統還是靜態型別系統更好了,小孩子才做選擇!
string
C#延續了C家族語言中字元和字串的表示方法,即字元使用單引號''
、字串使用雙引號""
包圍。
不同的是,除了常規的一些玩法,C#還引入了另一種字串定義方式:@""
。
使用@""
定義的字串可以自動將字串中的跳脫字元恢復到普通字元,而不需要再次跳一下。例如:
1 | string str = @"\t\n\\"; |
此外,這種方式定義的字串可以任意換行,換行字元等都算字串的長度。例如:
1 | string str = @"<script type=""text/javascript""> |
除上述的各種資料類型外,C#還有一個重要類型,也是C家族語言的靈魂,你們的指針類型!關於指針類型,具體的情況下面再說。
C# 類型轉換
有隱形轉換、強制轉換、方法轉換三種方法。
隱形轉換是指將一個較小範圍的資料類型轉換為較大範圍的資料類型時,編譯器會自動完成類型轉換,這些轉換是C#預設的以安全方式進行的轉換,不會導致資料遺失。例如,從小的整數類型轉換為大的整數類型,從衍生類別轉換為基底類別。
1 | byte a = 1; |
強制轉換和所有C家族一樣,只需要在變數值之前加上(<type>)
即可。當然,如果無法轉換的話,編譯器會報紅。
C#還內建了一些方法用來進行類型轉換,與Java類似,比如ToString()
、ToInt32()
等等。用法和Java相同。
C# 判斷、迴圈
C#的判斷語句和迴圈語句幾乎和Java、C++沒有任何區別。需要注意的是C#也支援foreach
迴圈,語法稍有不同:
1 | foreach(item in <variables>) { |
除此之外,if-else
判斷、switch-case
判斷、三元運算判斷、while
迴圈、for
迴圈、do-while
迴圈的用法都是完全相同的。
C# 封裝
封裝是物件導向程式設計的三大核心概念(封裝,繼承,多型)之一,概念我們都已經十分熟悉了,反映到C#中,大約只需要瞭解一下訪問修飾字元了。
訪問修飾字元,在C#中包括public
、private
、protected
、internal
、protected internal
六種。
這和Java中也比較類似,但是由於C#和Java在程式結構上的不同,這些字元的作用範圍不一定相同。
我們先來看相同的幾個:
public
:可以被任意外部型別訪問。private
:只有一個型別中的函式可以訪問,即便是型別的物件也不能夠訪問。如果一個變數或方法沒有使用任何修飾元,則預設使用private。protected
:僅限於本型別和子型別可以訪問。所不同的是,Java中除了該型別和子型別之外,還確定了同一個package中的其他型別可以直接存取protected的物件,C#由於沒有package的概念,因此只有該型別和子型別可以存取。
接下來是C#獨有的internal
和protected internal
。
要完全理解這兩個概念,我們首先要理解一個在C#中的基本概念——組件(Assembly)。不知道大家有沒有發現,在Rider中,我們剛開始創建的叫一個Solution(解決方案)而不是常見的叫Project(專案)。建立下來的Solution結構大概是這樣的:
當我們在最上面的TestConsole Solution上面按下滑鼠右鍵,你會驚恐的發現,居然有一個New Project選項。
是的,在C#或者說.NET的結構中,居然有比Project還高一級的結構!
那麼言歸正傳,什麼叫Assembly呢?簡單來講,一個Solution下面的每一個Project都叫一個Assembly。根據Microsoft官方的定義,Assembly有如下的特點:
- 組件會實作為 .exe 或 .dll 檔案。
針對以 .NET Framework 為目標的程式庫,您可以藉由將組件放進全域組件快取 (GAC),在應用程式之間共用組件。 您必須先為組件設定強式名稱,才能將其放進 GAC 中。 如需詳細資訊,請參閱強式名稱的組件。 - 系統只會在需要時才將組件載入到記憶體。 若系統不需要組件,則不會執行載入程序。 因此在較大型的專案中,組件可提升資源管理效率。
- 藉由使用反映,您能以程式設計方式取得組件的相關資訊。 如需詳細資訊,請參閱反映 (C#) 或 Reflection (Visual Basic) (反映 (Visual Basic))。
- 您可以使用 .NET 和 .NET Framework 上的MetadataLoadContext類別來載入組件並進行檢查。 MetadataLoadContext 會取代Assembly.ReflectionOnlyLoad 方法。
好,理解完了Assembly,我們繼續來看internal修飾元。internal
修飾元表示“組建內可訪問”。而protected internal
表示允許在本型別、派生型別(不一定要在同一個Assembly)、包含該型別的組件中訪問。
C# 方法/函式
在C#中的函式定義方法和其他C家族語言完全相同。不同的是,C#函式的引數遞送可以透過三種方式進行——值、引用、釋出。
這很類似於C語言中的傳值和傳址的概念。
值引數只將引數的值傳送給函式,函式中對形式引數的任何改變都不會影響實際引數。
1 | // 定義 |
而引用引數則不同。引用引數相當於拷貝了一份實際引數的引用,在函式中對形式引數的改變都會影響到實際引數的真實值。
1 | // 定義 |
釋出引數是一個絕無僅有的設計,它以巧妙的方式允許函式回傳多個值。
1 | // 定義 |
C# 空類型和合併運算子
類似於Java和Swift中的Optional類型,表示當前變數要麼是一個所定類型的值,要麼是一個null。在C#中,空類型使用?
字元定義:
1 | int? num = 1; |
使用合併運算子??
來為空類型變數確定一個為空時的預設值,以防該變數為空對程式造成的壞影響。
1 | int? num = 1; |
C# 陣列與集合
陣列Array是包含相同類型變數的固定長度的存儲單元。關於Array的定義,和Java完全相同,不過多闡釋。
需要注意的是,陣列可以用作函式的引數。當函式的引數個數不固定時,可以使用陣列:
1 | int ChangeA(params int[] a) |
這樣就有了可變引數了。
C# 中的集合有這麼幾類:
型別 | 描述 | Java對照 |
---|---|---|
ArrayList | 動態陣列,一個可以調整大小的陣列 | ArrayList |
Hashtable | 哈希表,鍵值對存儲。 | Map |
SortedList | 排序列表,是前兩種的集合,可以使用鍵訪問或使用索引訪問 | SortedMap |
Stack | 堆疊,後進先出的資料格局 | Stack |
Queue | 隊列,先進先出的資料格局 | Queue |
BitArray | 點陣列,用於存儲二進位資料的陣列 | BitArray |
這些集合類型所有的內建方法和Java十分相似,在此也不再贅述。
C# 結構體、枚舉
C#中的結構體被稱為小型別。其跟型別不同的是,結構體比較簡單,也比較輕量。相應的也會有一些功能上的犧牲。比如結構體無法進行繼承,也無法被繼承,無法被標記為abstract、virtual和protected。結構體也不能有零引數的構造子。
1 | // 定義 |
枚舉enum也是完全熟悉的用法,不過多闡述。
C# 型別
終於,我們抵達了物件導向的核心——型別。C#的型別幾乎和Java沒有任何區別,唯一需要注意的一點叫做解構子。這個概念在Java中很少用到,但在C++中比較常用。
解構子是一個特殊的成員函式,和構造子對應。它用於在物件被銷毀之前自動執行指令,比如關閉資料庫連線,釋放記憶體等,就可以使用解構子。
解構子在C#中以~<ClassName>() {}
被定義。
1 | class Book() { |
C# 繼承
物件導向三大概念之一。其概念和Java的繼承沒有很大差別,但是還是有不少細節的差距:
- C#的繼承符號使用的是
:
,而不是extends
。 - 使用
base
來使用父型別的和方法,而不是super
。 - 需要複寫的成員需要在父型別中以
virtual
標之,否則不能夠被複寫。使用override
來在子型別中複寫virtual成員。
1 | class Parent |
- 父型別中使用
abstract
標示的成員必須被複寫。
介面繼承
和Java相同,對型別的繼承只能是單一繼承,然而我們可以透過對介面繼承來實現多繼承。
介面一樣用interface
來定義,繼承的時候只需要class <ClassName> : <Interface1>, <Interface2>
即可。
與Java相同,介面繼承也必須完全實現介面中的方法,包括介面從其他介面繼承來的方法。
C# 多型
在Java講多型的時候,我們會有講到這樣一個例子:每一種動物都會吃飯,都會叫,都會跑。但是小貓小狗會有不同的動作來吃、叫和跑。所以我們可以使用一個抽象出來的介面,然後不同的動物實作這個介面來實現不同形式的動作。
C# 中的多型分為靜態和動態。靜態多型特指函式多載和運算子多載;動態多型則是透過抽象型別和虛函式實現的。
靜態多型
函式多載
有Java基礎,則十分容易理解:
1 | using System; |
運算子多載
C# 中的運算子也可以看作是特殊的函式。因此我們也可以在型別中特別多載適用於本型別的多載運算子。
1 | public static Box operator+ (Box b, Box c) |
以上運算子多載函式,實現了運算子+
的多載。
動態多型
有兩種情況:型別抽象或者型別不抽象。
當型別抽象的時候,沒有什麼特別說明的。抽象的型別,抽象的函式,一切都是自然而然的。
當型別不抽象的時候,若想要其中的某個函式被複寫,需要使用關鍵字virtual
來將其定義為一個虛函式。
1 | public class Shape |
C# 命名空間
命名空間的設計目的是提供一種讓一組名稱與其他名稱分隔開的方式。在一個命名空間中聲明的類別的名稱與另一個命名空間中聲明的相同的類別的名稱不衝突。
我們舉一個電腦系統中的例子,一個資料夾(目錄)中可以包含多個資料夾,每個資料夾中不能有相同的檔案名,但不同資料夾中的檔案可以重新命名。
命名空間的定義
使用namespace
來定義命名空間:
1 | namespace namespace_name |
呼叫命名空間中的函式或變數,使用.
操作子:
1 | namespace_name.item_name; |
using
using
表示程式使用的是給定命名空間中的名稱。例如,我們在程式中使用System命名空間,其中定義了型別 Console。我們可以只寫:
1 | Console.WriteLine ("Hello there"); |
或者也可以寫完全限定名稱:
1 | System.Console.WriteLine("Hello there"); |
巢狀命名空間
命名空間可以寫成巢狀,依然適用.
操作子呼叫函式或者變數:
1 | using System; |
C# 預處理器
在C語言和C++中,我們已經十分熟悉在程式開始之前使用#include<>
去為程式添加一些head檔案,也有使用過#define
去進行宏定義的操作。這些都叫做預處理器。
作為C++的參考程式語言,C#幾乎照搬了這一點。
C#中的預處理器有下面幾類:
預處理器 | 描述 |
---|---|
#define | 定義為一系列成為符號的字元 |
#undef | 它用於取消定義符號 |
#if | 它用於測試符號是否為真 |
#else | 它用於建立複合條件指令,與#if 一起使用 |
#elif | 它用於創建複合條件指令 |
#endif | 指定一個條件指令的結束 |
#line | 它可以讓您修改編譯器的行數以及(可選地)輸出錯誤和警告的檔案名稱 |
#error | 它允許從程式碼的指定位置產生一個錯誤 |
#warning | 它允許從程式碼的指定位置產生一級警告 |
#region | 它可以讓您在使用 Visual Studio Code Editor 的大綱特性時,指定一個可展開或折疊的程式碼區塊 |
#endregion | 表示#region 的結束 |
比較重要的是#define
預處理器和條件預處理器。
#define
#define
預處理器存在的意義事實上是條件編譯。即透過這個預處理器濾掉的程式碼根本不會被編譯。
#define
在C#中的用法和在C語言中的用法不相同。在C#中,它的用法是:
1 |
在C#中的#define
通常與條件預處理器同時使用。比如下面的例子:
1 |
|
條件指令
緊接著我們講條件指令。條件指令包括#if
、#elif
、#else
和#endif
。使用方法和程式碼幾乎相同,不再贅述。
C# 例外處理
C#中的例外處理幾乎和Java中一模一樣。熟悉的try-catch-finally
、throw
等等。
客製化例外需要繼承System.ApplicationException
型別。
C# 檔案讀寫
使用FileStream
來實現簡單的檔案讀寫。其用法如下:
1 | FileStream <object_name> = new FileStream( <file_name>, |
它的引數如下:
引數 | 描述 |
---|---|
FileMode | Append :開啟一個已有的檔案,並將遊標放置在檔案的末端。如果檔案不存在,則建立檔案。Create :建立一個新的檔案。如果檔案已存在,則刪除舊檔案,然後建立新檔案。CreateNew :指定作業系統應建立一個新的檔案。 如果檔案已存在,則拋出異常。Open :開啟一個現有的檔案。 如果檔案不存在,則丟擲例外。OpenOrCreate :指定作業系統應開啟一個已有的檔案。如果檔案不存在,則用指定的名稱建立新的檔案開啟。Truncate :開啟一個現有的文件,而檔案一旦打開,就會被截斷為零位元組大小。然後我們可以向文件寫入全新的數據,但保留文件的初始建立日期。如果檔案不存在,則拋出異常。 |
FileAccess | FileAccess 枚舉的成員有:Read 、ReadWrite 和Write |
FileShare | Inheritable :允許檔案句柄可由子程序繼承。Win32 不直接支援此功能。None :謝絕共享目前檔案。檔案關閉前,打開該檔案的任何請求(由此進程或另一個進程發出的請求)都會失敗。Read :允許隨後開啟檔案讀取。 如果未指定此標誌,則在檔案關閉前,任何開啟該檔案以進行讀取的請求(由此進程或另一進程發出的請求)都會失敗。但是,即使指定了此標誌,仍可能需要附加權限才能夠存取該檔案。ReadWrite :允許隨後開啟檔案讀取或寫入。如果未指定此標誌,則在檔案關閉前,任何開啟該檔案以進行讀取或寫入的請求(由此進程或另一進程發出)都會失敗。但是,即使指定了此標誌,仍可能需要附加權限才能夠存取該檔案。Write :允許隨後開啟檔案寫入。如果未指定此標誌,則在檔案關閉前,任何開啟該檔案以進行寫入的請求(由此進程或另一進程序發出的請求)都會失敗。但是,即使指定了此標誌,仍可能需要附加權限才能夠存取該檔案。Delete :允許隨後刪除檔案。 |
例子:
1 | FileStream F = new FileStream("sample.txt", FileMode.Open, FileAccess.Read, FileShare.Read); |
C
C#中的(attribution)幾乎類似於Java中的標註(annotation),可以幫助你在一定程度上左右程式的執行。
在C#中,一共有三個.NET提供的attribution,分別是Obsolete
、Conditional
和AttributeUsage
。我們分別來看。
Obsolete
這個attribution用於標記應該過時但仍然希望保留的程式碼。在使用的過程中會丟擲一個警告或者錯誤。
1 | [ |
message
為字串,用於描述過時的資訊。
iserror
預設為false
,表示丟擲的是一個warning,如果設為true
,則表示丟擲一個error。
1 | using System; |
Conditional
用於條件編譯,與#define
預處理器一起使用。用法是:
1 | [ |
例如:
1 |
|
AttributeUsage
用於描述一個客製化的attribution如何使用。用法如下:
1 | [ |
引數validon用於定義目標attribution可以被用到哪裡,預設為AttributeTargets.All
;引數AllowMultiple是一個boolean值,如果為true,則目標attribution是多用的,預設false
;引數Inherited定義是否可被繼承,預設為false
,即不可繼承。
客製化
首先要創建一個客製化attribution,派生自System.Attribution
型別:
1 | [ |
然後我們需要定義其中的客製化存儲資訊:
1 | [ |
然後應用這個客製化特性:
1 | [ ] |
接下來我們可以使用Reflection來檢索這些資訊。
C# 反映
反映在Java中也有。它允許你在程式運行的過程中修改程式中的後設資料。
書銜上文,使用反映來處理Attribution中的資料:
1 | using System; |
C# Property
最常見的就是get
和set
,我們之前講過它和Java的區別。直接看例子:
1 | public string Code |
我們還可以抽象之,然後在繼承的時候將其實作:
1 | using System; |
C# 委託
委託宣告
1 | public delegate int MyDelegate (string s); |
上面的委託可用來引用任何一個帶有一個單一的 string 引數的方法,並傳回一個 int 類型變數。
委託創建和使用
下面的程式碼表示了一個委託從宣告、創建到使用的全過程:
1 | using System; |
應該比較好理解。
委託的合併委派
使用+
運算子,可以把相同屬性的方法全部委派給一個委託。當呼叫委託的時候,會按照順序呼叫這些方法:
1 | using System; |
C# 事件
事件(Event) 基本上說是使用者操作,如按鍵、點擊、滑鼠移動等等,或是一些提示訊息,如係統產生的通知。 應用程式需要在事件發生時響應事件。 例如,中斷。
C#中的事件處理是典型的“發布-訂閱”委託模型。
- 發佈器(publisher)是一個包含事件和委託定義的物件。事件和委託之間的聯繫也定義在這個物件中。發佈器(publisher)類別的物件呼叫這個事件,並通知其他的物件。
- 訂閱器(subscriber)是一個接受事件並提供事件處理程序的物件。在發佈器(publisher)類別中的委託呼叫訂閱器(subscriber)類別中的方法(事件處理)。
接下來我們逐步來創建一個完整的事件發布和訂閱。
1 | // 創建一個委託 |
這樣,當Publisher的sendMsg
方法被呼叫後,會自動透過事件處理通知Subscriber。