JDK 1.5版本包含了Java語法方面的主要改進。
自從Java 1.0版本首次受到開發人員歡迎以來,Java語言的語法就沒有發生過太大的變化。雖然1.1版本增加了內部類和匿名內部類,1.4版本增加了帶有新的assert關鍵字的assertion(斷定)功能,但Java語法和關鍵字仍然保持不變--像編譯時常量一樣處于靜態。它將通過J2SE 1.5(代號Tiger)發生改變。
過去的J2SE版本主要關注新類和性能,而Tiger的目標則是通過使Java編程更易于理解、對開發人員更為友好、更安全來增強Java語言本身,同時最大限度地降低與現有程序的不兼容性。該語言中的變化包括generics(泛化)、autoboxing、一個增強的“for”循環、 typesafe enums(類型安全的枚舉類型)、一個靜態導入工具(static import facility)和varargs。
通過generics來改進類型檢查
generics使你能夠指定一個集合中使用的對象的實際類型,而不是像過去那樣只是使用Object。generics也被稱為“參數化類型”,因為在generics中,一個類的類型接受影響其行為的類型變量。
generics并不是一個新概念。C++中有模板,但是模板非常復雜并且會導致代碼膨脹。C++編碼人員能夠僅使用C++模板,通過一些小的技巧來執行階乘函數,然后看著編譯器生成C++源代碼來處理模板調用。Java開發人員已經從C++語言中學到了很多關于generics的知識,并經過了足夠長時間的實踐,知道如何正確使用它們。Tiger的當前計劃是從健壯的Generic Java (GJ)方案演變而來的。GJ方案的口號是“使Java的類型簡化、再簡化。”
為了了解generics,讓我們從一個不使用generics的例子開始。下面這段代碼以小寫字母打印了一個字符串集合:
//獲得一個字符串集合 public void lowerCase(Collection c) { Iterator itr = c.iterator(); while (itr.hasNext()) { String s = (String) itr.next(); System.out.println(s.toLowerCase()); } }
這個方法不保證只接收字符串。編程人員負責記住傳給這個方法什么類型的變量。Generics通過顯式聲明類型來解決這個問題。Generics證明并執行了關于集合包含什么東西的規則。如果類型不正確,編譯器就會產生一個錯誤。在下面的改寫代碼中,注意Collection和Iterator是如何聲明它們只接收字符串對象的:
public void lowerCase( Collection<String> c) { Iterator<String> itr = c.iterator(); while (itr.hasNext()) { System.out.println( itr.next().toLowerCase()); } }
現在,該代碼包含了更強大的類型,但它仍然包含許多鍵盤類型。我們將在后面加以介紹。注意,你可以存儲類型參數的任何子類型。接下來,我們將使用這個特性draw()一個形狀集合。
// 獲得孩子集合... public void drawAll(Collection<Shape> c) { Iterator<Shape> itr = c.iterator(); while (itr.hasNext()) { itr.next().draw(); } }
尖括號中的值被稱為類型變量。參數化類型能夠支持任何數量的類型變量。例如,java.util.Map就支持兩個類型變量--一個用于鍵類型,一個用于值類型。下面的例子使用了一個帶有指向一列元素對象的字符串查找鍵的map:
public static void main(String[] args) { HashMap<String, List<Element>> map = new HashMap<String, List<Element>>(); map.put("root", new ArrayList<Element>()); map.put("servlet", new LinkedList<Element>()); }
這個類定義聲明了它支持多少個類型變量。類型參數的數量必須精確地與所期望的相匹配。而且,類型變量一定不能是原始類型(primitive types)。
List<String, String> // takes one List<int> // 無效的,原始類型
即使在期望使用一個普通類型(raw type)的時候,你也可以使用一個參數化類型。當然,你也可以反過來做,但這么做會收到一條編譯時警告:
public static void oldMethod(List list) { System.out.println(list.size()); } public static void main(String[] args) { List<String> words = new ArrayList<String>(); oldMethod(words); // 沒問題 }
這就實現了輕松的向后兼容:接受一個原始列表的老方法能夠直接接受一個參數化List<String>。接受參數化List<String>的新方法也能夠接受一個原始列表,但是因為原始列表不聲明或執行相同的類型約束,所以這個動作會觸發一個警告。可以保證的是:如果在編譯時你沒有得到名為unchecked(未檢查)的警告,那么在運行時編譯器生成的強制類型轉換(cast)將不會失敗。
有趣的是,參數化類型和普通類型被編譯為相同的類型。沒有專門的類來指定這一點,使用編譯器技巧就可以完成這一切。instanceof檢查可以證明這一點。
words instanceof List // true words instanceof ArrayList //true words instanceof ArrayList<String> // true
這個檢查產生了一個問題:“如果它們是相同的類型,這種檢查能起多大作用?”這是一條用墨水而不是用血寫的約束。這段代碼將產生一個編譯錯誤,因為你不能向List<String>中添加新的Point:
List<String> list = new ArrayList<String>(); list.add(new Point()); // 編譯錯誤
但是這段代碼被編譯了!
List<String> list = new ArrayList<String>(); ((List)list).add(new Point());
它將參數化類型強制轉換為一個普通類型,這個普通類型是合法的,避免了類型檢查,但正如前面所解釋的那樣,卻產生了一個調用未檢查的警告:
warning: unchecked call to add(E) as a member of the raw type java.util.List ((List)list).add(new Point()); ^
寫一個參數化類型
Tiger提供了一個寫參數化類型的新語法。下面顯示的Holder類可以存放任意引用類型。這樣的類很便于使用,例如,通過引用語義支持CORBA傳遞,而不需要生成單獨的Holder類:
public class Holder<A> { private A value; Holder(A v) { value = v; } A get() { return value; } void set(A v) { value = v; } }
使用一個參數化的Holder類型,你能夠安全地得到和設置數據,而不需進行強制類型轉換:
public static void main(String[] args) { Holder<String> holder = new Holder<String>("abc"); String val = holder.get(); // "abc" holder.set("def"); }
“A”類型參數名可以是任何標準的變量名。它通常是一個單一的大寫字母。你也可以聲明類型參數必須能夠擴展另一個類,如下所示:
// 也可以 public class Holder<C extends Child>
關于是否能夠聲明任何其他的類型參數仍然存在爭議。你對generics了解的越深,你需要的特殊規則就越多,但是特殊規則越多,generics就會越復雜。
Tiger中設計用來保存線程局部變量(thread local variable)的核心類java.lang.ThreadLocal,將可能變得與下面這個 Holder類的作用類似:
public class ThreadLocal<T> { public T get(); public void set(T value); }
我們也將看見java.lang.Comparable的變化,允許類聲明與它們相比較的類型:
public interface Comparable<T> { int compareTo(T o); } public final class String implements Comparable<String> { int compareTo(String anotherString); }
Generics不僅僅用于集合,它們有更為廣泛的用途。例如,雖然你不能基于參數化類型(因為它們與普通類型沒有什么不同)進行捕捉(catch),但是你可以拋出(throw)一個參數化類型。換句話說,你可以動態地決定throws語句中拋出什么。
下面這段令人思維混亂的代碼來自generics規范。該代碼通過擴展Exception的類型參數E定義了一個 Action接口。Action類有一個拋出作為E 出現的任何類型的run()方法。然后,AccessController類定義一個接受Action<E>的靜態exec()方法,并聲明exec()拋出E。聲明該方法自身是參數化的需要該方法標記(method signature)中的特殊<E extends Exception>。
現在,事情變得有點棘手了。main()方法調用在Action 實例(作為一個匿名內部類實現)中傳遞的AccessController.exec()方法。該內部類被參數化,以拋出一個 FileNotFoundException。main()方法有一個捕捉這一異常類型的catch語句。如果沒有參數化類型,你將不能確切地知道run()會拋出什么。有了參數化類型,你能夠實現一個泛化的Action類,其中run()方法可以任意實現,并可以拋出任意異常(Exception):
interface Action<E extends Exception> { void run() throws E; } class AccessController { public static <E extends Exception> void exec(Action<E> action) throws E { action.run(); } } public class Main { public static void main(String[] args) { try { AccessController.exec( new Action<FileNotFoundException>() { public void run() throws FileNotFoundException { // someFile.delete(); } }); } catch (FileNotFoundException f) { } } }
協變返回類型
下面進行一個隨堂測驗:下面的代碼是否能夠成功編譯?
class Fruit implements Cloneable { Fruit copy() throws CloneNotSupportedException { return (Fruit)clone(); } } class Apple extends Fruit implements Cloneable { Apple copy() throws CloneNotSupportedException { return (Apple)clone(); } }
答案:該代碼在J2SE 1.4中不能編譯,因為改寫一個方法必須有相同的方法標記(包括返回類型)作為它改寫的方法。然而,generics有一個叫做協變返回類型的特性,使上面的代碼能夠在Tiger中進行編譯。該特性是極為有用的。
例如,在最新的JDOM代碼中,有一個新的Child接口。Child有一個detach()方法,返回從其父對象分離的Child對象。在Child接口中,該方法當然返回Child:
public interface Child { Child detach(); // etc }
當Comment類實現detach()時,它總是返回一個Comment,但如果沒有協變返回類型,該方法聲明必須返回Child:
public class Comment { Child detach() { if (parent != null) parent.removeContent(this); return this; } }
這意味著調用者一定不要將返回的類型再向下返回到一個Comment。協變返回類型允許Comment 中的detach()返回 Comment。只要Comment是Child的子類就行。除了能夠返回Document的DocType和能夠返回Element的EntityRef,該特性對立刻返回Parent 的Child.getParent()方法也能派上用場。協變返回類型將確定返回類型的責任從類的用戶(通過強制類型轉換確認)轉交給類的創建者,只有創建者知道哪些類型彼此之間是真正多態的。 這使應用編程接口(API)的用戶使用起來更容易,但卻稍微增加了API設計者的負擔。
Autoboxing
Java有一個帶有原始類型和對象(引用)類型的分割類型系統。原始類型被認為是更輕便的,因為它們沒有對象開銷。例如,int[1024]只需要4K存儲空間,以及用于數組自身的一個對象。然而,引用類型能夠在不允許有原始類型的地方被傳遞,例如,傳遞到一個List。這一限制的標準工作場景是在諸如list.add(new Integer(1))的插入操作之前,將原始類型與其相應的引用類型封裝(box或wrap)在一起,然后用諸如((Integer)list.get(0)).intValue()的方法取出(unbox)返回值。
新的 autoboxing特性使編譯器能夠根據需要隱式地從int轉換為Integer,從char 轉換為Character等等。auto-unboxing進行相反的操作。在下面的例子中,我不使用autoboxing計算一個字符串中的字符頻率。我構造了一個應該將字符型映射為整型的Map,但是由于Java的分割類型系統,我不得不手動管理Character和Integer封箱轉換(boxing conversions)。
public static void countOld(String s) { TreeMap m = new TreeMap(); char[] chars = s.toCharArray(); for (int i=0; i < chars.length; i++) { Character c = new Character(chars[i]); Integer val = (Integer) m.get(c); if (val == null) val = new Integer(1); else val = new Integer(val.intValue()+1); m.put(c, val); } System.out.println(m); }
Autoboxing使我們能夠編寫如下代碼:
public static void countNew(String s) { TreeMap<Character, Integer> m = new TreeMap<Character, Integer>(); char[] chars = s.toCharArray(); for (int i=0; i < chars.length; i++) { char c = chars[i]; m.put(c, m.get(c) + 1); // unbox } System.out.println(m); }
這里,我重寫了map,以使用generics,而且我讓autoboxing給出了map能夠直接存儲和檢索char 和int值。不幸的是,上面的代碼有一個問題。如果m.get(c)返回空值(null),會發生什么情況呢?怎樣取出null值?在搶鮮版(early access release)(參見下一步)中,取出一個空的Integer 會返回0。自搶鮮版起,專家組決定取出null值應該拋出一個NullPointerException。因此,put()方法需要被重寫,如下所示:
m.put(c, Collections.getWithDefault( m, c) + 1);
新的Collections.getWithDefault()方法執行get()函數,在該方法中,如果值為空值,它將返回期望類型的默認值。對于一個int類型來說,則返回0。
雖然autoboxing有助于編寫更好的代碼,但我的建議是謹慎地使用它。封箱轉換仍然會進行并仍然會創建許多包裝對象(wrapper-object)實例。當進行計數時,采用將一個int與一個長度為1的的 int數組封裝在一起的舊方法更好。然后,你可以將該數組存儲在任何需要引用類型的地方,獲取intarr[0]的值并使用intarr[0]++遞增。你甚至不必再次調用put(),因為會在適當的位置產生增量。使用這一方法和其他一些方法,你能夠更有效地進行計數。使用下面的算法,執行100萬個字符的對時間會從650毫秒縮短為30毫秒:
public static void countFast(String s) { int[] counts = new int[256]; char[] chars = s.toCharArray(); for (int i=0; i < chars.length; i++) { int c = (int) chars[i]; counts[c]++; // no object creation } for (int i = 0; i < 256; i++) { if (counts[i] > 0) { System.out.println((char)i + ":" + counts[i]); } } }
在C#中,我們可以看到一個類似但稍微不同的方法。C#有一個統一的類型系統,在這個系統中值類型和引用類型都擴展System.Object。但是,你不能直接看到這一點,因為C#為簡單的值類型提供了別名和優化。int是System.Int32的一個別名,short是System.Int16的一個別名,double是System.Double的一個別名。在C#中,你能夠調用“int i = 5; i.ToString();”,它是完全合法的。這是因為每個值類型都有一個在它被轉換為引用類型時創建的相應隱藏引用類型(在值類型被轉換為一個引用類型時創建的)。
int x = 9; object o = x; //創建了引用類型 int y = (int) o;
當基于一個不同的類型系統時,最終結果與我們在J2SE 1.5中看到的非常接近。
?
對于循環的增強
還記得前面的這個例子么?
public void drawAll(Collection<Shape> c) { Iterator<Shape> itr = c.iterator(); while (itr.hasNext()) { itr.next().draw(); } }
你再也不用輸入這么多的文字了!這里是Tiger版本中的新格式。
public void drawAll(Collection<Shape> c) { for (Shape s : c) { s.draw(); } }
你可以閱讀這樣一段代碼“foreach Shape s in c”。我們注意到設計者非常聰明地避免添加任何新的關鍵字。考慮到很多人都用“in”來輸入數據流,我們對此應該感到非常高興。編譯器將該新的語法自動擴展到其迭代表中。
for (Iterator<Shape> $i = c.iterator(); $i.hasNext(); ) { Shape s = $i.next(); s.draw(); }
你可以使用該語法來對普通(raw,非參數化的)類型進行迭代,但是編譯器會輸出一個警告,告訴你必須的類型轉換可能會失敗。你可以在任何數組和對象上使用“foreach”來實現新的接口java.lang.Iterable。
public interface Iterable<T> { SimpleIterator<T> iterator(); } public interface SimpleIterator<T> { boolean hasNext(); T next(); }
java.lang中的新的接口避免對java.util的任何語言依賴性。Java語言在java.lang之外必須沒有依賴性。要注意通過next()方法來更巧妙地使用協變返回類型。需要說明的一點是,利用該“foreach”語法和SimpleIterator接口,就會喪失調用iterator.remove()的能力。如果你還需要該項能力,則必須你自己迭代該集合。
與C#對比一下,我們會看到相似的語法,但是C#使用“foreach”和“in”關鍵字,從最初版本開始它們就被作為保留字。
// C# foreach (Color c in colors) { Console.WriteLine(c); }
C#的“foreach”對任何集合(collection)或者數組以及任何可列舉的實現都有效。我們再一次看到了在Java和C#之間的非常相似之處。
(類型安全的枚舉類型)typesafe enum
enums 是定義具有某些命名的常量值的類型的一種方式。你在C,C++中已經見過它們,但是顯然,它們曾經在Java中不用。現在,經過了八年之后,Java重又采用它們,并且大概比先前的任何語言都使用得更好。讓我們首先來看看先前我們是如何解決enum問題的?不知道你有沒有編寫過如下代碼?
class PlayingCard { public static final int SUIT_CLUBS = 0; public static final int SUIT_DIAMONDS = 1; public static final int SUIT_HEARTS = 2; public static final int SUIT_SPADES = 3; // ... }
這段代碼很簡單也很常見,但是它有問題。首先,它不是類型安全的(typesafe)。可以給一個方法傳遞文字“5”來獲取一個suit,并且將被編譯。同時,這些值用這些常量直接被編譯成每個類。Java通過這些常量進行這種"內聯"(inlining)來達到優化的目的,但其風險在于,如果對這些值重新排序并且只重新編譯該類,則其他類將會錯誤地處理這些suits。 而且,該類型是非常原始的,它不能被擴展或者增強,同時如果你輸出這些值中的一個,你只會得到一個含意模糊的整型量,而不是一個好記的有用名字。這種方法非常簡單,這也正是我們為什么要這樣做的原因,但是它并不是最好的方法。所以,也許需要嘗試一下下面的這個方法:
class PlayingCard { class Suit { public static final Suit CLUBS = new Suit(); public static final Suit DIAMONDS = new Suit(); public static final Suit HEARTS = new Suit(); public static final Suit SPADES = new Suit(); protected Suit() { } } }
它是類型安全的(typesafe)且更加具有可擴展性,并且,屬于面向對象設計的類。然而,這樣簡單的一種方法并不支持序列化,沒有合法值的列表,無法將這些值排序,并且,不能作為一個有意義的字符串來打印一個值。你當然可以添加這些特性,Josh Bloch在他的Effective Java一書中(第五章,第21條)為我們準確展示了如何解決這些問題。然而,你最終得到的是幾頁蹩腳的代碼。
Java新的enum特性具有一個簡單的單行語法:
class PlayingCard { public enum Suit { clubs, diamonds, hearts, spades } }
被稱之為enum(枚舉)類型的該suit,對應于每個enum常量都有一個成員項。每個enum類型都是一個實際類,它可以自動擴展新類java.lang.Enum。編譯器賦予enum類以有意義的String()、 hashCode(), 和equals() 方法, 并且自動提供Serializable(可序列化的)和Comparable(可比較的)能力。令人高興地是enum類的聲明是遞歸型的:
public class Enum<E extends Enum<E>> implements Comparable<E>, Serializable {
使用最新的enum類型可以提供很多好處:包括:比整型操作(int operations)更好的性能,編譯時更好的類型安全性,不會被編譯到客戶端并且可以被重新命名和排序的常量,打印的值具有含意清晰的信息,能夠在集合(collections)甚至switch中被使用,具有添加域(fields)和方法的能力,以及實現任意接口的能力。
每個enum具有一個字符串名字和一個整型順序號值:
out.println(Suit.clubs); // "clubs" out.println(Suit.clubs.name); // "clubs" out.println(Suit.clubs.ordinal); // 0 out.println(Suit.diamonds.ordinal); // 1 Suit.clubs == Suit.clubs // true Suit.clubs == Suit.diamonds // false Suit.clubs.compareTo(Suit.diamonds) // -1
enum可以擁有構造器和方法。甚至一個main()方法都是合法的。下面的例子將值賦給羅馬數字:
public enum Roman { I(1), V(5), X(10), L(50), C(100), D(500), M(1000); private final int value; Roman(int value) { this.value = value; } public int value() { return value; } public static void main(String[] args) { System.out.println(Roman.I); } }
非常奇怪的是,不能將序列數值賦給一個enum,比如說“enum Month{jan=1,feb=2,….}”。 然而,卻可以給enum常量添加行為。比如說,在JDOM中,XMLOutputter支持數種空白處理方法。如果JDOM是參照J2SE1.5構建的,那么這些方法就可以用一個enum來定義,并且enum類型本身可以具有這種處理行為。不管這種編碼模式是不是會被證明是有用的,我們都會逐漸了解它。肯定這是一個異常有趣的概念。
public abstract enum Whitespace { raw { String handle(String s) { return s; } }, trim { String handle(String s) { return s.trim(); } }, trimFullWhite { String handle(String s) { return s.trim().equals("") ? "":s; } }; abstract String handle(String s); public static void main(String[] args) { String sample = " Test string "; for (Whitespace w : Whitespace.VALUES) System.out.println(w + ": '" + w.handle(sample) + "'"); }}
很少有公開的出版物談及Java新的enum。enum常量名是不是都應該都大寫?對于常量來說,這是一個標準,但是規范指出小寫名稱“于更好的字符串格式,一般應該避免使用定制的toString方法。”另外,名字和順序號該是域還是方法?這是封裝方法一再引起爭論的問題。在向J2SE1.5添加關鍵字方面,該特性也落了一個不太好的名聲。令人傷心的是,它還是一個通常被用作存儲Enumerator(計數器)實例的詞。如果你已經在你的代碼中使用了“enum”,那么在你為J2SE 1.5的應用編譯之前,必須修改它。現在,你已得到了充分的警示。
讓我們看一下C#,所有的enum都擴展成System.Enum。每個enum都具有可以被賦值的整型(或者字節型或者其他類型)值。enum還擁有靜態方法,以便從字符串常量來初始化enum,獲取有效值列表,從而,可以看到某個值是不是被支持。通過使用[flags]屬性來標記一個enum,你可以確保值支持位屏蔽,并且系統負責打印被屏蔽的值的有用輸出:
// C# [Flags] public enum Credit : byte { Visa = 1, MC = 2, Discover = 4 } Credit accepted = Credit.Visa | Credit.MC; c.WriteLine(accepted); // 3 c.WriteLine(accepted.Format());//"Visa|MC"
靜態導入
靜態導入使得我們可以將一套靜態方法和域放入作用域(scope)。它是關于調用的一種縮寫,可以忽略有效的類名。比如說,對Math.abs(x)的調用可以被簡單地寫成 abs(x)。為了靜態地導入所有的靜態域和方法,我們可以使用“import static java.lang.Math”,或者指定要導入的具體內容,而使用“import static java.lang.System.out”--在這里沒有什么令人激動的新特性,只是縮寫而已。它讓你可以不用Math而來完成math(數字計算)。
import static java.lang.Math.*; import static java.lang.System.out; public class Test { public static void main(String[] args) { out.println(abs(-1) * PI); } }
注意,“static”關鍵字的重用是為了避免任何新的關鍵詞。語言越成熟,對于“static”關鍵詞的使用就越多。如果在靜態成員之間發生沖突的話,就會出現含混的編譯錯誤,這一點跟類的導入一樣。是否將java.lang.Math.* 作為固有的導入引發了一定的爭論,不過在獲知其將會觸發含混的錯誤之后,這種爭論不會再發生了。
Varargs
“varargs”表示“參數的變量”,存在于C語言中,并且支持通用的printf()和scanf()函數。 在Java中,我們通過編寫一些接受Object[]、List、Properties(屬性)的方法以及可以描述多個值的其它簡單數據結構--比如說,Method.invoke(Object obj,Object[] args--來模擬這一特性。)。這要求調用程序將數據封裝到這種單一的容器結構中。varargs允許調用程序傳遞值的任意列表,而編譯器會為接收程序將其轉化為數組。其語法就是在參數聲明中的參數名之后添加“...”,以便使其成為vararg。它必須是最后一個參數--比如說,編寫一個sum()函數以便將任意數量的整數相加:
out.println(sum(1, 2, 3)); public static int sum(int args...) { int sum = 0; for (int x : args) { sum += x; } return sum; }
在搶鮮版本中,vararg符號使用方括號,就像sum(int[] args...)。然而,在之后的討論中,根據James Gosling的提議,方括號被去掉了。在這里的例子中,我們不使用方括號,但是如果你需要在搶鮮版本中使用這些代碼的話,就需要將方括號添加上去。借助autoboxing,可以通過接受一個Object args…,可以接受任何類型的參數,包括原始類型。這與printf()類型的一些方法一樣,它們接受任何數量的所有類型的參數。實際上,該Tiger版本可以使用這一特性通過format方法(其行為與printf()一樣)來提供一個Formattable(可格式化)的接口。這是我們在以后的文章中將要討論的話題。目前,我們只編寫簡單的printf():
public static void printf(String fmt, int args...) { int i = 0; for (char c : fmt.toCharArray()) { out.print(c == '%' ? args[i++] : c); } } public static void main(String[] args) { printf("My values are % and % ", 1, 2); } 在Tiger版本中,你會發現采用一個新格式的invoke()函數: invoke(Object obj, Object args...). 這看上去更加自然。結論
J2SE1.5版努力使Java的編程更加簡便、安全和更加富有表現力。這些特性和諧完美的被結合在一起。如果你跟我一樣,總是喜歡用老的“for”循環,你肯定希
望你擁有Tiger。然而,需要記住的是,該規范并沒有最終完成,很多地方還需要修改。管理這些變化的專家小組(JSR-14,JSR-175以及JSR-201)會在2003年
年末的beta版本發布之前,以及預期在2004年發布最終版發布之前,會做出很多修改。然而,Sun表達了對JavaOne的信心,認為總體上主要原則不會改變太多。
如果你想體驗一下,那么你可以從下面的站點獲取搶鮮版本。 從中你會找到可能從任何一個預覽版軟件都會遇到的錯誤,但是也會看到很多激動人心的新特性。
我強烈建議你嘗試一下。