VB 與 UniCode


聲明

個人可以自由轉載本文,不過應保持原文的完整性,並通知我;商業轉載先請和我聯繫。

本文沒有任何明確或不明確地提示說本文完全正確,閱讀和使用本文的內容是您自己的選擇,本人不負任何責任,但是如果您發現本文有錯漏的地方,希望您可以給我指出。

本文假定您已經對 VB 的編程比較熟悉,所以對一些本人認為簡單的問題不會做太多的解釋,如果有什麼問題,可以給我提出。

意見、建議和提出的問題最好寫在我的首頁 http://llf.126.com 的留言版上。

一 UniCode

UniCode 作為一個名詞應該是非常著名的了,特別是在我們這樣一個非英語國家裡。UniCode 是一種聯合的符號內碼,因為用兩個字節表示,所以可以表示世界上的大多數語系。我在某些書上見到說 UniCode 分兩種編碼轉換格式,UTF-8 和 UTF-16 ,其中的 UTF-8 編碼是變長的,長度從一個字節到三個字節不等,它的優勢在於英文不用處理就符合此編碼,UTF-16 編碼和 UniCode 的編碼基本相同,可能在純文字轉換上有應用您 ? 有興趣的朋友可以透過 E-mail 或者留言版和我討論此編碼 。本文不討論這些編碼轉換格式,而主要討論 UniCode 的記憶體處理,長度固定兩個字節,因為這種編碼是 Windows 系統內定的 Unicode 編碼方式,另外,長度固定對於字串的處理也非常有利,個人認為用處更大一些。

我不想在概念上做太多的文章,大家只要記住 UniCode 是兩個字節就可以了。作為英文,在 UniCode 裡的編碼是首字節置零,尾字節就是原來的英文碼,比如「A」的十六進制碼是「41」,而其 UniCode 的十六進制碼是「0041」。不過千萬不要認為 GB 碼的中文字編碼和 UniCode 相同,其實幾乎都不相同,所以一般要一個對照表進行轉換,不過各語系版本的 Windows 自帶不同的編碼和 UniCode 之間互相轉換的函數,我們就不需要關心細節了。Windows 的可執行檔案中經常使用 UniCode 作為存儲格式,使用十六進制的編輯器就可以看到,如果是英文,就顯示成每個字母前有一個「00」,但是如果是中文而又存成 UniCode 的話,是不能查看到的,以前我曾經說過變量使用中文沒有問題,因為在可執行檔案裡查不到那些中文,但是如果是 UniCode 的話,查不到也不表明沒有,所以我又把檔案轉換成 ANSI 格式,再次尋找,仍然沒有,也就是說結論沒有變化,我們還是可以使用中文變量名的。

因為 Windows 的可執行檔案編譯有使用 UniCode 的習慣,所以海峽對岸的同志們編寫的軟體雖然沒有專門製作 GB 版,但是像選單標題之類的東西顯示也是正常的,不過既然只是「經常」,而不是「總是」,所以很多對話框的顯示卻是亂碼,也就可以解釋了。進一步推想,如果所有的字串都存儲為 UniCode 的話,我們就不需要等待同一個軟體的 GB 版和 BIG5 版了,當然,我們看到繁體字習以為常,但是他們看到簡體字時,不知道會是什麼感想 ?

另外,像 GB、Big5 之類的編碼,都是考慮到和英文的相容性的,比如 GB 碼,就是將首位置「1」,當然,現在的 GBK 內碼只是第一個字節首位置「1」,第二個字節作了擴展,也可以是 ASCII 值小於 127 的值了。在簡體中文版 Windows 95 的目錄下有一個「GBK.TXT」檔案,羅列了 GBK 內碼所有的字,有興趣可以看一看。順便說一下,以前常用的 GB2312 內碼只有六千多字,Dos 下的中文字系統都是使用此內碼的,所以也只能顯示這六千多字,而 GBK 內碼有兩萬九千多字,內含繁體編碼,不過字型檔案不一定支援 GBK ,我所知的微軟的 GBK 字型包括 Windows 自帶的「新細明體」、「新細明體」,Office 自帶的「新細明體」、「幼園」,其它的字型檔案一般都是 GB2312 的,包括 Windows 自帶的「新細明體」、「仿宋_GB2312」,還有其它的像「微軟簡行楷」、「微軟簡魏碑」等,其它公司製作的字型檔案我至今沒有見過支援 GBK 的,實在很遺憾,而且微軟好像也沒有再製作 GBK 字型,大概是只顧得打官司了您 ? !

