[程序設計語言]-[核心概念]-04:數據類型


0. 概述

為何高級語言需要類型系統這個概念?在匯編時代是沒有完整的數據類型系統的,結構化編程引入了結構化的控制流、為結構化設計的子程序,隨之這種結構化的代碼所操作的數據也進一步的“抽象化、特化”,故而有了數據類型這種概念,類型系統主要用於兩個用途:

  1. 為許多操作提供了隱含的上下文信息,使程序員可以在許多情況下不必顯示的描述這種上下文。比如int類型的兩個對象相加就是整數相加、兩個字符串類型的對象相加就是拼接字符串、C#中new object()隱含在背后的就是要分配內存返回對象的引用等等。
  2. 類型描述了其對象上一些合法的可以執行的操作集合。比如一個整數類型的對象你就不能給它一個跑步的操作、一個人的對象你就不能對它進行求sin值。編譯器可以使用這個合法的集合進行錯誤檢查,如在編譯階段強制檢查的語言大部分都是強類型語言、在運行時檢查的大都是弱類型語言。

一個類型系統包含一些內部類型,一種定義新類型的機制,一組有關類型等價類型相容類型推理的規則。

1. 類型系統

 從編譯方面的知識我們可以知道,計算機硬件可以按多種不同的方式去解釋寄存器里的一組二進制位。處理器的不同功能單元可能把一組二進制位解釋為指令、地址、字符、各種長度的整數或者浮點數等。當然,二進制位本身是無類型的,對存儲器的哪些位置應該如何解釋,大部分硬件也無任何保留信息。匯編語言由於僅僅是對一些二進制指令的“助記符號”翻譯,它也是這種無類型情況。高級語言中則總是關聯值與其類型,需要這種關聯的一些原因和用途就如前面說到的上下文信息和錯誤檢測。

一般來說,一個類型系統包含一種定義類型並將它們與特定的語言結構關聯的機制;以及一些關於類型等價、類型相容、類型推理的規則。必須具有類型的結構就是那些可以有值的,或者可以引用具有值得對象的結構。類型等價規則確定兩個值得類型何時相同類型相容規則確定特定類型的值是否可以用在特定的上下文環境里類型推理規則基於一個表達式的各部分組成部分的類型以及其外圍上下文來確定這個表達式的類型

在一些多態性變量或參數的語言中,區分表達式(如一個名字)的類型與它所引用的那個對象的類型非常重要,因為同一個名字在不同時刻有可能引用不同類型的對象。

在一些語言中,子程序也是有類型的,如果子程序是一級或者二級值,其值是動態確定的子程序,這時語言就需要通過類型信息,根據特定的子程序接口(即參數的個數和類型)提供給這種結構的可接受的值集合,那么子程序就必須具有類型信息。在那些不能動態創建子程序引用的靜態作用域語言(這種語言中子程序是三級值),編譯器時就能確定一個名字所引用的子程序,因此不需要子程序具有類型就可以保證子程序的正確調用。

1.1 類型檢查

類型檢查時一個處理過程,其目的就是保證程序遵循了語言的類型相容規則,違背這種規則的情況稱為類型沖突。說一個語言是強類型的,那么就表示這個語言的實現遵循一種禁止把任何操作應用到不支持這種操作的類型對象上的規則。說一個語言是靜態類型化(statically type)的,那么它就是強類型的,且所有的類型檢查都能在編譯時進行(現實中很少有語言是真正的靜態類型,通常這一術語是指大部分類型檢查可以在編譯器執行,其余一小部分在運行時檢查)。如C#我們通常都認為它是靜態類型化的語言。

動態(運行時)類型檢查是遲約束的一種形式,把大部分的檢查操作都推遲到運行的時候進行。采用動態作用域規則的語言大部分都是動態類型語言,因為它的名字和對象的引用都是在運行時確定的,而確定引用對象的類型則更是要在引用確定之后才能做出的。

類型檢查是把雙刃劍,嚴格的類型檢查會使編譯器更早的發現一些程序上的錯誤,但是也會損失一部分靈活性;動態類型檢查靈活性大大的,但是運行時的代價、錯誤的推遲檢查,各種語言的實現也都在這種利弊上進行權衡。

1.2 多態性

