首頁 > 後端開發 > C#.Net教程 > C# 程式設計師最常犯的 10 個錯誤

C# 程式設計師最常犯的 10 個錯誤

伊谢尔伦
發布: 2016-11-24 13:15:00
原創
1140 人瀏覽過

 關於C#

  C#是達成微軟公共語言運作庫(CLR)的少數語言中的一種。達成CLR的語言可以受益於其帶來的特性,如跨語言整合、異常處理、安全性增強、零件組合的簡易模型以及調試和分析服務。作為現代的CLR語言,C#是應用最廣泛的,其應用場景針對Windows桌面、行動手機以及伺服器環境等複雜、專業的開發專案。

  C#是種物件導向的強型別語言。 C#在編譯和運行時都有的強類型檢查,使在大多數典型的程式錯誤能夠被儘早地發現,而且位置定位相當精準。相較於那些不拘泥類型,在違規操作很久後才報出可追蹤到莫名其妙錯誤的語言,這可以為程式設計師節省很多時間。然而,許多程式設計師有意或無意地拋棄了這個檢測的有點,這導致本文中討論的一些問題。

 關於本文

  本文介紹了10種最常見的程式錯誤,或是C#程式設計師要避免的陷阱。

  儘管本文中討論的錯誤是C#環境下的,但對其他達成CLR或使用框架類別庫(FCL)的語言也相關(FCL)。

 常見錯誤 #1: 像使用值一樣使用參考或過來用

  C++以及許多其他語言的程式設計師習慣於控制他們分配給變數的值是否為簡易的值或現有物件的引用。在C#中呢,這將由寫該物件的程式設計師決定,而不是由實例化該物件並對它進行變數賦值的程式設計師決定。這是新手C#程式設計師們的共同「問題」。

  如果你不知道你正在使用的物件是否是值類型或引用類型,你可能會遇到一些驚喜。例如:

Point point1 = new Point(20, 30);
Point point2 = point1;
point2.X = 50;
Console.WriteLine(point1.X);       // 20 (does this surprise you?)
Console.WriteLine(point2.X);       // 50
 
Pen pen1 = new Pen(Color.Black);
Pen pen2 = pen1;
pen2.Color = Color.Blue;
Console.WriteLine(pen1.Color);     // Blue (or does this surprise you?)
Console.WriteLine(pen2.Color);     // Blue
登入後複製

如你所見,儘管Point和Pen物件的創建方式相同,但是當一個新的X的座標值被分配到point2時, point1的值保持不變 。而當一個新的color值被分配到pen2,pen1也隨之改變。因此,我們可以推斷point1和point2每個都包含自己的Point物件的副本,而pen1和pen2引用了同一個Pen物件 。如果沒有這個測試,我們怎麼能夠知道這個原理?

  一種辦法是去看一下物件是如何定義的(在Visual Studio中,你可以把遊標放在物件的名字上,並按下F12鍵)

 public struct Point { … }     // defines a “value” type
  public class Pen { … }        // defines a “reference” type
登入後複製

 如上所示,在C#中,struct關鍵字是用來定義一個值類型,而class關鍵字是用來定義參考類型的。 對於那些有C++程式設計背景人來說,如果被C++和C#之間某些類似的關鍵字搞混,可能會對以上這種行為感到很驚訝。

  如果你想要依賴的行為會因值類型和引用類型而異,舉例來說,如果你想把一個物件作為參數傳給一個方法,並在這個方法中修改這個物件的狀態。你一定要確保你在處理正確的類型物件。

  常見的錯誤#2:誤會未初始化變數的預設值

  在C#中,值得類型不能為空。根據定義,值的型別值,甚至初始化變數的值型別必須有一個值。這就是所謂的該類型的預設值。這通常會導致以下,當意想不到的結果時,檢查一個變數是否未初始化:

class Program {
      static Point point1;      static Pen pen1;      static void Main(string[] args) {
          Console.WriteLine(pen1 == null);      // True
          Console.WriteLine(point1 == null);    // False (huh?)
      }
  }
登入後複製

為什麼不是【point 1】空?答案是,點是一個值類型,和預設值點(0,0)一樣,沒有空值。未能認識到這是一個非常簡單和常見的錯誤,在C#中

  很多(但是不是全部)值類型有一個【IsEmpty】屬性,你可以看看它等於預設值:

Console.WriteLine(point1.IsEmpty);        // True
登入後複製

 當你檢查一個變數是否已經初始化,確保你知道值未初始化是變數的類型,將會在預設情況下,不為空值。

  常见错误 #3: 使用不恰当或未指定的方法比较字符串

  在C#中有很多方法来比较字符串。

  虽然有不少程序员使用==操作符来比较字符串,但是这种方法实际上是最不推荐使用的。主要原因是由于这种方法没有在代码中显示的指定使用哪种类型去比较字符串。

  相反,在C#中判断字符串是否相等最好使用Equals方法:

public bool Equals(string value);  public bool Equals(string value, StringComparison comparisonType);
登入後複製

  第一个Equals方法(没有comparisonType这参数)和使用==操作符的结果是一样的,但好处是,它显式的指明了比较类型。它会按顺序逐字节的去比较字符串。在很多情况下,这正是你所期望的比较类型,尤其是当比较一些通过编程设置的字符串,像文件名,环境变量,属性等。在这些情况下,只要按顺序逐字节的比较就可以了。使用不带comparisonType参数的Equals方法进行比较的唯一一点不好的地方在于那些读你程序代码的人可能不知道你的比较类型是什么。

  使用带comparisonType的Equals方法去比较字符串,不仅会使你的代码更清晰,还会使你去考虑清楚要用哪种类型去比较字符串。这种方法非常值得你去使用,因为尽管在英语中,按顺序进行的比较和按语言区域进行的比较之间并没有太多的区别,但是在其他的一些语种可能会有很大的不同。如果你忽略了这种可能性,无疑是为你自己在未来的道路上挖了很多“坑”。举例来说:

string s = "strasse";
   
  // outputs False:
  Console.WriteLine(s == "straße");
  Console.WriteLine(s.Equals("straße"));
  Console.WriteLine(s.Equals("straße", StringComparison.Ordinal));
  Console.WriteLine(s.Equals("Straße", StringComparison.CurrentCulture));        
  Console.WriteLine(s.Equals("straße", StringComparison.OrdinalIgnoreCase));
   
  // outputs True:
  Console.WriteLine(s.Equals("straße", StringComparison.CurrentCulture));
  Console.WriteLine(s.Equals("Straße", StringComparison.CurrentCultureIgnoreCase));
登入後複製

 最安全的实践是总是为Equals方法提供一个comparisonType的参数。

  下面是一些基本的指导原则:

  当比较用户输入的字符串或者将字符串比较结果展示给用户时,使用本地化的比较(CurrentCulture 或者CurrentCultureIgnoreCase)。

  当用于程序设计的比较字符串时,使用原始的比较(Ordinal 或者 OrdinalIgnoreCase)

  InvariantCulture和InvariantCultureIgnoreCase一般并不使用,除非在受限的情境之下,因为原始的比较通常效率更高。如果与本地文化相关的比较是必不可少的,它应该被执行成基于当前的文化或者另一种特殊文化的比较。

  此外,对Equals 方法来说,字符串也通常提供了Compare方法,可以提供字符串的相对顺序信息而不仅仅中测试是否相等。这个方法可以很好适用于<, <=, >和>= 运算符,对上述讨论同样适用。

  常见误区 #4: 使用迭代式 (而不是声明式)的语句去操作集合

  在C# 3.0中,LINQ的引入改变了我们以往对集合对象的查询和修改操作。从这以后,你应该用LINQ去操作集合,而不是通过迭代的方式。

  一些C#的程序员甚至都不知道LINQ的存在,好在不知道的人正在逐步减少。但是还有些人误以为LINQ只用在数据库查询中,因为LINQ的关键字和SQL语句实在是太像了。

  虽然数据库的查询操作是LINQ的一个非常典型的应用,但是它同样可以应用于各种可枚举的集合对象。(如:任何实现了IEnumerable接口的对象)。举例来说,如果你有一个Account类型的数组,不要写成下面这样:

 decimal total = 0;  foreach (Account account in myAccounts) {    if (account.Status == "active") {
      total += account.Balance;
    }
  }
登入後複製

你只要这样写:

decimal total = (from account in myAccounts
               where account.Status == "active"
                select account.Balance).Sum();
登入後複製

 虽然这是一个很简单的例子,在有些情况下,一个单一的LINQ语句可以轻易地替换掉你代码中一个迭代循环(或嵌套循环)里的几十条语句。更少的代码通常意味着产生Bug的机会也会更少地被引入。然而,记住,在性能方面可能要权衡一下。在性能很关键的场景,尤其是你的迭代代码能够对你的集合进行假设时,LINQ做不到,所以一定要在这两种方法之间比较一下性能。

  #5常见错误:在LINQ语句之中没有考虑底层对象

  对于处理抽象操纵集合任务,LINQ无疑是庞大的。无论他们是在内存的对象,数据库表,或者XML文档。在如此一个完美世界之中,你不需要知道底层对象。然而在这儿的错误是假设我们生活在一个完美世界之中。事实上,相同的LINQ语句能返回不同的结果,当在精确的相同数据上执行时,如果该数据碰巧在一个不同的格式之中。

  例如,请考虑下面的语句:

decimal total=(from accout in myaccouts
where accout.status==‘active"
                   select accout .Balance).sum();
登入後複製

想象一下,该对象之一的账号会发生什么。状态等于“有效的”(注意大写A)?

  好吧,如果myaccout是Dbset的对象。(默认设置了不同区分大小写的配置),where表达式仍会匹配该元素。然而,如果myaccout是在内存阵列之中,那么它将不匹配,因此将产生不同的总的结果。

  等一会,在我们之前讨论过的字符串比较中, 我们看见 == 操作符扮演的角色就是简单的比较. 所以,为什么在这个条件下, == 表现出的是另外的一个形式呢 ?

  答案是,当在LINQ语句中的基础对象都引用到SQL表中的数据(如与在这个例子中,在实体框架为DbSet的对象的情况下),该语句被转换成一个T-SQL语句。然后遵循的T-SQL的规则,而不是C#的规则,所以在上述情况下的比较结束是不区分大小写的。

  一般情况下,即使LINQ是一个有益的和一致的方式来查询对象的集合,在现实中你还需要知道你的语句是否会被翻译成什么比C#的引擎或者是其他表达,来确保您的代码的行为将如预期在运行时。

  常见错误 #6:对扩展方法感到困惑或者被它的形式欺骗

  如同先前提到的,LINQ状态依赖于IEnumerable接口的实现对象,比如,下面的简单函数会合计帐户集合中的帐户余额:

 public decimal SumAccounts(IEnumerable<Account> myAccounts) {      return myAccounts.Sum(a => a.Balance);
  }
登入後複製

 在上面的代码中,myAccounts参数的类型被声明为IEnumerable,myAccounts引用了一个Sum 方法 (C# 使用类似的 “dot notation” 引用方法或者接口中的类),我们期望在IEnumerable接口中定义一个Sum()方法。但是,IEnumerable没有为Sum方法提供任何引用并且只有如下所示的简洁定义:

public interface IEnumerable<out T> : IEnumerable {
      IEnumerator<T> GetEnumerator();
  }
登入後複製

但是Sum方法应该定义到何处?C#是强类型的语言,因此如果Sum方法的引用是无效的,C#编译器会对其报错。我们知道它必须存在,但是应该在哪里呢?此外,LINQ提供的供查询和聚集结果所有方法在哪里定义呢?

  答案是Sum并不在IEnumerable接口内定义,而是一个

  定义在System.Linq.Enumerable类中的static方法(叫做“extension method”)

 namespace System.Linq {
    public static class Enumerable {      ...
      // the reference here to “this IEnumerable<TSource> source” is
      // the magic sauce that provides access to the extension method Sum
      public static decimal Sum<TSource>(this IEnumerable<TSource> source,
                                         Func<TSource, decimal> selector);      ...
    }
  }
登入後複製

 可是扩展方法和其它静态方法有什么不同之处,是什么确保我们可以在其它类访问它?

  扩展方法的显著特点是第一个形参前的this修饰符。这就是编译器知道它是一个扩展方法的“奥妙”。它所修饰的参数的类型(这个例子中的IEnumerable)说明这个类或者接口将显得实现了这个方法。

  (另外需要指出的是,定义扩展方法的IEnumerable接口和Enumerable类的名字间的相似性没什么奇怪的。这种相似性只是随意的风格选择。)

  理解了这一点,我们可以看到上面介绍的sumAccounts方法能以下面的方式实现:

public decimal SumAccounts(IEnumerable<Account> myAccounts) {
      return Enumerable.Sum(myAccounts, a => a.Balance);
  }
登入後複製

 事实上我们可能已经这样实现了这个方法,而不是问什么要有扩展方法。扩展方法本身只是C#的一个方便你无需继承、重新编译或者修改原始代码就可以给已存的在类型“添加”方法的方式。

  扩展方法通过在文件开头添加using [namespace];引入到作用域。你需要知道你要找的扩展方法所在的名字空间。如果你知道你要找的是什么,这点很容易。

  当C#编译器碰到一个对象的实例调用了一个方法,并且它在这个对象的类中找不到那个方法,它就会尝试在作用域中所有的扩展方法里找一个匹配所要求的类和方法签名的。如果找到了,它就把实例的引用当做第一个参数传给那个扩展方法,然后如果有其它参数的话,再把它们依次传入扩展方法。(如果C#编译器没有在作用域中找到相应的扩展方法,它会抛措。)

  對C#編譯器來說,擴展方法是個“語法糖”,使我們能把程式碼寫得更清晰,更易於維護(多數情況下)。顯然,前提是你知道它的用法,否則,它會比較容易讓人迷惑,尤其是一開始。

  應用擴充方法確實有優勢,但也會讓那些對它不了解或認識不正確的開發者頭疼,浪費時間。尤其是在看線上範例程式碼,或者其它已經寫好的程式碼的時候。當這些程式碼產生編譯錯誤(因為它呼叫了那些顯然沒在被呼叫類型中定義的方法),一般的傾向是考慮程式碼是否應用於所引用類別庫的其它版本,甚至是不同的類別庫。很多時間會被花在找新版本,或是被認為「遺失」的類別庫。

  在擴展方法的名字和類別中定義的方法的名字一樣,只是在方法簽名上有微小差異的時候,甚至那些熟悉擴展方法的開發者也偶爾犯上面的錯誤。很多時間會被花在尋找「不存在」的拼字錯誤上。

  在C#中,用擴展方法變得越來越流行。除了LINQ,在另外兩個出自微軟現在被廣泛使用的類別庫Unity Application Block和Web API framework中,也應用了擴充方法,而且還有很多它的。框架越新,用擴展方法的可能性越大。

  當然,你也可以寫你自己的擴充方法。但是必須意識到雖然擴展方法看起來和其它實例方法一樣被調用,但這實際上只是幻。事實上,擴展方法不能存取所擴展類別的私有和保護成員,所以它不能被當作傳統繼承的替代品。

  常見錯誤 #7: 對手頭上的任務使用錯誤的集合類型

  C#提供了大量的集合類型的對象,下面只列出了其中的一部分:

  Array,ArrayList,BitArray,BitVector32,Dictionary< K,V>,HashTable,HybridDictionary,List,NameValueCollection,OrderedDictionary,Queue, Queue,SortedList,Stack, Stack,StringCollection,StringDictionary.

   選擇和沒有足夠的選擇一樣糟糕,集合類型也是這樣。數量眾多的選擇餘地絕對可以確保是你的工作正常運作。但你最好還是花一些時間提前搜尋並了解集合類型,以便選擇一個最適合你需求的集合類型。這最終會使你的程式效能更好,減少出錯的可能性。

  如果有一個集合指定的元素類型(如string或bit)和你正在操作的一樣,你最好優先選擇使用它。當指定對應的元素類型時,這種集合的效率更高。

  為了利用好C#中的類型安全,你最好選擇使用一個泛型接口,而不是使用非泛型的藉口。泛型介面中的元素類型是你在宣告物件時指定的類型,而非泛型中的元素是object類型。當使用一個非泛型的介面時,C#的編譯器不能對你的程式碼進行類型檢查。同樣,當你在操作原生類型的集合時,使用非泛型的介面會導致C#對這些類型進行頻繁的裝箱(boxing)和拆箱(unboxing)操作。和使用指定了合適類型的泛型集合相比,這會帶來很明顯的效能影響。

  另一個常見的陷阱是自己去實作一個集合類型。這並不是說永遠不要這樣做,你可以透過使用或擴展.NET提供的一些被廣泛使用的集合類型來節省大量的時間,而不是去重複造輪子。 特別是,C#的C5 Generic Collection Library 和CLI提供了許多額外的集合類型,像是持久化樹狀資料結構,基於堆疊的優先權佇列,哈希索引的數組列表,鍊錶等以及更多。

  常見錯誤#8:遺漏資源釋放

  CLR 託管環境扮演了垃圾回收器的角色,所以你不需要明確釋放已建立物件所佔用的記憶體。事實上,你也不能顯性釋放。 C#中沒有與C++ delete對應的運算子或與C語言中free()函數對應的方法。但這並不意味著你可以忽略所有的使用過的物件。許多物件類型封裝了許多其它類型的系統資源(例如,磁碟文件,資料連接,網路連接埠等等)。保持這些資源使用狀態會急劇耗盡系統的資源,削弱效能並且最終導致程式出錯。

  尽管所有C#的类中都定义了析构方法,但是销毁对象(C#中也叫做终结器)可能存在的问题是你不确定它们时候会被调用。他们在未来一个不确定的时间被垃圾回收器调用(一个异步的线程,此举可能引发额外的并发)。试图避免这种由垃圾回收器中GC.Collect()方法所施加的强制限制并非一种好的编程实践,因为可能在垃圾回收线程试图回收适宜回收的对象时,在不可预知的时间内致使线程阻塞。

  这并意味着最好不要用终结器,显式释放资源并不会导致其中的任何一个后果。当你打开一个文件、网络端口或者数据连接时,当你不再使用这些资源时,你应该尽快的显式释放这些资源。

  资源泄露几乎在所有的环境中都会引发关注。但是,C#提供了一种健壮的机制使资源的使用变得简单。如果合理利用,可以大增减少泄露出现的机率。NET framework定义了一个IDisposable接口,仅由一个Dispose()构成。任何实现IDisposable的接口的对象都会在对象生命周期结束调用Dispose()方法。调用结果明确而且决定性的释放占用的资源。

  如果在一个代码段中创建并释放一个对象,却忘记调用Dispose()方法,这是不可原谅的,因为C#提供了using语句以确保无论代码以什么样的方式退出,Dispose()方法都会被调用(不管是异常,return语句,或者简单的代码段结束)。这个using和之前提到的在文件开头用来引入名字空间的一样。它有另外一个很多C#开发者都没有察觉的,完全不相关的目的,也就是确保代码退出时,对象的Dispose()方法被调用:

using (FileStream myFile = File.OpenRead("foo.txt")) {
    myFile.Read(buffer, 0, 100);
  }
登入後複製

 在上面示例中使用using语句,你就可以确定myFile.Dispose()方法会在文件使用完之后被立即调用,不管Read()方法有没有抛异常。

  常见错误 #9: 回避异常

  C#在运行时也会强制进行类型检查。相对于像C++这样会给错误的类型转换赋一个随机值的语言来说,C#这可以使你更快的找到出错的位置。然而,程序员再一次无视了C#的这一特性。由于C#提供了两种类型检查的方式,一种会抛出异常,而另一种则不会,这很可能会使他们掉进这个“坑”里。有些程序员倾向于回避异常,并且认为不写 try/catch 语句可以节省一些代码。

  例如,下面演示了C#中进行显示类型转换的两种不同的方式:

// 方法 1:
  // 如果 account 不能转换成 SavingAccount 会抛出异常
  SavingsAccount savingsAccount = (SavingsAccount)account;
   
  // 方法 2:
  // 如果不能转换,则不会抛出异常,相反,它会返回 null
  SavingsAccount savingsAccount = account as SavingsAccount;
登入後複製

很明显,如果不对方法2返回的结果进行判断的话,最终很可能会产生一个 NullReferenceException 的异常,这可能会出现在稍晚些的时候,这使得问题更难追踪。对比来说,方法1会立即抛出一个 InvalidCastExceptionmaking,这样,问题的根源就很明显了。

  此外,即使你知道要对方法2的返回值进行判断,如果你发现值为空,接下来你会怎么做?在这个方法中报告错误合适吗?如果类型转换失败了你还有其他的方法去尝试吗?如果没有的话,那么抛出这个异常是唯一正确的选择,并且异常的抛出点离其发生点越近越好。

  下面的例子演示了其他一组常见的方法,一种会抛出异常,而另一种则不会:

int.Parse();     // 如果参数无法解析会抛出异常
  int.TryParse();  // 返回bool值表示解析是否成功
   
  IEnumerable.First();           // 如果序列为空,则抛出异常
  IEnumerable.FirstOrDefault();  // 如果序列为空则返回 null 或默认值
登入後複製

 有些程序员认为“异常有害”,所以他们自然而然的认为不抛出异常的程序显得更加“高大上”。虽然在某些情况下,这种观点是正确的,但是这种观点并不适用于所有的情况。

  举个具体的例子,某些情况下当异常产生时,你有另一个可选的措施(如,默认值),那么,选用不抛出异常的方法是一个比较好的选择。在这种情况下,你最好像下面这样写:

 if (int.TryParse(myString, out myInt)) {    // use myInt
  } else {    // use default value
  }
登入後複製

而不是这样:

try {
    myInt = int.Parse(myString);    // use myInt
  } catch (FormatException) {    // use default value
  }
登入後複製

 但是,这并不说明 TryParse 方法更好。某些情况下适合,某些情况下则不适合。这就是为什么有两种方法供我们选择了。根据你的具体情况选择合适的方法,并记住,作为一个开发者,异常是完全可以成为你的朋友的。

  常见错误 #10: 累积编译器警告而不处理

  这个错误并不是C#所特有的,但是在C#中这种情况却比较多,尤其是从C#编译器弃用了严格的类型检查之后。

  警告的出现是有原因的。所有C#的编译错误都表明你的代码有缺陷,同样,一些警告也是这样。这两者之间的区别在于,对于警告来说,编译器可以按照你代码的指示工作,但是,编译器发现你的代码有一点小问题,很有可能会使你的代码不能按照你的预期运行。

  一个常见的例子是,你修改了你的代码,并移除了对某些变量的使用,但是,你忘了移除该变量的声明。程序可以很好的运行,但是编译器会提示有未使用的变量。程序可以很好的运行使得一些程序员不去修复警告。更有甚者,有些程序员很好的利用了Visual Studio中“错误列表”窗口的隐藏警告的功能,很容易的就把警告过滤了,以便专注于错误。不用多长时间,就会积累一堆警告,这些警告都被“惬意”的忽略了(更糟的是,隐藏掉了)。

  但是,如果你忽略掉这一类的警告,类似于下面这个例子迟早会出现在你的代码中。

class Account {
   
      int myId;      int Id;   // 编译器已经警告过了,但是你不听
   
      // Constructor
      Account(int id) {          this.myId = Id;     // OOPS!
      }
   
  }
登入後複製

再加上使用了编辑器的智能感知的功能,这种错误就很有可能发生。

  现在,你的代码中有了一个严重的错误(但是编译器只是输出了一个警告,其原因已经解释过),这会浪费你大量的时间去查找这错误,具体情况由你的程序复杂程度决定。如果你一开始就注意到了这个警告,你只需要5秒钟就可以修改掉,从而避免这个问题。

  记住,如果你仔细看的话,你会发现,C#编译器给了你很多关于你程序健壮性的有用的信息。不要忽略警告。你只需花几秒钟的时间就可以修复它们,当出现的时候就去修复它,这可以为你节省很多时间。试着为自己培养一种“洁癖”,让Visual Studio 的“错误窗口”一直显示“0错误, 0警告”,一旦出现警告就感觉不舒服,然后即刻把警告修复掉。

  当然了,任何规则都有例外。所以,有些时候,虽然你的代码在编译器看来是有点问题的,但是这正是你想要的。在这种很少见的情况下,你最好使用 #pragma warning disable [warning id] 把引发警告的代码包裹起来,而且只包裹警告ID对应的代码。这会且只会压制对应的警告,所以当有新的警告产生的时候,你还是会知道的。.

 总结

  C#是一门强大的并且很灵活的语言,它有很多机制和语言规范来显著的提高你的生产力。和其他语言一样,如果对它能力的了解有限,这很可能会给你带来阻碍,而不是好处。正如一句谚语所说的那样“knowing enough to be dangerous”(译者注:意思是自以为已经了解足够了,可以做某事了,但其实不是)。

  熟悉C#的一些关键的细微之处,像本文中所提到的那些(但不限于这些),可以帮助我们更好的去使用语言,从而避免一些常见的陷阱。


相關標籤:
來源:php.cn
本網站聲明
本文內容由網友自願投稿,版權歸原作者所有。本站不承擔相應的法律責任。如發現涉嫌抄襲或侵權的內容,請聯絡admin@php.cn
熱門教學
更多>
最新下載
更多>
網站特效
網站源碼
網站素材
前端模板