二 VB 和 UniCode 的關係

在 C 語系中,內部的字串是 ANSI 格式,也就是以字節為單位,但是在 VB 中字串是 UniCode 格式,也就是說以字為單位,為了和類 C 語系相區別,我把以 UniCode 表示的字串稱之為字串。

既然 VB 中字串是 UniCode 格式,我們就知道 Len("ABC測試") 等於 5 , LenB("ABC測試") 等於 10 ,當然,因為 Windows 9x 系統內部並不使用 UniCode ,所以我們在和 API 接口時就會出現問題了。我們經常可以見到 API 的聲明函數最後由一個「A」,比如「SetWindowTextA」「GetPrivateProfileStringA」等等,這是表明此函數使用 ANSI 字串格式;相對的,也有使用 UniCode 格式字串的相同功能的函數,後綴為「U」,比如「SetWindowTextU」「GetPrivateProfileStringU」等等,不過這些 UniCode 格式的函數一般用於 Windows NT ,Windows 9x 上很少有,另外,因為 Windows NT 也支援 ANSI 格式的函數,所以平時我們調用的仍然是 ANSI 格式的函數,也所以 VB 在調用 API 時,都會把字串轉換成字串,以便和 ANSI 函數相兼容。

不過 Windows 9x 中也並不是沒有 UniCode 函數,比如 OLE 自動化函數就全部是 UniCode 函數,如上,VB 會把自己的 UniCode 字串轉換成 ANSI 字串,所以調用這些 OLE 自動化函數一般不能用「Declare」語句定義,當然,事實上 VB 和 OLE 自動化關係十分密切,VB 中的很多功能都是建立在 OLE 自動化的基礎上的(比如讀取 JPG、GIF 等圖形檔案就是使用的 OLE 自動化函數),所以 VB 在內部調用 OLE 自動化函數,這樣就不需要作轉換了,也因此,VB 內部調用 OLE 自動化函數的速度比 VC 調用 OLE 自動化函數的速度要快,因為 VC 要先做 ANSI 字串到 UniCode 的轉換,不過這種優勢並不太明顯,就像 VB 調用 ANSI 格式函數時速度比 VC 慢的劣勢也不明顯一樣。(需要注意,VB 使用和 OLE 自動化同樣的變體類型的變量,這也是 VB 調用 OLE 自動化函數速度快的一個原因,使用其它語系調用 OLE 自動化函數可能需要自己做普通變量到變體變量的轉換)

因為 VB 內部使用 UniCode ,很多人在使用 VB 編程處理字串的時候都或多或少地遇到了問題,因此很多人在介紹在 VB 中處理字串的技巧的時候總會帶出一些對 VB 的不滿,因為不能用老的對字串的認識來處理字串了,不過我認為這種認識是不對的,正是因為我們是在一個非英語國家,VB 的這一特性才更有用處,當然,如果能多瞭解關於 UniCode 的知識會更有用。

在 VB 中還有一種處理字串的特殊方法,用來解決我們遇到需要對字串按字節方式訪問的問題,看一下以下的代碼,大家應該可以明白的:

Option Explicit

Private Sub Form_Load()
 Dim 字節數組() As Byte
 Dim 字串 As String
 字串 = "測試"
 字節數組 = 字串 & "成功"
 字串 = 字節數組
 MsgBox 字節數組, vbOKOnly, 字串
End Sub

我們看到,在以上的程式中,字節數組可以和字串互相賦值,而且字節數組在取得字串時會自動調整大小以適應字串,這一功能非常有用,而且非常方便,不過因為 VB 內部是 UniCode 的,所以我們處理時必須以兩個字節為單位,否則會造成處理錯誤,當然,因為總是以兩個字節為單位,所以也是很方便的,比起老的字串方式處理中文可是方便的太多了。VB 中的 Integer 是十六位的,也就是兩個 Byte ,那麼使用 Integer 數組處理字串豈不是更好 ? 好的,VB 支援 Integer 數組和字串的互相賦值嗎 ? 不支援 !

三 VB 中快速處理 UniCode

VB 中處理 UniCode 是有一些問題需要解決的。

首先,我們遇到的問題是如何取得中英文混合字串在 ANSI 格式時的長度 ? VB 當然是可以測量其長度的,不過需要用到字串轉換函數: LenB(StrConv("ABC測試",vbFromUnicode)) 。好的,我們來看一下,首先我們把字串轉換成字串 StrConv("ABC測試",vbFromUnicode) ,然後用 LenB 函數取得字串的長度,顯然,在這裡我們做了一些的無用功——字串轉換,這是很費時間的,但是因為 VB 內部是使用 UniCode 格式的,所以倒也不可避免,不過在某些情況下可以將這一次轉換和其它的轉換合併成一次以減少時間消耗。

在瞭解轉換合併之前,我們先來瞭解一下 VB 調用 API 時的字串參數的處理方法。來看一個簡單的 API 函數:

Declare Function SetWindowText Lib "user32" Alias "SetWindowTextA" _
 (ByVal hwnd As Long, ByVal lpString As String) As Long

在這個函數中有兩個參數,一個是視窗句柄 hwnd ,一個是要設定的視窗標題指針 lpString 。我們知道在 VB 裡使用 ByVal 關鍵字傳遞字串參數,當然,在其中並不是真的按值傳遞,VB 在這時作了一次 UniCode 到 ANSI 的轉換,然後把轉換後的 ANSI 字串的指針傳遞給了 API 函數,因為有時候 API 不止利用字串指針傳入字串,也會傳出字串,所以 VB 在 API 函數調用結束時又作了一次 ANSI 到 UniCode 的轉換,把轉換的結果傳回到原來的字串裡,在這個函數裡是 lpString 。假設在您的程式裡要調用 SetWindowText ,而且又要得到字串的長度,那麼程式一般是這樣的:

Public Function 設定標題(標題 As String) As Long
 設定標題 = LenB(StrConv(標題, vbFromUnicode))
 Call SetWindowText(Me.hWnd, 標題)
End Function

程式可以說是非常簡單,不過這裡所作的額外操作還是很多的,如前所述,調用 SetWindowText 時會有一次 UniCode 到 ANSI 的轉換,還有一次 ANSI 到 UniCode 的轉換,另外,為了取得字串的長度,程式本身還做了一次 UniCode 到 ANSI 的轉換,而且這些 VB 在內部進行的轉換都另外申請了記憶體以便進行操作,其實這都是可以避免的,不過需要修改一下 API 的定義,另外需要用到字串和 Byte 數組之間能互相賦值的特性,而且還要用到一個未公開的函數 VarPtr 用來取得變量的地址:

Declare Function SetWindowText Lib "user32" Alias "SetWindowTextA" _
 (ByVal hwnd As Long, ByVal lpString As Long) As Long

Public Function 設定標題(標題 As String) As Long
 Dim 標題字串() As Byte
 標題字串 = StrConv(標題, vbFromUnicode)
 設定標題 = UBound(標題字串) + 1
 Call SetWindowText(Me.hWnd, VarPtr(設定標題(0)))
End Function

這樣,我們在調用此 API 函數時一共只分配了一次記憶體,作了一次 UniCode 到 ANSI 的轉換就完成了和上一個函數同樣的任務,速度大為提高,記憶體的需求量也有所降低。

當然,標題不可能很長,所以這個函數所得到的速度提升和性能改善並不能在程式中體現出來,而且 SetWindowText 這個函數還會刷新視窗標題,會用到極其緩慢的 GDI 函數,怕是即使調用很多次,速度提升也會淹沒在緩慢的 GDI 海洋裡,在這裡也只是作為一個例子來說明此種處理的優勢,取這個函數只是為了容易理解罷了,為了測試用這種方法究竟能得到多少的優勢,應該選一個只作簡單記憶體處理的 API (最好是自己做一個 DLL ,寫一個什麼也不做的函數) 調用,不過我沒有測試過,如果各位誰作了這種測試,可不要忘了把結果告訴我啊。 :)

重新回到字串和數組的問題上。我們知道,VB 中處理字串有很多函數,比如 MidLeftRight 等,如果我們使用這些函數來取得字串的幾串,會經過一次函數調用,一些對輸入值的判斷和轉換,重新分配記憶體,返回。而這只是我們查看時的代價,如果要修改一個字串中間的一個字,我們就不得不取得此字前的字串,加上修改後的字,再加上此字後的字串,浪費就更大了。在這種時候,數組就能顯出其優勢了,因為對數組的操作事實上是指針操作,速度非常快,而且如果要修改其中的字,因為是指針操作,所以不需要擷取字串的操作,只要簡單的對要改的字操作就可以了:

Option Explicit

Private Sub Command1_Click()
 字串方式 "不好嗎 ? "
End Sub

Private Sub Command2_Click()
 數組方式 "不好嗎 ? "
End Sub

Private Sub 字串方式(字串 As String)
 Dim i As Integer, 新字串 As String, 字 As String
 For i = 1 To Len(字串)
 字 = Mid(字串, i, 1)
 If 字 = "好" Then
 新字串 = 新字串 & "壞"
 Else
 新字串 = 新字串 & 字
 End If
 Next
 MsgBox 新字串
End Sub

Private Sub 數組方式(字串 As String)
 Dim i As Integer, 新字串() As Byte, 字() As Byte
 字 = "好"
 新字串 = 字串
 For i = LBound(新字串) To UBound(新字串) Step 2
 If 新字串(i) = 字(0) And 新字串(i + 1) = 字(1) Then
 字 = "壞"
 新字串(i) = 字(0)
 新字串(i + 1) = 字(1)
 'Exit For '只替換第一個字的時候加上這一句
 End If
 Next
 MsgBox 新字串
End Sub

在實際的編程中間大概不會有第一個函數那樣的操作方法,因為可以用 InStr 函數得到某字的位置,但是取出前後字串的操作還是需要的,所以仍然會比第二個函數的速度慢,當然,這也是在大量資料操作的時候,如果資料量較小,還是使用 Mid 等函數的好,畢竟簡單才是 Basic 的精華 !

不過我們也見到了,在第二個函數中處理字的時候仍然很麻煩,因為要判斷兩次才能確定一個字,很不方便,仍然是上面的老問題: VB 支援 Integer 數組和字串的互相賦值嗎 ? 答案當然也仍然是: 不支援 ! 不過我們可以自己設計這樣的函數來實現這樣的功能。

為了實現這一功能,首先我們要得到字串的地址,不過我試了很多辦法,就是沒有辦法直接得到它的地址,很不幸的仍然需要使用 Byte 數組做橋樑,下面是我寫的兩個函數,一個實現從字串數組到字數組的轉換,一個實現從字數組到字串數組的轉換:

Private Declare Sub CopyMemory Lib "kernel32" Alias "RtlMoveMemory" _
	(lpvDest As Any, lpvSource As Any, ByVal cbCopy As Long)

Public Sub 字串數組到字數組(字串數組() As Byte, 字數組() As Integer)
 Dim 長度 As Long
 長度 = UBound(字串數組) - LBound(字串數組) + 1
 If 長度 >= 2 Then
 Redim 字數組(1 To 長度 \ 2)
 CopyMemory 字數組(1), 字串數組(0), 長度
 End If
End Sub

Public Sub 字數組到字串數組(字數組() As Integer, 字串數組() As Byte)
 Dim 字長度 As Long, 字串長度 As Long
 字長度 = UBound(字數組) - LBound(字數組) + 1
 字串長度 = 字長度 * 2
 If 字長度 >= 1 Then
 If UBound(字串數組) - LBound(字串數組) + 1 <> 字串長度 Then
 Redim 字串數組(1 To 字串長度)
 End If
 CopyMemory 字串數組(0), 字數組(1), 字串長度
 End If
End Sub

所以用過程(Sub)也是迫不得已,只有這樣我才能確定 VB 使用的是快速的指針方式,而不是又建立了一個副本。現在,我們就可以使用以上的兩個函數改寫剛才的程式:

Option Explicit

Private Sub Command1_Click()
 數組方式 "不好嗎 ? "
End Sub

Private Sub 數組方式(字串 As String)
 Dim i As Integer, 新字串() As Byte, 字() As Byte
 Dim 中間字串() As Integer, 中間字() As Integer
 字 = "好壞"
 新字串 = 字串
 字串數組到字數組 新字串, 中間字串
 字串數組到字數組 字, 中間字
 
 For i = LBound(中間字串) To UBound(中間字串)
 If 中間字串(i) = 中間字(1) Then
 中間字串(i) = 中間字(2)
 Exit For
 End If
 Next
 '也可以不用以下的一句,因為大多數的 API 需要的只是地址
 '所以如果和 API 接口的話,需要做的是把 UniCode 轉換成
 'ANSI,然後取得地址傳遞過去,如果和 UniCode 函數接口的
 '話,更可以直接把地址傳過去。把 UniCode 轉換成 ANSI 的
 '話,需要使用 API 函數 WideCharToMultiByte 。
 字數組到字串數組 中間字串, 新字串
 MsgBox 新字串
End Sub

再重申一次,這樣做的原因是有大量資料需要處理,如果沒有大量資料需要處理的話,一來不需要這麼麻煩,二來可能速度還會減慢,大家可以分析一下,應該是可以明白的。那麼什麼時候是有大量資料,而且需要有這種方便的 Integer 數組的呢 ? 我遇到的是我做的內碼轉換器,使用這種方法的速度提升占總提升的 1/3 (另外的 2/3 在檔案的讀和寫上,參見我寫的《檔案處理速度》),以下給出其中使用此方法的部分核心代碼以供參考:

Private Function 字數組方式轉換(字數組() As Integer) As Boolean
 Dim i As Long, 不使用多線程 As Boolean, n As Integer
 不使用多線程 = Not 使用多線程
 If 不使用多線程 Then DoEvents
 For i = LBound(字數組) To UBound(字數組)
 字數組(i) = 內碼對照表(字數組(i))
 If 不使用多線程 Then
 n = n + 1
 If n > 1000 Then
 n = 0
 DoEvents
 End If
 End If
 If 中止 Then
 中止 = False
 字數組方式轉換 = True
 Exit For
 End If
 Next
End Function

可以看到,這一段代碼裡也示範了在不使用多線程的時候怎麼加快程式的執行速度,不過和本題無關,就不說了。

上面的註釋中說道把 Integer 數組做 UniCode 到 ANSI 的轉換需要使用 API 函數,這是因為 StrConv 也不認識 Integer 數組,下面把我寫的關於純文字檔案操作的兩個函數附上,其中利用了 API 函數 MultiByteToWideChar 和 WideCharToMultiByte ,不過使用的是 Bruce McKinney 提供的類型庫的函數說明:

Public Function 純文字檔案讀入(檔案名 As String, 內容() As Integer) As Boolean
 Dim 檔案句柄 As Long, 檔案長度 As Long, 總長度 As Long, 臨時() As Byte
 檔案名 = Trim$(檔案名)
 檔案句柄 = CreateFile(檔案名, GENERIC_READ, FILE_SHARE_READ, _
 0&, OPEN_EXISTING, FILE_FLAG_SEQUENTIAL_SCAN, 0&)
 If 檔案句柄 <> 0 Then
 檔案長度 = GetFileSize(檔案句柄, 0&)
 Redim 臨時(1 To 檔案長度)
 If ReadFile(檔案句柄, 臨時(1), 檔案長度, 檔案長度, ByVal 0&) <> 0 Then
 Redim 內容(1 To 檔案長度)
 總長度 = MultiByteToWideCharPtrs(CP_OEMCP, 0&, VarPtr(臨時(1)), _
 檔案長度, VarPtr(內容(1)), 檔案長度 * 2)
 Redim Preserve 內容(1 To 總長度)
 純文字檔案讀入 = True
 End If
 CloseHandle 檔案句柄
 End If
End Function

Public Function 純文字檔案寫入(檔案名 As String, 內容() As Integer) As Boolean
 Dim 檔案句柄 As Long, 檔案長度 As Long, 總長度 As Long, 臨時() As Byte
 檔案名 = Trim$(檔案名)
 檔案句柄 = CreateFile(檔案名, GENERIC_WRITE, 0&, 0&, _
 CREATE_NEW, FILE_FLAG_SEQUENTIAL_SCAN, 0&)
 If 檔案句柄 <> 0 Then
 總長度 = UBound(內容) - LBound(內容) + 1
 Redim 臨時(1 To 總長度 * 2)
 檔案長度 = WideCharToMultiBytePtrs(CP_OEMCP, 0&, VarPtr(內容(1)), _
 總長度, VarPtr(臨時(1)), 總長度 * 2, 0&, 0&)
 Redim Preserve 臨時(1 To 檔案長度)
 If WriteFile(檔案句柄, 臨時(1), 檔案長度, 檔案長度, ByVal 0&) <> 0 Then
 純文字檔案寫入 = True
 End If
 CloseHandle 檔案句柄
 End If
End Function

上面的函數中各位只需注意 UniCode 和 ANSI 互相轉換的函數就可以了,其中的「CP_OEMCP」常數指的是當前版本的 Windows 使用的預設代碼頁,比如在簡體中文版 Windows 上調用 MultiByteToWideCharPtrs 函數時使用 CP_OEMCP 常數,就會把 GBK 內碼的文字流轉換成 UniCode 內碼的文字流,而如果在繁體中文版的 Windows 上,同樣的程式就是把 BIG5 內碼的文字流轉換成 UniCode 內碼的文字流;相對的,WideCharToMultiBytePtrs 函數使用 CP_OEMCP 常數就是把 UniCode 文字流轉換成當前語系內碼的文字流。至於使用 API 函數進行讀寫操作,正像 Bruce McKinney 所說,是因為和其它 API 函數的相容性考慮,不過不再說了,不然就離題太遠了。

結語 UniCode 隨想

回想一下我們處理 UniCode 的方法。首先,我們把純文字檔案讀出,然後把讀出的內容使用 API 函數轉換成 UniCode ,在 UniCode 的世界中,我們處理各種語系都會得心應手,但是 Windows 的字庫是各種方言的字庫,比如 GBK 、BIG5 等,如果我們可以使用 UniCode 字庫的話,那麼一套字庫就可以支援所有國家版本的 Windows ,而不會造成每安裝一種語系支援就需要安裝一套新的字庫,這樣顯示的時候調用的就只是一套字庫,作業系統需要提供的只是各國編碼和 UniCode 的轉換函數罷了。

我們知道 Windows NT 是從底層開始重新設計的作業系統,所以 NT 其實也是從底層支援 UniCode 的,現在 Windows 2000 使用和 NT 同樣的構造,就是說它也是從底層開始支援 UniCode 的,事實上 Windows 2000 會出一種所謂的世界版,用戶可以選擇添加對多種語系的支援,並且選擇多種語系的字庫,在 Internet 高度發達的今天,這種方便性當然是無可置疑的,我堅決擁護。不過我的推想是如果世界上存儲資料的時候都使用 UniCode ,而不是各自的編碼方法,則對於我們在各國之間傳輸資料將會有非常大的幫助,如果真的完全實現了這一點,作業系統連提供各國編碼和 UniCode 的轉換函數都將成為多餘的了 !

HTML 現在是網路上流行的的格式,不過 XML 的發展應該會替代今天 HTML 的地位,而 XML 的標準中有一項規定: 必須支援 UniCode (同時也可以選擇性的支援 GBK、BIG5 等編碼)。UniCode 是英語國家制定的標準,XML 也是英語國家制定的標準,這讓人有些遺憾 —— 非英語國家為了使用電腦,想出了各種方法來讓電腦支援本語種,卻沒有想到兼容其它國家(除了英語)的問題,最後仍然是英語國家找到了解決方案。而在已經有了解決方案的時候,我們卻還懵然不覺,甚至有些不願意瞭解,才是更可遺憾的地方。

有集體觀的個人才有發展,有國家觀的集體才有發展,有世界觀的國家才有發展,不是嗎 ?

點睛工作室梁利鋒 結稿於 2000.3.27



回教學