多態性使得同一段代碼體可以對多個類型的對象工作。它意味着可能需要運行時的動態檢查,但也未必一定需要。在Lisp、Smalltalk以及一些腳本語言中,完全的動態類型化允許程序員把任何操作應用於任何對象,只有到了運行時采取檢查一個對象是否實現了具體的操作。由於對象的類型可以看作它們的一個隱式的(未明確聲明的,一個不恰當的比喻就如C#中的this)參數,動態類型化也被說成是支持隱式的參數多態性

雖然動態類型化具有強大的威力(靈活性),但卻會帶來很大的運行時開銷,還會推遲錯誤報告。一些語言如ML采用了一種復雜的類型推理系統,設法通過靜態類型化支持隱式的參數多態性。

在面向對象語言里,子類型多態性允許類型T的變量X引用了從T派生的任何類型的對象,由於派生類型必定支持基類型的所有操作,因此編譯器完全可以保證類型T的對象能接受的任何操作,X引用的對象也都能接受。對於簡單的繼承模型,子類型多態的類型檢查就能完全在編譯時實現。采用了這種實現的大多數語言(如C++,JAVA和C#)都提供另一種顯示的參數化類型(泛型),允許程序員定義帶有類型參數的類。泛型對於容器(集合)類型特別有用,如T的列表( List<T> )和T的棧( Stack<T> )等,其中T只是一個類型占位符,在初始化的這個容器對象時提供具體的類型來代替它。與子類型多態類似,泛型也可以在編譯時完成類型檢查。比如C++的模板完全就是編譯期間的東西,編譯后就完全沒有了模板的痕跡;JAVA則是利用一種“擦拭法”的技術實現的泛型,需要在運行時做一些檢查;而C#的泛型實現則是介於C++和JAVA之間。

現在一些腳本語言、動態語言的流行使得開發者開始質疑靜態類型化的價值:“如果我們不可能在編譯時檢查所有的東西,那么費勁的去檢查那些能檢查的東西值得嗎”?因為作為一般性規律,寫出類型正確的代碼比證明它們正確要容易的多,而靜態檢查就是希望得到這種證明。隨着類型檢查越來越復雜,靜態檢查的復雜性也隨之暴漲,寫過C#代碼的同學也許會有體會到這種強類型的檢查有時候逼的我們不得不多寫好多代碼。動態類型檢查是會帶來一些運行時開銷、會推遲錯誤報告,但越來越多的開發者覺得與人的效率相比,這種代價也不是不可以接受的。就如現在常說的一種“鴨子類型”,這種類型在編譯(或者說書寫時)根本就不去做類型檢查,而是在運行時檢查是否具有某種指定的操作,就好比一個對象會“像鴨子一樣呱呱叫”和“像鴨子一樣走路”,那么就認為它是鴨子。

1.3 類型的分類

在不同語言里,有關類型的術語也不相同,這里說的通常都是常用的術語,大部分語言多提供的內部類型差不多就是大部分處理器所支持的類型:整數、字符、布爾和實數。

一般語言規范中都會規定數值類型的精度問題,以及一些字符的編碼規定。通常特殊的一個數值類型是枚舉類型,具體的語法在不同的語言中略有差異,但是其也都是一個目的(用一個字符友好的表示一個數值)。

還有一些語言中提供一種稱為子界的類型,它表示一種基於基本數值的一個連續的區間。比如Pascal中表示1到100:

type test=1..100;

//周一到周日
workday=mon..fri;

復合類型:由一些簡單的基本類型組合成的一些類型稱為復合類型,比如常見的記錄、變體記錄、數組、集合、指針、表等,具體的都會在后面詳細介紹。

2. 類型檢查

前面我們說到類型檢查引出了2個概念“靜態類型”和“動態類型”,這兩個概念的區別就是類型檢查執行的時間差異,前者是編譯階段后者是運行時。那么既然有檢查的前提就是要有規則,前面說的強類型就是指這種規則比較“嚴格”,而弱類型則是指相對的“寬松”的規則(但也不是說沒有規則)。大多數的靜態類型語言中,定義一個對象都是需要描述清楚它的類型,進一步講,這些對象出現的上下文也都是有類型的,也就是說語言中的一些規則限制了這種上下文中可以合法出現的對象類型。那么什么是合法的?從上下文得出期望的類型的角度來看,就是類型等價或者類型相容的概念;從類型來得出上下文的角度看,這個概念就是類型推理

2.1 類型等價

 在用戶可以定義新類型的語言中,類型等價的定義一般基於兩種形式。

  1. 結構等價:基於類型定義的內容,就是它們由同樣的組成部分且按照同樣的方式組合而成;
  2. 名字等價:基於類型的詞法形式,可以認為是每一個名字都引進一個新的類型;

定義總是有點晦澀,看看下面代碼吧:

 1 struct Student{
 2   String Name;
 3   int     Age;
 4 }
 5 
 6 struct School{
 7   String Name;
 8   int     Age;
 9 }
10 
11 Student stu;
12 School sch;

如果語言按照結構等價,那么stu和sch就認為是類型等價的(因為它們的組成部分相同)。按照名字等價則只有stu或者再一個stu1類型等價了,因為每一個類型名字都不一樣,也就只有一個類型的兩個對象才會有類型等價的情況出現。

結構等價:它的准確定義在不同的語言中也不一樣,因為它們要決定類型之間的哪些潛在差異是重要的,哪些是可以接受的(比如上面代碼中如果School類型的Age是浮點數、或者和Name的聲明順序換了下位置,是否還認為是等價的)。結構等價是一種很直接的認識類型的方式,早期的一些語言(Algol 68、Modula-3、ML)有些事基於結構等價的,現在的大部分語言(Java、C#)大都是基於名字等價了,為何呢?因為從某種意義上看,結構等價是由底層、由實現決定的,屬於比較低級的思考方式。就如一個上下文如果期望Student的對象,你給傳遞了一個sch,實施結構等價的編譯器是不會拒絕這種情況的(假如這不是你希望的,那么你也不會得到任何提示或者錯誤信息,很難排查的)。

名字等價:它基於一種假設,就是說程序員花時間定義了兩個類型,雖然它們的組成部分可能相同,但是程序員要表達的意思就是這是兩個不同的類型。名字等價的常規判斷就非常簡單了,看看聲明兩個對象的類型是否是一個就是了。但是也會有一些特殊的情況出現,比如類型別名(C、C++的程序員很熟悉這種東西吧),比如 typedef int Age; 就為int類型重新定義了一個別名"Age"。那些認為int不等價越Age的語言稱為嚴格名字等價,認為等價的稱為寬松名字等價。其實這兩種也是很容易區分的,只要能區分聲明和定義兩個概念的差異就可以區分。在嚴格名字等價中看待typedef int Age是認為定義了一個新類型Age,在寬松名字等價看來這就是一個類型聲明而已,int和Age共享同一個關於整數的定義。

類型變換和轉換:在靜態類型的語言中,如果“a=b”,那么我們會期望b的類型和a的相同;比如func(arg1),那么我們調用的時候期望實際參數匹配arg1這個形式參數。現在假定所提供的類型和期望的類型和所提供的類型相同,那么我們在要求某個類型的上下文中使用另外一個類型時就需要顯示的寫出類型變換(或稱為類型轉換)。根據具體的變換的具體情況,在運行時執行這種變化會有以下三種主要的情況出現:

  1. 所涉及的類型可以認為是結構等價的,這種情況里面因為涉及的類型采用了相同的底層的表示,則這種變換純粹就是概念上的操作,不需要運行時執行任何代碼。
  2. 所涉及的類型具有不同的值集合,但它們的值集合具有相同的表示形式。比如一個類型和它的子類型,一個整數和一個無符號的整數。拿無符號整數變換為整數來說,由於無符號整數的最大值是整數類型所容納不了的,則運行時就必須執行一些代碼來保證這種變換的合法性,如果合法則繼續下去,否則會產生一個動態語義錯誤。
  3. 所涉及的類型具有不同的底層表示,但是我們可以在它們的值之間定義某種對應關系。比如32位整數可以變換到IEEE的雙精度浮點數,且不會丟失精度。浮點數也可以通過舍入或割斷的形式變換成整數,但是會丟失小數部分。

非變換的類型轉換:有這么一種情況,我們需要改變一個值,但是不需要改變它的二進制表示形式,更通俗點說就是我們希望按照另外一個類型的方式去解釋某個類型的二進制位,這種情況稱為非變換類型轉換。最簡單的一個例子比如說,一個byte類型的數值65,按byte類型來解釋它是65,如果按照char類型來解釋它就是字符“A”。比如C++中的static_cast執行類型變換,reinterpret_cast執行非變換的類型轉換。c中出現的union形式的結構,就可以認為是這種非變換的類型轉換的合法的安全的語言結構。在比如下面C中一般性非變換類型轉換代碼:

//C中執行非變換的類型轉換一般方式為:先取得對象的地址,將其變換成所需類型的指針,然后再做簡接操作取值。
//n是整數,r是浮點數
r=*((float *) &n);

//因為C中指向整數和浮點數的指針具有相同的表現形式(都是地址值而已)
//那么第一步&n就是取得n的地址值(指針)
//第二步((float *) &n)就是把這個指針變成浮點數類型的指針
//第三步*((float *) &n)就是按照浮點數的解釋方式去解釋n位置的二進制位。

任何非變換的類型轉換都極其危險的顛覆了語言的類型系統。在弱類型系統的語言中,這種顛覆可能很難發現,在強類型系統的語言中顯示的使用這種非變換的類型轉換,起碼從代碼上可以看得出來它是這么一回事,或多或少的有利於排查問題。

2.2 類型相容

 大多數語言的上下文中並不要求類型等價,相應的一般都是實施較為“寬松”的類型相容規則。比如賦值語句要求右值相容與左值、參數類型相容,實際返回類型與指定的返回類型相容。在語言中,只要允許把一個類型的值用到期望的另外一個類型的上下文中,語言都必須執行一個到所期望類型的自動隱式變換,稱為類型強制(比如int b;double a=b;)。就像前面說的顯示的類型變換一樣,隱式的類型變換也可能需要執行底層代碼或者做一些動態類型檢查。

一個重載的名字可能引用不同類型的對象,這種歧義性需要通過上下文信息進行解析。比如a+b這個表達式可以表示整數或者浮點數的加法運算,在沒有強制的語言中,a和b必須都是整數或都是浮點數。如果是有強制的語言,那么在a或者b有一個是浮點數的情況下,編譯器就必須使用浮點數的加法運算(另外一個整數強制轉換為浮點數)。如果語言中+只是進行浮點數運算,那么即使a和b都是整數,也會被全部轉成浮點數進行運算(這代價就高了好多了)。

通用引用類型:一些語言根據實習需求,設計有通用的引用類型,比如C中的void*、C#中的Object,任意的值都可以賦值給通用引用類型的對象。但是問題是存進去容易取出來難,當通用引用類型是右值的時候,左值的類型可能支持某些操作,然而這些操作右值對象是不具備的。為了保證通用類型到具體類型的賦值安全,一種解決辦法是讓對象可以自描述(也就是這個對象包含其真實類型的描述信息),C++,JAVA,C#都是這種方式,C#中如果賦值的類型不匹配則會拋出異常,而C++則是使用dynamic_cast做這種賦值操作,具體的后果呢,也是C++程序員負責。

2.3 類型推理

通過前面的類型檢查我們可以保證表達式的各各組成部分具有合適的類型,那么這整個表達式的類型是什么來着?其實在大多數的語言中也是比較簡單的,算術表達式的類型與運算對象相同、比較表達式總是布爾類型、函數調用的結果在函數頭聲明、賦值結果就是其左值的類型。在一些特殊的數據類型中,這個問題並不是那么清晰明了,比如子界類型(關於子界類型請參考這里)、復合類型。比如下面的子界類型問題(Pascal):

type Atype=0..20;
type Btype=10..20;

var a: Atype;
var b: Btype;

那么a+b什么類型呢???它確實是不能是Atype或者Btype類型,因為它可能的結果是10-40。有人覺得那就新構造一個匿名的子界類型,邊界時10到40。實際情況是Pascal給的答案是它的基礎類型,也就是整數。

在Pascal中,字符串'abc'的類型是array[1..3] of char、而Ada則認為是一種未完全確定的類型,該類型與任何3個字符數組相容,比如在Ada中'abc' & 'defg'其結果是一個7字符的數組,那么這個7字符數組的類型是array[1..7] of cahr呢還是某一個也是7個字符組成的類型array (weekday) of character呢,更或者是其他任意一個也是包含七個字符數組的另外一個類型。這種情況就必須依賴表達式所處的上下文信息才能推到出來具體的類型來。

類型推理中比較有意思的是ML的做法,感興趣的可以深入了解一番,這里就不去做介紹了。

3. 記錄/結構與變體/聯合

 一些語言中稱記錄為結構(struct),比如C語言。C++把結構定義為class的一種特殊形式(成員默認全局可見),Java中沒有struct的概念,而C#則對struct采用值模型,對class采用引用模型。

Pascal中簡單的記錄類型定義如下:

1 type two_chars=packed array [1..2] of char;
2 type element - record
3     name:two_chars;
4     number:integer;
5     weight:real;
6     metallic:Boolean
7 end

C中與此對應的定義為:

1 struct element{
2      char name[2];
3      int number;
4      double weight;
5      Bool merallic;    
6 };       

記錄里面的成員(如name,number...)稱為域(field)。在需要引用記錄中的域時,大部分語言使用“.”記法形式。比如Pascal中:

1 var copper:eement;
2 copper.name=6.34;

C的寫法與Pascal相似,有些語言中會使用其他符號,比如Fortran 90中用“%”( copper%name )。有些語言則顛倒域和記錄的順序(域出現在記錄前面),比如ML中( #name copper )、Common Lisp中( element-name copper )。這些語法雖然迥異,但是本質上卻無任何差異,只是其具有不同的外在書寫形式而已。

大部分語言中海允許記錄的嵌套定義,還如Pascal中:

 1 type short_string=packed array[1..30] of char;
 2 type ore=record
 3     name:short_string;
 4     element_yielded:record /*嵌套的記錄定義*/
 5         name:two_chars;
 6         number:integer;
 7         weight:real;
 8         metallic:Boolean
 9     end
10 end

換個方式也可以定義為:

1 type ore=record
2     name:short_string;
3     element_yielded:element
4 end

一些語言中只允許第二種形式(也就是說允許記錄的域的類型是記錄,但是不允許詞法上的嵌套定義)。

3.1 存儲布局和緊縮

 一個記錄的各個域通常被放入內存中的相鄰位置。編譯器在符號表中保存每個域的偏移量,裝載和保存的時候通過基址寄存器和偏移量即可得到域的內存地址。類型element在32位的機器中可能的布局如下:

4 byte/32bits(黑色表示空洞-可能會有臟數據)
name(2個字節) 2個字節的空洞
number(4個字節)
weight
(8個字節)
metallic(1個字節) 3個字節的空洞

 總的算來element占據5*4=20byte(未壓縮),其中空洞占據5個字節。如果記錄的相等性判斷按照按位比較的話,空洞中的可能會有一些臟數據出現,從而影響到程序的正常行為。那么解決辦法大致有2種,1是壓縮布局,消除空洞;2是把空洞位置置0。記錄的上面的Pascal代碼中有個packed關鍵字,不知大家注意到木有,它的意思就是說告訴編譯器,對這段定義優先優化空間而不是時間,那么它優化后的結果可能如下:

4 byte/32bits
name(2個字節) number(4個字節)
   
weight(8個字節)
  metalic(1個字節) 1個字節的空洞

 這樣的布局element占據4*4=16byte(已壓縮)。空間是節省了,但是帶來的后果卻是運行時上的時間開銷(對於未對齊的域的存取需要多條指令方可取出)。如是大家找到一種折中的方案(保證對齊的情況下進行壓縮),效果如下:

4 byte/32bits
name(2個字節) metalic(1個字節) 1個字節的空洞
number(4個字節)
weight
(8個字節)

 這種方案會打亂域的排列順序,不過這也無所謂,必經這種行為屬於實現層面的,對程序員來說是屬於透明的,除非特殊情況不必去關心編譯器是怎么安排的。

3.2 變體記錄

說曹操曹操到,特殊情況的存儲布局情況來了》變體記錄提供2個貨更多個可以選擇的域,在給定的某一時刻,只有其中一種選擇是有效的。變體記錄源於Fortran 1的equivalence語句和Algol 68的union類型(C中引入了這種類型)。Fortran的語法形式如下:

1 integer i
2 real r
3 logical b
4 equivalence(i,r,b)

equivalence語句高速編譯器i、r、b不會同時使用,應該在內存中共享存儲空間。equivalence語句沒能提供一組內在的方法來確定當前哪一個對象是合法的,比如說你存儲了一個r,然后按照integer整數來讀取,這種行為完全是可以的,因為這些都是使用者的責任,因此也會有一些隱含的安全性問題。

筆者認為變體記錄的本質和上面2.2類型等價中介紹到的非變換的類型轉換是一回事:一塊存儲區域,數據存進去了,但是到底按照什么類型來讀取完全由使用者負責,唯一的不同之處在於變體記錄規定了一個有限的類型集合,而非變換的類型轉換卻沒有任何約束。當然,如果從語義上看這兩者完全也沒有可比性,

關於變體記錄,各種語言的語法看起來真是太丑了(筆者覺得)。這里也不去做語法方面的介紹了,明白一點即可(多個域共享一塊存儲區域)。C的union語法看起來還是比較舒服點:

1 union Student
2 {
3    int height;
4    double weight;
5 };

上面的C語言的例子表明這個聯合體可以解釋為兩種意思(height或者weight),但是它們在某一時刻只有一種狀態是有效的。再比如C#中雖然不支持union,但是卻提供了另外一種機制可以讓你控制class或者struct的成員的內存布局,也可以模擬出C中union的效果來,以前寫過一個IP和整數的互轉的結構。這種類型的內存布局存儲各位腦補一下,就不畫圖了。

4. 數組

 數組是最常見也是最重要的復合數據類型。記錄用於組合一些不同類型的域在一起;而數組則不同,它們總是同質的。從語義上看,可以把數組想象成從一個下標類型成員(元素)類型的映射。

有些語言要求下標類型必須是integer,也有許多語言允許任何離散類型作為下標;有些語言要求數組的元素類型只能是標量,而大多數語言則允許任意類型的元素類型。也有一些語言允許非離散類型的下標,這樣產生的關聯數組只能通過散列表的方式實現,而無法使用高效的連續位置方式存儲,比如C++中的map,C#中的Dictionary。在本節中的討論中我們假定數組的下標是離散的。

4.1 語法和操作

大多數的語言都通過數組名后附加下標的方式(圓括號|方括號)來引用數組里的元素。由於圓括號()一般用於界定子程序調用的實際參數,方括號在區分這兩種情況則有易讀的優勢。Fortran的數組用圓括號,是因為當時IBM的打卡片機器上沒有方括號,,,

聲明一個數組的語法在各語言的實現中各有不同,比如C char name[10]; ,C# char[] name; 。何時確定數組的形狀(維數和上下屆)對管理數組的存儲有着決定性的作用,比如一下的5種可能性:

  1. 全局生存期,靜態形狀: 如果一個數組的形狀在編譯時已知,而且在程序執行期間一直存在,那么編譯器就可以在靜態的全局存儲中為這種數組分配空間。
  2. 局部生存期,靜態形狀: 如果一個數組的形狀在編譯時已知,但它在程序執行期間不應該一直存在,則可以運行時在子程序的棧幀里為數組分配空間。
  3. 局部生存期,加工時完成形狀約束: 如果一個數組的形狀只能到加工時才知道,這種情況下仍可以在子程序的棧幀里為數組分配空間,但是需要多做一層簡介操作。
  4. 任意生存期,加工時完成形狀約束: 在C#和Java里的數組變量是對象(面向對象語言中所指的對象)的引用。聲明 int[] A; 並不做空間分配,只是創建一個引用,要想這種引用確實引用某一個東西,則必須為其創建一個新對象( A=new int[10]; )或者指向另外一個數組對象,無論哪一種情況,數組一旦分配,其大小就不會改變。
  5. 任意生存期,動態形狀: 如果一個數組的大小可以動態調整,那么久無法在棧幀里分配了,因為當數組增大時,它兩邊的空間可能已做他它用。為了能改變期大小,這種數組就必須在堆里分配。大多數情況下,為了增大數組,就要新分配一塊更大的新空間,然后復制舊數據到新塊。

4.2 存儲布局

大多數語言的實現里,一個數組都存放在內存的一批連續地址中,比如第二個元素緊挨着第一個,第三個緊挨着第二個元素。對於多維數組而言,則是一個矩陣,會出現行優先列優先的選擇題,這種選擇題對於語言使用者而言是透明的,而對語言的實現者則需要考慮底層方面的優化問題了。

在一些語言中,還有另外一種方式,對於數組不再用連續地址分配,也不要求各行連續存放,而是允許放置在內存的任何地方,再創建一個指向各元素的輔助指針數組,如果數組的維數多於兩維,就再分配一個指向指針數組的指針數組。這種方式稱為行指針布局,這種方式需要更多的內存空間,但是卻有兩個優點:

  1. 首先,可能加快訪問數組里單獨元素的速度;
  2. 其次,允許創建不用長度的行,而且不需要再各行的最后留下對齊所用的空洞空間,這樣節省下來的空間有時候可能會超過指針占據的空間。

C,C++和C#都支持連續方式或行指針方式組織多維數組,從技術上講,連續布局才是真正的多維數組,而行指針方式則只是指向數組的指針數組。

5. 字符串

許多語言中,字符串也就是字符的數組。而在另一些語言中,字符串的情況特殊,允許對它們做一些其他數組不能用的操作,比如Icon以及一些腳本語言中就有強大的字符串操作功能。

字符串是編程中非常重要的一個數據類型,故而很多語言都對字符串有特殊的處理以便優化其性能以及存儲(比如C#中的字符串不可變性保證了性能,字符串駐留技術照顧了存儲方面的需要),由於這些特殊的處理,故而各各語言中為字符串提供的操作集合嚴重依賴語言設計者對於實現的考慮。

6. 指針和遞歸類型

所謂的遞歸類型,就是可以在其對象中包含一個或多個本類型對象的引用類型。遞歸類型用於構造各種各樣的“鏈接”數據結構,比如樹。

在一些對變量采用引用模型的語言中,很容易在創建這種遞歸類型,因為每個變量都是引用;在一些對變量采用值模型的語言中,定義遞歸類型就需要使用指針的概念,指針就是一種變量,其值是對其他對象的引用。

  1. 在一些語言中,指針被嚴格的限制為只能指向堆里的對象,而創建指針的方式只有一種,那就是調用一個內部功能,在堆中分配一個新對象並返回指向它的地址
  2. 在另一些語言中,可以用“取地址”操作創建指向非堆對象的指針。

我們通常認為指針等同於地址,實際上則不然。指針是一個高級概念,就是對對象的引用;地址是一個低級概念,是內存單元的位置。指針通常通過地址實現,但並不總是這樣,在具有分段存儲器體系結構的機器上,指針可以由一個段標識和一個段內偏移量組成。在那些企圖捕捉所有懸空引用的語言里,指針可能包含一個地址和一個訪問關鍵碼。

對於任何允許在堆里分配新對象的語言,都存在一個問題:若這種對象不在需要了,何時以及以何種方式收回對象占用的空間?對於那些活動時間很短的程序,讓不用的存儲留在那里,可能還可以接受,畢竟在它不活動時系統會負責回收它所使用的任何空間。但是大部分情況下,不用的對象都必須回收,以便騰出空間,如果一個程序不能把不再使用的對象存儲回收,我們就認為它存在“內存泄漏”。如果這種程序運行很長一段時間,那么它可能就會用完所有的空間而崩潰。許多早期的語言要求程序員顯示的回收空間,如C,C++等,另一些語言則要求語言實現自動回收不再使用的對象,如Java,C#以及所有的函數式語言和腳本語言。顯示的存儲回收可以簡化語言的實現,但會增加程序員忘記回收不再使用的對象(造成內存泄漏),或者不當的回收了不該回收的正在使用的對象(造成懸空引用)的可能性。自動回收可以大大簡化程序員的工作,但是為語言的實現帶來了復雜度。

6.1 語法和操作

對指針的操作包括堆中對象的分配和釋放,對指針間接操作以訪問被它們所指的對象,以及用一個指針給另一個指針賦值。這些操作的行為高度依賴於語言是函數式還是命令式,以及變量/名字使用的是引用模型還是值模型。

函數式語言一般對名字采用某種引用模型(純的函數式語言里根本沒有變量和賦值)。函數式語言里的對象傾向於采取根據需要自動分配的方式。

命令式語言里的變量可能采用值模型或引用模型,有時是兩者的某種組合。比如 A=B; 

  1. 值模型: 把B的值放入A。
  2. 引用模型: 使A去引用B所引用的那個對象。

對於引用模型,直截了當的實現方式是讓沒一個變量都表示一個地址,但是這種做法會導致內部類型操作的低效。另一種更好也更常見的方式是根據不同的情況應用不同的模型,對於那些引用可變對象(如樹的節點)的變量采用地址,而那些不可變的對象(如整數,字符)采用實際值。換一種說法,每個變量在語義上都是引用,比如對於整數3的引用實現為存放3的地址還是直接存放3本身,實際上都沒關系,因為“這個3”根本不會變。

Java的實現方式區分了內部類型和用戶定義的類型,對內部類型采用值模型,對用戶定義的類型采用則采用引用模型,C#的默認方式與Java類似,另外還提供一些附加的語言特性,比如“unsafe”可以讓程序員在程序中使用指針。

6.2 懸空引用

[程序設計語言]-02:名字、作用域和約束(Bindings)中我們列舉了對象的3種存儲類別:靜態、棧和堆。靜態對象在程序的執行期間始終是活動的,棧對象在它們的聲明所在的子程序執行期間是活動的,而堆對象則沒有明確定義活動時間。

在對象不在活動時,長時間運行的程序就需要回收該對象的空間,棧對象的回收將作為子程序調用序列的一部分被自動執行。而在堆中的對象,由程序員或者語言的自動回收機制負責創建或者釋放,那么如果一個活動的指針並沒有引用合法的活動對象,這種情況就是懸空引用。比如程序員顯示的釋放了仍有指針引用着的對象,就會造成懸空指針,再進一步假設,這個懸空指針原來指向的位置被其他的數據存放進去了,但是實際卻不是這個懸空指針該指向的數據,如果對此存儲位置的數據進行操作,就會破壞正常的程序數據。

那么如何從語言層面應對這種問題呢?Algol 68的做法是禁止任何指針指向生存周期短於這個指針本身的對象,不幸的是這條規則很難貫徹執行。因為由於指針和被指對象都可能作為子程序的參數傳遞,只有在所有引用參數都帶有隱含的生存周期信息的情況下,才有可能動態的去執行這種規則的檢查。下面列舉了幾種處理方式供參考:

  1. 碑標(tombstones):碑標是一種機制,語言可以借助它捕獲所有指向棧對象或堆對象的懸空引用。
    這種機制就是不讓指針直接引用對象,而是引近另一層間接的操作。在堆里分配對象時(或當指針要指向棧里的對象時),運行系統就分配一個碑標,讓指針里包含這個碑標的地址,在碑標里存放該對象的地址。在對象被回收時,修改碑標使之保存一個不是合法地址的值(通常是0)。對於堆對象而言,釋放對象時很容易把碑標改為不合法的地址;而對於棧里的對象,在退出子程序時需要找到當前棧幀中的對象關聯的那些碑標。碑標技術的空間和時間代價都可能是非常高的(碑標空間的開銷,合法性檢查,雙重的間接操作)。
  2. 鎖和鑰匙:鎖和鑰匙是碑標的一種替代技術,其缺點是只能用於堆對象,而且只為懸空引用提供了一定概率上的保護。
    它的機制是讓每個指針里都包含一對信息,一個地址和一個鑰匙。堆里每個對象的開始是一個鎖。指向堆對象的指針合法的條件就是指針和鑰匙與對象的鎖匹配。每新建一個對象,都生成一對新鑰匙和鎖。這種機制也會引起顯著的開銷(鑰匙的空間開銷,指針訪問時的比較的代價,同時也需要更長的指令序列)。

為了使時間和空間的開銷最小,大多數編譯器默認生成的代碼都不包含對懸空引用的檢查。

6.3 廢料收集

對程序員而已,顯示釋放堆對象是很沉重的負擔,也是程序出錯的主要根源之一,為了追蹤對象的生存軌跡所需的代碼,會導致程序更難設計、實現,也更難維護。一種很有吸引力的方案就是讓語言在實現層面去處理這個問題。隨着時間的推移,自動廢料收集回收都快成了大多數新生語言的標配了,雖然它的有很高的代價,但也消除了去檢查懸空引用的必要性了。關於這方面的爭執集中在兩方:以方便和安全為主的一方,以性能為主的另一方。這也說明了一件事,編程中的很多地方的設計,架構等等方面都是在現實中做出權衡。

7. 總結

本文從語言為何需要類型系統出發,解釋了類型系統為語言提供了那些有價值的用途:1是為許多操作提供隱含的上下文,使程序員在許多情況下不必顯示的描述這種上下文2是使得編譯器可以捕捉更廣泛的各種各樣的程序錯誤。然后介紹了類型系統的三個重要規則:類型等價類型相容類型推理。以此3個規則推導出的強類型(絕不允許把任何操作應用到不支持該操作的對象上)弱類型以及靜態類型化(在編譯階段貫徹實施強類型的性質)動態類型化的性質以及在對語言的使用方面的影響。以及后續介紹了語言中常見的一些數據類型的用途以及語言在實現這種類型方面所遇到的問題以及其大致的實現方式。最后則把重點放在了指針的概念上,以及由於語言引入指針而引發的各種問題以及其處理方式。

end。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM