目录

第Ⅰ部分  C#   

1  .NET体系结构.... 3

1.1  C#.NET的关系...... 3

1.2  公共语言运行库...... 3

1.3  详细介绍中间语言...... 6

1.3.1  面向对象和接口的支持..... 7

1.3.2  相异值类型和引用类型..... 8

1.3.3  强数据类型..... 8

1.3.4  通过异常方法处理错误..... 14

1.3.5  特性的使用..... 14

1.4  程序集...... 14

1.4.1  私有程序集..... 15

1.4.2  共享程序集..... 16

1.4.3  反射..... 16

1.5  .NET Framework...... 16

1.6  命名空间...... 17

1.7  C#创建.NET应用程序...... 18

1.7.1  创建ASP.NET应用程序..... 18

1.7.2  创建Windows窗体..... 20

1.7.3  Windows控件..... 20

1.7.4  Windows服务..... 20

1.8  C#.NET企业体系结构中
的作用...... 20

1.9  小结...... 22

2  C#基础.... 23

2.1  引言...... 23

2.2  第一个C#程序...... 23

2.2.1  代码..... 24

2.2.2  编译并运行程序..... 24

2.2.3  详细介绍..... 25

2.3  变量........ 27

2.3.1  变量的初始化..... 27

2.3.2  变量的作用域..... 28

2.3.3  常量..... 31

2.4  预定义数据类型...... 32

2.4.1  值类型和引用类型..... 32

2.4.2  CTS类型..... 33

2.4.3  预定义的值类型..... 33

2.4.预定义的引用类型..... 36

2.5  流控制...... 39

2.5.1  条件语句..... 39

2.5.2  循环..... 43

2.5.3  跳转语句..... 46

2.6  枚举...... 47

2.7  数组...... 49

2.8  命名空间...... 50

2.8.1  using语句..... 51

2.8.2  命名空间的别名..... 52

2.9  Main()方法...... 53

2.9.1  多个Main()方法..... 53

2.9.2  Main()方法传送参数..... 54

2.10  有关编译C#文件的更多内容...... 55

2.11  控制台I/O.... 56

2.12  使用注释...... 58

2.12.1  源文件中的内部注释..... 58

2.12.2  XML文档说明..... 59

2.13  C#预处理器指令...... 61

2.13.1  #define#undef. 61

2.13.2  #if, #elif, #else#endif. 62

2.13.3  #warning# error. 63

2.13.4  #region#endregion.. 63

2.13.5  #line.. 64

2.13.6  #pragma.. 64

2.14  C#编程规则...... 64

2.14.1  用于标识符的规则..... 64

2.14.2  用法约定..... 65

2.15  小结...... 71

3 对象和类型.... 72

3.1  类和结构...... 72

3.2  类成员...... 73

3.2.1  数据成员..... 73

3.2.2  函数成员..... 74

3.2.3  只读字段..... 88

3.3  结构...... 89

3.3.1  结构是值类型..... 90

3.3.2  结构和继承..... 91

3.3.3  结构的构造函数..... 91

3.4  部分类...... 92

3.5  静态类...... 94

3.6  Object...... 94

3.6.1  System.Object方法..... 94

3.6.2  ToString()方法..... 95

3.7  小结...... 97

4 继承.... 98

4.1  继承的类型...... 98

4.1.1  实现继承和接口继承..... 98

4.1.2  多重继承..... 99

4.1.3  结构和类..... 99

4.2  实现继承...... 99

4.2.1  虚方法..... 100

4.2.2  隐藏方法..... 101

4.2.3  调用函数的基类版本..... 102

4.2.4  抽象类和抽象函数..... 103

4.2.5  密封类和密封方法..... 103

4.2.6  派生类的构造函数..... 104

4.3  修饰符...... 109

4.3.1  可见性修饰符..... 109

4.3.2  其他修饰符..... 110

4.4  接口...... 111

4.4.1  定义和实现接口..... 112

4.4.2  派生的接口..... 116

4.5  小结...... 118

5 运算符和类型强制转换.... 119

5.1  运算符...... 119

5.1.1  运算符的简化操作..... 120

5.1.2  三元运算符..... 121

5.1.3  checkedunchecked运算符..... 122

5.1.4  is运算符..... 123

5.1.5  as运算符..... 123

5.1.6  sizeof运算符..... 123

5.1.7  typeof运算符..... 124

5.1.8  可空类型和运算符..... 124

5.1.9  空接合运算符..... 124

5.1.10  运算符的优先级..... 125

5.2  类型的安全性...... 125

5.2.1  类型转换..... 126

5.2.2  装箱和拆箱..... 130

5.3  对象的相等比较...... 130

5.3.1  引用类型的相等比较..... 131

5.3.2  值类型的相等比较..... 132

5.4  运算符重载...... 132

5.4.1  运算符的工作方式..... 133

5.4.2  运算符重载的示例:
Vector
结构..... 134

5.5  用户定义的数据类型转换...... 141

5.5.1  执行用户定义的类型转换..... 142

5.5.2  多重数据类型转换..... 149

5.6  小结...... 152

6 委托和事件.... 153

6.1  委托...... 153

6.1.1  C#中声明委托..... 154

6.1.2  C#中使用委托..... 155

6.2  匿名方法...... 158

6.2.1  简单的委托示例..... 159

6.2.2  BubbleSorter示例..... 161

6.2.3  多播委托..... 164

6.3  事件...... 166

6.3.1  从客户的角度讨论事件..... 167

6.3.2  生成事件..... 169

6.4  小结...... 173

7 内存管理和指针.... 174

7.1  后台内存管理...... 174

7.1.1  值数据类型..... 174

7.1.2  引用数据类型..... 176

7.1.3  垃圾收集..... 178

7.2  释放未托管的资源...... 179

7.2.1  析构函数..... 179

7.2.2  IDisposable接口..... 180

7.2.3  实现IDisposable接口和
析构函数..... 182

7.3  不安全的代码...... 183

7.3.1  指针..... 183

7.3.2  指针示例PointerPlayaround.. 193

7.3.3  使用指针优化性能..... 197

7.4 小结...... 201

8 字符串和正则表达式.... 202

8.1  System.String...... 202

8.1.1  创建字符串..... 203

8.1.2  StringBuilder成员..... 206

8.1.3  格式化字符串..... 207

8.2  正则表达式...... 213

8.2.1  正则表达式概述..... 213

8.2.2  RegularExpressionsPlayaround
示例..... 214

8.2.3  显示结果..... 216

8.2.4  匹配、组合和捕获..... 218

8.3  小结...... 220

9 集合.... 221

9.1  对象组...... 221

9.1.1  集合..... 222

9.1.2  数组列表..... 225

9.1.3  Stack..... 229

9.1.4  Queue..... 231

9.1.5  SortedList..... 232

9.1.6  字典和散列表..... 234

9.1.7  泛型..... 243

9.2  小结...... 244

10 泛型.... 245

10.1  概述...... 245

10.1.1  性能..... 245

10.1.2  类型安全..... 246

10.1.3  二进制代码的重用..... 247

10.1.4  代码的扩展..... 247

10.1.5  命名约定..... 248

10.2  泛型集合类...... 248

10.2.1  泛型集合概述..... 248

10.2.2  使用List<T>..... 251

10.2.3  使用Queue<T>..... 256

10.2.4  使用LinkedList<T>..... 260

10.3  创建定制的泛型类...... 265

10.3.1  默认值..... 267

10.3.2  约束..... 267

10.4  泛型方法...... 270

10.5  泛型委托...... 272

10.6  Framework的其他泛型类型...... 274

10.6.1  结构Nullable<T>... 274

10.6.2  EventHandler
<TEventArgs>... 276

10.6.3  ArraySegment<T>... 276

10.7  小结...... 277

11 反射.... 278

11.1  定制特性...... 278

11.1.1  编写定制特性..... 279

11.1.2  定制特性示例:
WhatsNewAttributes.. 282

11.2  反射...... 286

11.2.1  System.Type..... 286

11.2.2  TypeView示例..... 288

11.2.3  Assembly..... 291

11.2.4  完成WhatsNewAttributes
示例..... 292

11.3  小结...... 296

12 错误和异常.... 297

12.1  错误和异常处理...... 297

12.1.1  异常类..... 297

12.1.2  捕获异常..... 299

12.1.3  用户定义的异常类..... 308

12.2  小结...... 316

13 线程.... 317

13.1  线程...... 317

13.2  多线程应用程序...... 318

13.3  线程的处理...... 319

13.3.1  ThreadPlayaround示例..... 322

13.3.2  线程的优先级..... 325

13.3.3  同步..... 326

13.4  使用ThreadPool创建线程...... 330

13.5  小结...... 334

第Ⅱ部分  .NET  

14  Visual Studio 2005.. 337

14.1  使用Visual Studio 2005... 337

14.1.1  创建项目..... 341

14.1.2  解决方案和项目..... 347

14.1.3  Windows应用程序代码..... 349

14.1.4  读取Visual Studio 6项目..... 350

14.1.5  项目的浏览和编码..... 350

14.1.6  生成项目..... 360

14.1.7  调试..... 363

14.2  修订功能...... 366

14.3  小结...... 368

15 程序集.... 369

15.1  程序集的含义...... 369

15.1.1  DLL Hell的解决方案..... 370

15.1.2  程序集的特性..... 370

15.1.3  应用程序域和程序集..... 371

15.2  程序集的结构...... 374

15.2.1  程序集的清单..... 374

15.2.2  命名空间、程序集和组件..... 375

15.2.3  私有程序集和共享程序集..... 375

15.2.4  查看程序集..... 375

15.2.5  构建程序集..... 376

15.3  跨语言支持...... 380

15.3.1  CTSCLS.. 380

15.3.2  语言无关性..... 381

15.3.3  CLS要求..... 389

15.4  全局程序集缓存...... 391

15.4.1  本机图像生成器..... 391

15.4.2  全局程序集缓存查看器..... 392

15.4.3  全局程序集缓存工具
(gacutil.exe)
. 393

15.5  创建共享程序集...... 393

15.5.1  共享程序集名..... 393

15.5.2  创建共享程序集..... 395

15.6  配置...... 400

15.6.1  配置类别..... 400

15.6.2  版本问题..... 401

15.6.3  配置目录..... 409

15.7  小结...... 411

16  .NET的安全性.... 412

16.1  代码访问的安全性...... 412

16.1.1  代码组..... 413

16.1.2  代码访问权限和权限集..... 419

16.1.3  策略的级别:Machine
User
Enterprise.. 423

16.2  Framework中安全性的
支持...... 425

16.2.1  要求权限..... 426

16.2.2  请求权限..... 427

16.2.3  隐式的权限..... 430

16.2.4  拒绝权限..... 431

16.2.5  断言权限..... 432

16.2.6  创建代码访问权限..... 433

16.2.7  声明的安全性..... 434

16.3  安全策略的管理...... 435

16.3.1  安全配置文件..... 435

16.3.2  代码组和权限的管理..... 438

16.3.3  安全性的启用和禁用..... 438

16.3.4  重置安全策略..... 439

16.3.5  代码组的创建..... 439

16.3.6  代码组的删除..... 440

16.3.7  代码组权限的更改..... 440

16.3.8  权限集的创建和应用..... 441

16.3.9  使用强名发布代码..... 443

16.3.10  使用证书发布代码..... 445

16.3.11  区域的管理..... 449

16.4  基于角色的安全性...... 451

16.4.1  Principal. 451

16.4.2  Windows Principal. 452

16.4.3  角色..... 454

16.4.4  声明基于角色的安全性..... 454

16.5  小结...... 455

17 本地化.... 456

17.1  System.Globalization
命名空间...... 456

17.1.1  Unicode问题..... 456

17.1.2  文化和区域..... 457

17.1.3  使用文化..... 461

17.1.4  排序..... 466

17.2  资源...... 467

17.2.1  创建资源文件..... 468

17.2.2  资源文件生成器..... 468

17.2.3  ResourceWriter. 468

17.2.4  使用资源文件..... 469

17.2.5  System.Resources
命名空间..... 474

17.3  使用Visual Studio的本地化
示例...... 475

17.3.1  编程修改文化..... 480

17.3.2  使用定制资源文件..... 481

17.3.3  资源的自动回退..... 482

17.3.4  外包翻译..... 482

17.4  ASP.NET本地化...... 483

17.5  定制的资源读取器...... 485

17.5.1  创建DatabaseResource
Reader
..... 485

17.5.2  创建DatabaseResource
Set
..... 487

17.5.3  创建DatabaseResource
Manager
..... 487

17.5.4  DatabaseResourceReader
客户应用程序..... 488

17.6  创建定制文化...... 489

17.7  小结...... 489

18 部署.... 490

18.1  部署的设计...... 490

18.2  部署选项...... 490

18.2.1  Xcopy实用工具..... 491

18.2.2  Copy Web工具..... 491

18.2.3  发布Web站点..... 491

18.2.4  部署项目..... 491

18.2.5  ClickOnce.. 491

18.3  部署的要求...... 491

18.4  简单的部署...... 492

18.4.1  Xcopy部署..... 493

18.4.2  XcopyWeb应用程序..... 493

18.4.3  Copy Web工具..... 493

18.4.4  发布Web站点..... 494

18.5  Installer项目...... 494

18.5.1  Windows Installer. 495

18.5.2  创建安装程序..... 495

18.6  ClickOnce.. 504

18.6.1  ClickOnce操作..... 504

18.6.2  发布应用程序..... 504

18.6.3  ClickOnce设置..... 505

18.6.4  应用程序缓存..... 505

18.6.5  安全性..... 506

18.6.6  高级选项..... 506

18.7  小结...... 511

第Ⅲ部分    

19  .NET数据访问.... 515

19.1  ADO.NET概述...... 515

19.1.1  命名空间..... 516

19.1.2  共享类..... 516

19.1.3  数据库特定的类..... 516

19.2  使用数据库连接...... 517

19.2.1  管理连接字符串..... 518

19.2.2  高效地使用连接..... 520

19.2.3  事务处理..... 522

19.3  命令...... 524

19.3.1  执行命令..... 525

19.3.2  调用存储过程..... 528

19.4  快速数据访问:数据读取器...... 531

19.5  管理数据和关系:DataSet...... 534

19.5.1  数据表..... 535

19.5.2  数据列..... 536

19.5.3  数据关系..... 541

19.5.4  数据约束..... 542

19.6  XML模式...... 544

19.7  填充数据集...... 552

19.7.1  用数据适配器来填充
DataSet
. 552

19.7.2  XML中给数据集填充
数据..... 553

19.8  保存对数据集的修改...... 553

19.8.1  通过数据适配器进行更新..... 554

19.8.2  写入XML输出结果..... 556

19.9  使用ADO.NET.... 558

19.9.1  分层开发..... 558

19.9.2  生成SQL Server的键..... 559

19.9.3  命名约定..... 561

19.10  小结...... 562

20  .NET编程和
SQL Server 2005.. 564

20.1  .NET运行库的主机...... 564

20.2  Microsoft.SqlServer.Server.. 565

20.3  用户定义的类型...... 566

20.3.1  创建UDT... 566

20.3.2  使用UDT... 572

20.3.3  在客户端代码中使用UDT... 573

20.4  用户定义的合计函数...... 574

20.4.1  创建用户定义的合计函数..... 574

20.4.2  使用用户定义的合计函数..... 575

20.5  存储过程...... 576

20.5.1  创建存储过程..... 576

20.5.2  使用存储过程..... 577

20.6  用户定义的函数...... 578

20.6.1  创建用户定义的函数..... 578

20.6.2  使用用户定义的函数..... 579

20.7  触发器...... 579

20.7.1  创建触发器..... 580

20.7.2  使用触发器..... 581

20.8  XML数据类型...... 581

20.8.1  包含XML数据的表..... 582

20.8.2  数据的查询..... 584

20.8.3  XML数据修改语言

(XML DML). 585

20.8.4  XML索引..... 586

20.8.5  强类型化的XML... 587

20.9  小结...... 588

21 处理XML.. 589

21.1  .NET支持的XML标准...... 589

21.2  System.Xml命名空间...... 590

21.3  .NET中使用MSXML... 591

21.4  使用System.Xml...... 593

21.5  读写流格式的XML... 593

21.5.1  使用 XmlReader..... 594

21.5.2  使用XmlReader类进行
验证..... 597

21.5.3  使用XmlWriter..... 599

21.6  .NET中使用DOM..... 601

21.7  使用XPathNavigator.. 607

21.7.1  System.Xml.Xpath
命名空间..... 607

21.7.2  System.Xml.Xsl命名空间..... 612

21.8  XMLADO.NET.... 617

21.8.1  ADO.NET数据转换为
XML
文档..... 617

21.8.2  XML文档转换为
ADO.NET
数据..... 624

21.8.3  读写DiffGram.... 626

21.9  XML中串行化对象...... 629

21.10  小结...... 639

22 使用Active Directory. 640

22.1  Active Directory的体系结构...... 640

22.1.1  特性..... 641

22.1.2  Active Directory的概念..... 641

22.1.3  Active Directory数据的
特性..... 645

22.1.4  模式... 645

22.2  Active Directory的管理工具...... 646

22.2.1  Active Directory Users and
Computers
工具..... 646

22.2.2  ADSI Edit工具..... 647

22.3  Active Directory编程...... 648

22.3.1  System.DirectoryServices命名
空间中的类..... 649

22.3.2  绑定..... 650

22.3.3  获取目录项..... 654

22.3.4  对象集合..... 655

22.3.5  缓存..... 656

22.3.6  创建新对象..... 657

22.3.7  更新目录项..... 658

22.3.8  访问内部的ADSI对象..... 658

22.3.9  Active Directory中搜索..... 660

22.4  搜索用户对象...... 663

22.4.1  用户界面..... 663

22.4.2  获取模式命名环境..... 664

22.4.3  获取User类的属性名..... 665

22.4.4  搜索用户对象..... 666

22.5  DSML... 668

22.5.1  命名空间
System.DirectoryServices.
Protocols
中的类..... 668

22.5.2  DSML搜索Active
Directory
对象..... 669

22.6  小结...... 671

第Ⅳ部分  Windows应用程序

23  Windows窗体.... 675

23.1  创建Windows窗体应用程序...... 675

23.2  Control...... 681

23.2.1  大小和位置..... 682

23.2.2  外观..... 683

23.2.3  用户交互操作..... 683

23.2.4  Windows功能..... 684

23.2.5  杂项功能..... 685

23.3  标准控件和组件...... 685

23.3.1  Button控件..... 686

23.3.2  CheckBox控件..... 686

23.3.3  RadioButton控件..... 687

23.3.4  ComboBox控件、ListBox
控件和CheckedListBox控件.... 687

23.3.5  DateTimePicker控件..... 690

23.3.6  ErrorProvider组件..... 690

23.3.7  HelpProvider组件..... 691

23.3.8  ImageList组件..... 692

23.3.9  Label控件..... 692

23.3.10  ListView控件..... 692

23.3.11  PictureBox控件..... 694

23.3.12  ProgressBar控件..... 695

23.3.13  TextBox控件、RichTextBox
控件与MaskedTextBox
控件..... 695

23.3.14  Panel控件..... 696

23.3.15  FlowLayoutPanel
TableLayoutPanel
控件..... 696

23.3.16  SplitContainer控件..... 697

23.3.17  TabControl控件和TabPages
控件..... 698

23.3.18  ToolStrip控件..... 698

23.3.19  MenuStrip控件..... 701

23.3.20  ContextMenuStrip控件..... 701

23.3.21  ToolStripMenuItem控件..... 701

23.3.22  ToolStripManager..... 702

23.3.23  ToolStripContainer控件..... 702

23.4  窗体...... 702

23.4.1  Form..... 703

23.4.2  多文档界面..... 708

23.4.3  定制控件..... 708

23.5  小结...... 721

24 查看.NET数据.... 722

24.1  DataGridView控件...... 722

24.1.1  显示列表数据..... 722

24.1.2  数据源..... 724

24.2  DataGridView类的层次结构...... 732

24.3  数据绑定...... 735

24.3.1  简单的绑定..... 735

24.3.2  数据绑定对象..... 735

24.4  Visual Studio.NET
数据访问...... 739

24.4.1  创建一个连接..... 740

24.4.2  选择数据..... 742

24.4.3  更新数据源..... 743

24.4.4  构建模式..... 743

24.4.5  其他常见的要求..... 749

24.5  小结...... 757

25 使用GDI+绘图.... 758

25.1  理解绘图规则...... 758

25.1.1  GDIGDI+... 758

25.1.2  绘制图形..... 760

25.1.3  使用OnPaint()绘制图形..... 763

25.1.4  使用剪切区域..... 764

25.2  测量坐标和区域...... 766

25.2.1  PointPointF结构..... 766

25.2.2  SizeSizeF结构..... 767

25.2.3  RectangleRectangleF

结构..... 769

25.2.4  Region.. 770

25.3  调试须知...... 770

25.4  绘制可滚动的窗口...... 771

25.5  世界、页面和设备坐标...... 776

25.6  颜色...... 777

25.6.1  红绿蓝(RGB)..... 777

25.6.2  命名的颜色..... 777

25.6.3  图形显示模式和安全的
调色板..... 778

25.6.4  安全调色板..... 779

25.7  画笔和钢笔...... 779

25.7.1  画笔..... 779

25.7.2  钢笔..... 780

25.8  绘制图形和线条...... 781

25.9  显示图像...... 783

25.10  处理图像时所涉及到的问题...... 785

25.11  绘制文本...... 786

25.12  简单的文本示例...... 787

25.13  字体和字体系列...... 788

25.14  示例:枚举字体系列...... 790

25.15  编辑文本文档:CapsEditor
示例...... 792

25.15.1  Invalidate()方法..... 796

25.15.2  计算项和文档的大小..... 797

25.15.3  OnPaint(). 799

25.15.4  坐标转换..... 800

25.15.5  响应用户的输入..... 802

25.16  打印...... 805

25.17  小结...... 810

第Ⅴ部分  Web应用程序

26  ASP.NET页面.... 813

26.1  ASP.NET概述...... 813

26.2  ASP.NET Web窗体...... 814

26.2.1  ASP.NET代码模型..... 818

26.2.2  ASP.NET服务器控件..... 818

26.3  ADO.NET和数据绑定...... 833

26.3.1  更新会议登记应用程序..... 833

26.3.2  数据绑定的更多内容..... 840

26.4  应用程序配置...... 845

26.5  小结...... 847

27  ASP.NET开发.... 848

27.1  定制控件...... 848

27.1.1  用户控件..... 849

27.1.2  PCSDemoSite中的用户
控件..... 855

27.1.3  定制控件..... 856

27.2  Master页面...... 860

27.3  站点导航...... 864

27.4  安全性...... 867

27.4.1  使用安全向导添加Forms
身份验证功能..... 867

27.4.2  实现登录系统..... 870

27.4.3  Web 登录服务器控件..... 871

27.4.4  保护目录..... 872

27.4.5  PCSDemoSite中的安全性..... 872

27.5  主题...... 874

27.5.1  把主题应用于页面..... 875

27.5.2  定义主题..... 876

27.5.3  PCSDemoSite中的主题..... 876

27.6  小结...... 879

第Ⅵ部分    

28  Web服务.... 883

28.1  SOAP... 883

28.2  WSDL... 885

28.3  Web服务...... 886

28.3.1  创建Web服务..... 886

28.3.2  使用Web服务..... 890

28.4  扩充会议登记示例...... 892

28.4.1  会议登记Web服务..... 892

28.4.2  会议登记客户程序..... 897

28.5  使用SOAP标题交换数据...... 901

28.6  小结...... 906

29  .NET Remoting.. 908

29.1  .NET Remoting的含义...... 908

29.1.1  应用程序类型和协议..... 909

29.1.2  CLR Object Remoting.. 909

29.2  .NET Remoting概述...... 910

29.3  环境...... 912

29.3.1  激活..... 913

29.3.2  特性和属性..... 913

29.3.3  环境之间的通信..... 914

29.4  远程对象、客户机和服务器...... 914

29.4.1  远程对象..... 914

29.4.2  简单的服务器..... 915

29.4.3  简单的客户机..... 916

29.5  .NET Remoting体系结构...... 917

29.5.1  信道..... 918

29.5.2  格式标识符..... 922

29.5.3  ChannelServices
RemotingConfiguration.. 922

29.5.4  对象的激活..... 924

29.5.5  消息接收器..... 927

29.5.6  在远程方法中传递对象..... 928

29.5.7  生存期管理..... 933

29.6  .NET Remoting的其他特性...... 936

29.6.1  配置文件..... 936

29.6.2  利用ASP.NET驻留远程
服务器..... 946

29.6.3  类、接口和Soapsuds.. 948

29.6.4  异步远程调用..... 949

29.6.5  .NET Remoting的安全性..... 950

29.6.6  远程调用和事件..... 952

29.6.7  调用环境..... 958

29.7  小结...... 960

30  Enterprise Services. 961

30.1  概述...... 961

30.1.1  Enterprise Services简史..... 961

30.1.2  使用Enterprise Services
场合..... 962

30.1.3  环境..... 963

30.1.4  自动的事务处理..... 963

30.1.5  分布式事务处理..... 963

30.1.6  对象池..... 963

30.1.7  基于角色的安全性..... 964

30.1.8  排队的组件..... 964

30.1.9  松散藕合的事件..... 964

30.1.10  没有组件的服务..... 964

30.2  创建简单的COM+应用程序...... 965

30.2.1  ServicedComponent. 965

30.2.2  标记程序集..... 965

30.2.3  程序集的属性..... 965

30.2.4  创建组件..... 966

30.3  部署...... 967

30.3.1  自动部署..... 967

30.3.2  手工部署..... 968

30.3.3  创建安装软件包..... 968

30.4  Component Services浏览器...... 969

30.5  客户应用程序...... 971

30.6  事务处理...... 971

30.6.1  ACID属性..... 972

30.6.2  事务处理的属性..... 972

30.6.3  事务处理的结果..... 973

30.7  示例应用程序...... 973

30.7.1  实体类..... 974

30.7.2  OrderControl组件..... 978

30.7.3  OrderData组件..... 978

30.7.4  OrderLineData组件..... 981

30.7.5  客户应用程序..... 983

30.8  没有组件的服务...... 984

30.9  小结...... 986

31 消息队列.... 988

31.1  概述...... 988

31.1.1  使用Message Queuing
的场合..... 989

31.1.2  Message Queuing特性..... 990

31.2  Message Queuing产品...... 990

31.3  Message Queuing结构...... 991

31.3.1  消息..... 991

31.3.2  消息队列..... 992

31.4  Message Queuing管理工具...... 992

31.4.1  创建消息队列..... 992

31.4.2  消息队列属性..... 993

31.5  Message Queuing的编程实现...... 994

31.5.1  创建消息队列..... 994

31.5.2  查找队列..... 995

31.5.3  打开已知的队列..... 996

31.5.4  发送消息..... 997

31.5.5  接收消息..... 1000

31.6  课程订单应用程序...... 1002

31.6.1  课程订单类库..... 1002

31.6.2  课程订单消息发送程序..... 1005

31.6.3  发送优先级和可恢复
的消息..... 1006

31.6.4  课程订单消息接收程序..... 1007

31.7  接收结果...... 1010

31.7.1  确认队列..... 1011

31.7.2  响应队列..... 1012

31.8  事务队列...... 1012

31.9  消息队列的安装...... 1013

31.10  小结...... 1014

32 分布式编程的未来产品.... 1015

32.1  现有技术的问题...... 1015

32.2  Web服务...... 1016

32.2.1  安全性..... 1017

32.2.2  可靠性..... 1018

32.2.3  事务处理..... 1020

32.2.4  性能..... 1021

32.3  WCF概述...... 1023

32.4  WCF编程...... 1025

32.4.1  契约..... 1025

32.4.2  服务程序的实现..... 1027

32.4.3  绑定..... 1028

32.4.4  主机..... 1030

32.4.5  客户程序..... 1031

32.5  准备使用WCF... 1032

32.5.1  .NET Remoting.. 1032

32.5.2  ASP.NET Web服务..... 1033

32.5.3  Enterprise Services.. 1033

32.5.4  Message Queuing.. 1034

32.6  小结...... 1035

第Ⅶ部分 

33  COM的互操作性.... 1039

33.1  .NETCOM..... 1039

33.1.1  元数据..... 1040

33.1.2  释放内存..... 1040

33.1.3  接口..... 1040

33.1.4  方法绑定..... 1042

33.1.5  数据类型..... 1042

33.1.6  注册..... 1042

33.1.7  线程..... 1043

33.1.8  错误处理..... 1044

33.1.9  事件处理..... 1045

33.2  编组...... 1045

33.3  .NET客户程序中使用COM
组件...... 1046

33.3.1  创建COM组件..... 1046

33.3.2  创建Runtime Callable
Wrapper. 1050

33.3.3  线程问题..... 1053

33.3.4  添加连接点..... 1053

33.3.5  Windows窗体中使用
ActiveX
控件..... 1055

33.3.6  ASP.NET中使用COM
对象..... 1058

33.4  COM客户程序中使用.NET
组件...... 1058

33.4.1  COM Callable Wrapper. 1058

33.4.2  创建.NET组件..... 1059

33.4.3  创建类型库..... 1060

33.4.4  COM互操作特性..... 1062

33.4.5  COM注册..... 1065

33.4.6  创建COM客户程序..... 1066

33.4.7  添加连接点..... 1068

33.4.8  sink对象创建
客户程序..... 1069

33.4.9  Internet Explorer中运行
Windows
窗体控件..... 1070

33.5  小结...... 1070

第Ⅷ部分  Windows基本服务

34 文件和注册表操作.... 1073

34.1  管理文件系统...... 1073

34.1.1  表示文件和文件夹的
.NET
..... 1074

34.1.2  Path..... 1077

34.1.3  示例:文件浏览器..... 1077

34.2  移动、复制和删除文件...... 1082

34.2.1  FilePropertiesAndMovement
示例..... 1082

34.2.2  示例
FilePropertiesAndMovement
的代码..... 1083

34.3  读写文件...... 1087

34.3.1  读取文件..... 1087

34.3.2  写入文件..... 1089

34.3.3  ..... 1090

34.3.4  缓存的流..... 1092

34.3.5  读写二进制文件..... 1092

34.3.6  读写文本文件..... 1097

34.4  读取驱动器信息...... 1103

34.5  文件的安全性...... 1105

34.5.1  从文件中读取ACL... 1106

34.5.2  从目录中读取ACL... 1107

34.5.3  添加和删除文件中的
ACL
..... 1109

34.6  读写注册表...... 1110

34.6.1  注册表..... 1111

34.6.2  .NET注册表类..... 1112

34.6.3  SelfPlacingWindow示例..... 1115

34.7  小结...... 1121

35 访问Internet1122

35.1  WebClient...... 1122

35.1.1  下载文件..... 1123

35.1.2  基本的Web客户示例..... 1123

35.1.3  上传文件..... 1124

35.2  WebRequest类和WebResponse
...... 1125

35.3  把输出结果显示为HTML
页面...... 1128

35.3.1  在应用程序中进行简单的

Web浏览..... 1128

35.3.2  启动Internet Explorer
实例..... 1130

35.3.3  给应用程序提供更多的IE
类型特性..... 1131

35.3.4  使用WebBrowser控件显示
文档..... 1137

35.3.5  使用WebBrowser
控件打印..... 1138

35.3.6  显示请求页面的代码..... 1138

35.3.7  WebRequestWebResponse
的层次结构..... 1140

35.4  实用工具类...... 1140

35.4.1  URI. 1140

35.4.2  IP地址和DNS名称..... 1141

35.5  较低层的协议...... 1144

35.6  小结...... 1150

36  Windows服务.... 1151

36.1  Windows服务...... 1151

36.2  Windows服务的体系结构...... 1152

36.2.1  服务程序..... 1152

36.2.2  服务控制程序..... 1153

36.2.3  服务配置程序..... 1154

36.3  System.ServiceProcess
命名空间...... 1154

36.4  创建Windows服务...... 1154

36.4.1  使用套接字的类库..... 1155

36.4.2  TcpClient示例..... 1159

36.4.3  Windows服务项目..... 1160

36.4.4  线程和服务..... 1166

36.4.5  服务的安装..... 1166

36.4.6  安装程序..... 1166

36.5  服务的监视和控制...... 1171

36.5.1  MMC计算机管理..... 1171

36.5.2  net.exe.. 1172

36.5.3  sc.exe.. 1173

36.5.4  Visual Studio Server
Explorer. 1173

36.5.5  ServiceController..... 1174

36.6  故障排除...... 1179

36.6.1  交互式服务..... 1180

36.6.2  事件日志..... 1181

36.6.3  性能监视..... 1186

36.7  电源事件...... 1190

36.8  小结...... 1191

前言

对于开发人员来说,把C#语言及其相关环境.NET Framework描述为多年来最重要的新技术一点都不夸张。.NET提供了一种新环境。在这个环境中,可以开发出运行在Windows上的几乎所有应用程序,而C#是专门用于.NET的新编程语言。例如,使用C#可以编写出动态Web页面、XML Web服务、分布式应用程序的组件、数据库访问组件、传统的Windows桌面应用程序,甚或可以联机/脱机运行的新型智能客户应用程序。本书介绍.NET Framework 2.0,即.NET Framework的第3版。如果读者使用1.0或1.1版本编码,本书的一些章节就不适用。本书将标注出只适用于.NET Framework 2.0的新增内容。

不要被.NET这个名称所愚弄,这个名称仅强调Microsoft相信分布式应用程序是未来的趋势,即处理过程分布在客户机和服务器上,但C#不仅仅是编写Internet或与网络相关的应用程序的一种语言,它还提供了一种编写Windows平台上几乎任何类型的软件或组件的方式。另外,C#和.NET都对编写程序的方式进行了革新,更易于实现在Windows上编程。

这是一个相当重要的声明。毕竟,我们都知道计算机技术的发展速度非常快,每年Microsoft都会推出新软件、新的编程工具或Windows的新版本,并宣称这些对开发人员都非常有用,.NET和C#也不例外。

.NETC#的重要性

为了理解.NET的重要性,考虑一下近10年来出现的许多Windows技术的本质会有一定的帮助。尽管所有的Windows操作系统在表面上看来完全不同,但从Windows 3.1(1992年)到Windows Server 2003,在内核上都有相同的Windows API。在我们转而使用Windows的新版本时,API中增加了非常多的新功能,但这是一个演化和扩展API的过程,并非替换它。

开发Windows软件所使用的许多技术和架构也是这样。例如,COM(Component Object Model,组件对象模型)是作为OLE(Object Linking and Embedding,对象链接和嵌入)开发出来的,那时,它在很大程度上仅是把不同类型的办公文档链接在一起,所以利用它可以把一个小Excel电子表格放在Word文档中。之后,它逐步演化为COM、DCOM(Distributed COM,分布式组件对象模型)和最终的COM+。COM+是一种复杂的技术,它是几乎所有组件通信方式的基础,实现了事务处理、消息传输服务和对象池。

Microsoft选择这条道路的原因非常明显:它关注向后的兼容性。在过去的这些年中,第三方厂商编写了相当多的Windows软件,如果Microsoft每次都引入一项不遵循现有编码规则的新技术,Windows就不会获得今天的成功。

向后兼容性是Windows技术的极其重要的特性,也是Windows平台的一个长处。但它有一个很大的缺点:每次某项技术进行演化,增加了新功能后,都会比它以前更复杂。

很明显,对此必须进行改进。Microsoft不可能一直扩展这些开发工具和语言,使它们越来越复杂,既要保证能跟上最新硬件的发展步伐,又要与20世纪90年代初开始流行的Windows产品向后兼容。如果要得到一种简单而专业化的语言、环境和开发工具,让开发人员轻松地编写优秀的软件,就需要一种新的开端。

这就是C#和.NET的作用。粗略地说,.NET是一种在Windows平台上编程的新架构—— 一种新API。C#是一种全新的语言,它可以利用.NET Framework及其开发环境中的所有新特性,以及在最近20年来出现的面向对象的编程方法。

在继续介绍前,必须先说明,向后兼容性并没有在这个演化进程中失去。现有的程序仍可以使用,.NET也兼容现有的软件。软件组件在Windows上的通信,现在几乎都是使用COM实现的。因此,.NET能够提供现有COM组件的包装器(wrapper),以便.NET组件与之通信。

我们不需要学习了C#才能给.NET编写代码,因为Microsoft已经扩展了C++,提供了一种新语言J#,还对Visual Baisc进行了很多改进,把它转变成为功能更强大的Visual Baisc.NET,并允许把用这些语言编写的代码用于.NET环境。但这些语言都因有多年演化的痕迹,所以不能完全用现在的技术来编写。

本书将介绍C#编程技术,同时提供.NET体系结构工作原理的必要背景知识。我们不仅会介绍C#语言的基础,还会给出使用各种相关技术的应用程序示例,包括数据库访问、动态的Web页面、先进的图形技术和目录访问等。惟一的要求是用户至少熟悉一门在Windows上使用的高级语言,例如C++、Visual Baisc或J++。

.NET的优点

前面阐述了.NET的优点,但并没有说它会使开发人员的工作更易完成。本节将简要讨论.NET的改进特性。

       面向对象的编程:.NET FrameworkC#从一开始就完全是基于面向对象的。

       优秀的设计:一个基类库,它是以一种非常直观的方式设计出来的。

       语言的无关性:在.NET中,Visual Baisc.NETC#J#Managed C++等语言都可以编译为通用的中间语言(Intermediate Language)。这说明,语言可以用以前没有的方式交互操作。

       对动态Web页面的更好支持:ASP具有很大的灵活性,但效率不是很高,这是因为它使用了解释性的脚本语言,且缺乏面向对象的设计,从而导致ASP代码比较凌乱。.NET使用一种新技术ASP.NET,它为Web页面提供了一种集成式的支持。使用ASP.NET,可以编译页面中的代码,这些代码还可以使用.NET高级语言来编写,例如C#J#Visual Baisc 2005

       高效的数据访问:一组.NET组件,总称为ADO.NET,提供了对关系数据库和各种数据源的高效访问。这些组件也可以访问文件系统和目录。.NET内置了XML支持,可以处理从非Windows平台导入或导出的数据。

       代码共享:.NET引入了程序集的概念,替代了传统的DLL,可以完美无暇地修补代码在应用程序之间的共享方式。程序集是解决版本冲突的正式系统,程序集的不同版本可以同时存在。

       增强的安全性:每个程序集还可以包含内置的安全信息,这些信息可以准确地指出谁或哪种类型的用户或进程可以调用什么类的哪些方法。这样就可以非常准确地控制程序集的使用方式。

       对安装没有任何影响:有两种类型的程序集,分别是共享程序集和私有程序集。共享程序集是可用于所有软件的公共库,私有程序集只用于某个软件。私有程序集是完全自包含的,所以安装过程非常简单,没有注册表项,只需把相应的文件放在文件系统的相应文件夹中即可。

       Web服务的支持:.NET集成了对开发Web服务的完全支持,用户可以开发出任何类型的应用程序。

       Visual Studio 2005.NET附带了一个开发环境Visual Studio 2005,它可以很好地利用C++C#J#Visual Baisc 2005ASP.NET进行编码。Visual Studio 2005集成了Visual Studio .NET 2002/2003Visual Studio 6环境中的各种语言专用的所有最佳功能。

       C#:是使用.NET的一种面向对象的新语言。

第1章将详细讨论.NET体系结构的优点。

.NET Framework 2.0中的新增特性

.NET Framework的第1版(1.0版)在2002年发布,赢得了许多人的喝彩。.NET Framework的最新版本2.0在2005年发布,它被认为是对该架构进行了较大的改进。

Microsoft每次发布新的架构时,总是试图确保对已开发出的代码进行尽可能少的修改。到目前为止,Microsoft在这方面做得很成功。

注意:

一定要建立一个临时的服务器,来测试应用程序到.NET Framework 2.0的升级,而不是直接升级当前运行的应用程序。

下面将详细描述.NET Framework 2.0中的一些新变化,以及.NET Framework 2.0的开发环境——Visual Studio 2005的新增内容。

SQL Server集成

经过漫长的等待,SQL Server的最新版本终于发布了。这个版本是SQL Server 2005,在许多方面都比较独特。对.NET开发人员来说,最重要的是SQL Server 2005现在包含了CLR。Microsoft为开发人员开发的.NET产品,能把.NET Framework 2.0、Visual Studio 2005和SQL Server 2005关联在一起,所以,这三个产品现在是一起发布的。这是相当重要的,因为以前建立的大多数应用程序都使用这三个产品,它们需要一块儿升级,以无缝的方式交互操作。

因为SQL Server 2005现在包含了CLR,所以现在不需要使用T-SQL编程语言建立应用程序的数据库功能,而可以用任意.NET兼容语言,如C#,建立各种对象,如存储过程、触发器,甚至数据类型。

SQL Server Express是SQL Server中替代MSDE的2005版本。这个版本没有MSDE那样严格的限制。

64位支持

目前的大多数编程都在32位的机器上进行。在应用程序的开发过程中,计算机从16位升级到32位是一个质的飞跃。越来越多的企业开始迁移到Intel (Itanium芯片)和AMD (x64芯片)等公司的最新最大的64位服务器上,.NET Framework 2.0现在就支持这种64位大迁移。

Microsoft努力确保在.NET的32位环境中开发的所有代码都可以在64位环境下运行。也就是说,用SQL Server 2005或ASP.NET开发的所有代码迁移到64位服务器上后,其运行不受影响。Microsoft也对CLR做了许多改进,使.NET的64位版本能正常工作。这些改进包括垃圾回收(处理更多的数据)、JIT编译过程、异常处理等。

迁移到64位后,会增加一些强大的功能。最重要也是最明显的原因是64位服务器有更大的寻址空间。迁移到64位还可以有更大的基本数据类型。例如,2^32的整数值是4,294,967,296,而2^64的整数值是18,446,744,073,709,551,616。应用程序将更便于计算U.S.债务或其他很大的数字。

Microsoft和IBM等公司给客户展示了64位的魅力。一个主要领域就是数据库和虚拟数据存储功能,这是迁移到64位的一个最有意义的理由。

Visual Studio 2005可以在64位计算机上安装和运行。这个IDE有32位和64位编译器。其后果之一是,64位的.NET Framework只能用于Windows Server 2003 SP1或更高版本,以及将来的其他64位Microsoft操作系统。

在Visual Studio 2005中建立应用程序时,可以修改应用程序的建立属性,使之专门为64位计算机编译程序。要找到这个设置,需要打开应用程序的属性窗口,单击Properties页面上的Build选项卡。在Build页面上单击Advanced按钮,打开Advanced Compiler Setting对话框。在这个对话框中,可以修改底部的目标CPU。这样,就可以使所建立的应用程序用于Intel 64位计算机或AMD 64位计算机,如图0-1所示。

0-1  64位计算机创建应用程序

泛型

为了使集合的功能更强大,也为了提高它们的效率和可用性,.NET Framework 2.0引入了泛型。泛型引入到底层的架构上,这意味着C#和Visual Basic 2005等语言现在可以建立使用泛型类型的应用程序了。泛型概念并不是新内容,它们类似于C++的模板,但有些区别。其他语言中也有泛型,例如Java。把它们引入.NET Framework 2.0将给用户带来巨大的实惠。

泛型可以使一般的集合仍是强类型化的—— 出错的几率更小(因为错误在运行期间发生),提高性能,在使用集合时将可以使用Intellisense特性。

要在代码中使用泛型,需要引用System.Collections.Generic命名空间,该命名空间还允许访问Stack、Dictionary、SortedDictionary、List和Queue类的泛型版本。下面演示了Stack类的泛型版本:

void Page_Load(object sender, EventArgs e)

{

   System.Collections.Generic.Stack<string> myStack =

      New System.Collections.Generic.Stack<string>();

   myStack.Push("St. Louis Rams");

   myStack.Push("Indianapolis Colts");

   myStack.Push("Minnesota Vikings");

   Array myArray;

   myArray = myStack.ToArray();

   foreach(string item in myArray)

   {

      Label1.Text += item + "<br />";

   }

}

在上面的例子中,Stack类显式转换为string类型。这里用括号指定集合类型。这个例子使用Stack<string>把Stack类转换为string类型。如果要把它转换为不是string类型的Stack类,例如int,就可以指定Stack<int>。

在创建Stack类时,Stack类中的项集合就转换为指定的类型,所以Stack类不再把项集合转换为object类型,以后(在foreach循环中)再转换为string类型。这个过程称为装箱,该过程是很昂贵的。因为这段代码事先指定了类型,所以使用集合的性能提高了。

泛型除了用于处理集合类型之外,还可以用于处理类、委托、方法等。本书的第10章将详细介绍泛型。

匿名方法

匿名方法可以把编程步骤放在一个委托中,以后再执行该委托,而不是创建全新的方法。例如,如果不使用匿名方法,就要以下面的方式使用委托:

public partial class Default_aspx

{

   void Page_Load(object sender, EventArgs e)

   {

      this.Button1.Click += ButtonWork;

   }

   void ButtonWork(object sender, EventArgs e)

   {

      Label1.Text = "You clicked the button!";

   }

}

但若使用匿名方法,就可以把这些操作直接放在委托中,如下面的例子所示:

public partial class Default_aspx

{

   void Page_Load(object sender, EventArgs e)

   {

      this.Button1.Click += delegate(object myDelSender, EventArgs myDelEventArgs)

      {

         Label1.Text = "You clicked the button!";

      };

   }

}

在使用匿名方法时,不需要创建另一个方法,而是把需要的代码直接放在委托声明的后面。委托执行的语句和步骤放在花括号中,用一个分号结束。

可空类型

由于泛型引入到底层的.NET Framework 2.0中,所以现在可以使用System.Nullable<T>创建可空的值类型。这非常适合于创建int类型的可空项集合。而在此之前,很难创建包含空值的int,或者把空值赋予int。

要创建int类型的可空类型,可以使用下面的语法:

System.Nullable<int> x = new System.Nullable<int>;

有一个新的类型修饰符,可用于创建可空类型,如下面的例子所示:

int? salary = 800000

创建可空类型的功能不仅可在C#中使用,因为该功能内置于.NET Framework,而这要归功于.NET中的新泛型功能。所以,也可以在Visual Basic 2005中使用可空类型。

迭代器

迭代器允许在自己的定制类型中使用foreach循环。为此,需要使类实现IEnumerable接口,如下所示:

using System;

using Systm.Collections;

public class myList

{

   internal object[] elements;

   internal int count;

   public IEnumerator GetEnumerator()

   {

      yield return "St. Louis Rams";

      yield return "Indianapolis Colts";

      yield return "Minnesota Vikiings";

   }

}

要使用IEnumerator接口,需要引用System.Collections命名空间。有了该命名空间,就可以迭代定制类了,如下所示:

void Page_Load(object sender, EventArgs e)

{

   myList IteratorList = new myList();

   foreach(string item in IteratorList)

   {

      Response.Write(item.ToString() + "<br />");

   }

}

部分类

部分类是.NET Framework 2.0的一个新功能,C#也充分利用了这一功能。部分类可以把单个类分解到多个类文件中,以后在编译时再把这些文件合并起来。

要创建部分类,只需给要与另一个类合并起来的类使用partial关键字。在要与最初的类合并的类中,partial关键字放在class关键字的前面。例如,有一个简单的类Calculator,如下所示:

public class Calculator

{

   public int Add(int a, int b)

   {

      return a + b;

   }

}

接着,创建第二个类,它要与第一个类关联起来,如下面的例子所示:

public partial class Calculator

{

   public int Subtract(int a, int b)

   {

      return a - b;

   }

}

编译时,这些类会放在一个Calculator类实例中,就好像它们从一开始就放在一起一样。

C#的优点

C#在某种程度上可以看作是.NET面向Windows环境的一种编程语言。在过去的十几年中,Microsoft给Windows和 Windows API添加了许多功能,Visual Baisc 2005和C++也经历了许多变化。虽然Visual Baisc和C++最终已成为非常强大的语言,但这两种语言也存在问题,因为它们保留了原来的一些内容。

对于Visual Basic 6及其早期版本来说,它的主要优点是很容易理解,许多编程工作都很容易完成,基本上隐藏了Windows API和COM组件结构的内涵。其缺点是Visual Basic从来没有实现真正意义上的面向对象,所以大型应用程序很难分解和维护。另外,因为Visual Baisc的语法继承于BASIC的早期版本(BASIC主要是为了让初学者更容易理解,而不是为了编写大型商业应用程序),所以不能真正成为结构化或面向对象的编程语言。

另一方面,C++置根于ANSI C++语言定义。它与ANSI不完全兼容,因为Microsoft是在ANSI定义标准化之前编写C++编译器的,但已经相当接近了。遗憾的是,这导致了两个问题。其一,ANSI C++是在十几年前的技术条件下开发的,因此不支持现在的概念(例如Unicode字符串和生成XML文档),某些古老的语法结构是为以前的编译器设计的(例如成员函数的声明和定义是分开的)。其二,Microsoft同时还试图把C++演变为一种用于在Windows上执行高性能任务的语言在语言中避免添加大量Microsoft专用的关键字和各种库。其结果是在Windows中,该语言成为了一种非常杂乱的语言。让C++开发人员说说字符串有多少种定义方式就可以说明这一点:char*、LPTSTR、string、CString (MFC 版本)、CString (WTL 版本)、wchar_t*和 OLECHAR*等。

现在进入.NET时代—— 一种全新的环境,它对这两种语言都进行了新的扩展。Microsoft给C++添加了许多Microsoft专用的关键字,并把Visual Baisc演变为Visual Baisc.NET,再演变为Visual Baisc 2005,保留了一些基本的Visual Baisc语法,但在设计上完全不同,从实际应用的角度来看,Visual Baisc 2005是一种新语言。

在这里,Microsoft决定给开发人员另一个选择—— 专门用于.NET、具有新起点的语言, 即Visual C# 2005。Microsoft在正式场合把C#描述为一种简单、现代、面向对象、类型非常安全、派生于C和C++的编程语言。大多数独立的评论员对其说法是“派生于C、 C++ 和Java”。这种描述在技术上是非常准确的,但没有涉及到该语言的真正优点。从语法上看,C#非常类似于C++和Java,许多关键字都是相同的,C#也使用类似于C++和Java的块结构,并用括号({})来标记代码块,用分号分隔各行语句。对C#代码的第一印象是它非常类似于C++或Java代码。但在这些表面上的类似性后面,C#学习起来要比C++容易得多,但比Java难一些。其设计与现代开发工具的适应性要比其他语言更高,它同时具有Visual Basic的易用性、高性能以及C++的低级内存访问性。C#包括以下一些特性:

       完全支持类和面向对象编程,包括接口和继承、虚函数和运算符重载的处理。

       定义完整、一致的基本类型集。

       对自动生成XML文档说明的内置支持。

       自动清理动态分配的内存。

       可以用用户定义的特性来标记类或方法。这可以用于文档说明,对编译有一定的影响(例如,把方法标记为只在调试时编译)

       .NET基类库的完全访问权,并易于访问Windows API

       可以使用指针和直接内存访问,但C#语言可以在没有它们的条件下访问内存。

       Visual Baisc的风格支持属性和事件。

       改变编译器选项,可以把程序编译为可执行文件或.NET组件库,该组件库可以用与ActiveX控件(COM组件)相同的方式由其他代码调用。

       C#可以用于编写ASP.NET动态Web页面和XML Web服务。

应该指出,对于上述大多数特性,Visual Baisc 2005和Managed C++也具备。但C#从一开始就使用.NET,对.NET特性的支持不仅是完整的,而且提供了比其他语言更合适的语法。C#语言本身非常类似于Java,但其中有一些改进,因为Java并不是为应用于.NET环境而设计的。

在结束这个主题前,还要指出C#的两个局限性。其一是该语言不适用于编写时间急迫或性能非常高的代码,例如一个要运行1000或1050次的循环,并在不需要这些循环时,立即清理它们所占用的资源。在这方面,C++可能仍是所有低级语言中的佼佼者。其二是C#缺乏性能极高的应用程序所需要的关键功能,包括保证在代码的特定地方运行的内联函数和析构函数。但这类应用程序非常少。

编写和运行C#代码需要的环境

.NET Framework运行在Windows 98、2000、XP和2003上。要使用.NET编写代码,需要安装.NET SDK,除非使用内置了.NET Framework 1.0和1.1的Windows Server 2003。如果要运行本书中的例子,应安装.NET Framework 2.0,即使运行Windows Server 2003,也要安装.NET Framework 2.0,因为.NET Framework 2.0在这个服务器上并不是默认安装选项。

除非要使用文本编辑器或其他第三方开发环境来编写C#代码,否则一般使用Visual Studio 2005。运行托管代码不需要安装完整的SDK,但需要.NET运行库。需要把.NET运行库分布到还没有安装它的客户机上。

本书内容

在本书中,首先在第1章介绍.NET的整体体系结构,给出编写托管代码所需要的背景知识,此后本书分几部分介绍C#语言及其C#语言在各个领域中的应用。

第Ⅰ部分(1~13)——C#语言

本部分给出C#语言的背景知识。这一部分没有指定任何语言,但假定读者是有经验的编程人员。首先介绍C#的基本语法和数据类型,再介绍C#的面向对象特性,之后是C#中的一些高级论题。

第Ⅱ部分(14~18)——.NET环境

本部分介绍在.NET环境中的编程规则。特别是Visual Studio .NET、安全性、.NET应用程序的线程部署,以及把库生成为程序集的方式。

第Ⅲ部分(19~22)——数据

本部分介绍如何使用ADO.NET访问数据库,以及目录和Active Directory交互。我们还详细说明.NET对XML的支持、对Windows操作系统的支持,以及SQL Server 2005的.NET特性。

第Ⅳ部分(23~25)——Windows应用程序

本部分讨论传统Windows应用程序的创建,在.NET中这种应用程序称为Windows窗体。Windows窗体是应用程序的胖客户版本,使用.NET创建这些类型的应用程序是实现该任务的一种快捷、简单的方式。除了介绍Windows窗体之外,我们还将论述GDI+,这种技术可用于创建包含高级图形的应用程序。

第Ⅴ部分(26~27)——Web应用程序

这一部分介绍如何编写在网站上运行的组件,如何编写网页。其中包括ASP.NET 2.0提供的许多新特性。

第Ⅵ部分(28~32)——通信

这一部分介绍通信,主要论述独立于平台进行通信的Web服务、在.NET客户机和服务器之间通信的.NET Remoting技术、在后台上运行的Enterprise Services和DCOM通信。有了消息异步排队技术,可以进行断开连接的运行。“分布式编程的未来”一章可以让用户为当前的解决方案做出正确的通信选择。

第Ⅶ部分(33)——交互操作

COM的向后兼容性是.NET的一个重要组成部分,我们开发了太多的COM组件和应用程序。本部分将介绍如何在.NET应用程序中使用已有的COM组件,以及如何在COM应用程序中使用.NET组件。

第Ⅷ部分(34~36)——Windows基本服务

本部分是本书主要内容的总结,介绍如何访问文件和注册表,如何通过应用程序访问Internet,以及如何使用Windows服务。

第Ⅸ部分——附录(本书仅提供内容下载地址)

本部分包含几个附录,详细介绍了面向对象的编程规则及C#编程语言专用的信息。这些附录在本书中并未给出,您可以通过本书提及的Web站点http://www.wrox.com获得其PDF版本。

如何下载本书的示例代码

在读者学习本书中的示例时,可以手工输入所有的代码,也可以使用本书附带的源代码文件。本书使用的所有源代码都可以从本书合作站点http://www.wrox.com/或www.tupwk.com.cn/downpage上下载。登录到站点http://www.wrox.com/,使用Search工具或使用书名列表就可以找到本书。接着单击本书细目页面上的Download Code链接,就可以获得所有的源代码。

注释:

许多图书的书名都很相似,所以通过ISBN查找本书是最简单的,本书的ISBN0-7645-7534-1

在下载了代码后,只需用自己喜欢的解压缩软件对它进行解压缩即可。另外,也可以进入http://www.wrox.com/dynamic/books/download.aspx上的Wrox代码下载主页,查看本书和其他Wrox图书的所有代码。

勘误表

尽管我们已经尽了各种努力来保证文章或代码中不出现错误,但是错误总是难免的,如果您在本书中找到了错误,例如拼写错误或代码错误,请告诉我们,我们将非常感激。通过勘误表,可以让其他读者避免受挫,当然,这还有助于提供更高质量的信息。

请给fwkbook@tup.tsinghua.edu.cn发电子邮件,我们就会检查您的信息,如果是正确的,我们将在本书的后续版本中采用。

要在网站上找到本书的勘误表,可以登录http://www.wrox.com,通过Search工具或书名列表查找本书,然后在本书的细目页面上,单击Book Errata链接。在这个页面上可以查看到Wrox编辑已提交和粘贴的所有勘误项。完整的图书列表还包括每本书的勘误表,网址是www.wrox.com/misc-pages/booklist.shtml。

p2p.wrox.com

P2P邮件列表是为作者和读者之间的讨论而建立的。读者可以在p2p.wrox.com上加入P2P论坛。该论坛是一个基于Web的系统,用于传送与Wrox图书相关的信息和相关技术,与其他读者和技术用户交流。该论坛提供了订阅功能,当论坛上有新贴子时,会给您发送您选择的主题。Wrox作者、编辑和其他业界专家和读者都会在这个论坛上进行讨论。

在http://p2p.wrox.com上有许多不同的论坛,帮助读者阅读本书,在读者开发自己的应用程序时,也可以从这个论坛中获益。要加入这个论坛,需执行下面的步骤:

(1) 进入p2p.wrox.com,单击Register链接。

(2) 阅读其内容,单击Agree按钮。

(3) 提供加入论坛所需的信息及愿意提供的可选信息,单击Submit按钮。

然后就可以收到一封电子邮件,其中的信息描述了如何验证账户,完成加入过程。

提示:

不加入P2P也可以阅读论坛上的信息,但只有加入论坛后,才能发送自己的信息。

加入论坛后,就可以发送新信息,回应其他用户的贴子。可以随时在Web上阅读信息。如果希望某个论坛给自己发送新信息,可以在论坛列表中单击该论坛对应的Subscribe to this Forum图标。

对于如何使用Wrox P2P的更多信息,可阅读P2P FAQ,了解论坛软件的工作原理,以及许多针对P2P和Wrox图书的常见问题解答。要阅读FAQ,可以单击任意P2P页面上的FAQ链接。

1 .NET体系结构

我们不能孤立地使用C#语言,而必须和.NET Framework一起考虑。C#编译器专门用于.NET,这表示用C#编写的所有代码总是在.NET Framework中运行。对于C#语言来说,可以得出两个重要的结论:

       C#的结构和方法论反映了.NET基础方法论。

       在许多情况下,C#的特定语言功能取决于.NET的功能,或依赖于.NET基类。

由于这种依赖性,在开始使用C#编程前,了解.NET的结构和方法论就非常重要了,这就是本章的目的。

本章首先了解在.NET编译和运行所有的代码(包括C#)时通常会出现什么情况。对这些内容进行概述之后,就要详细阐述Microsoft中间语言(Microsoft Intermediate Language,MSIL或简称为IL),所有编译好的代码都要使用这种语言。本章特别要介绍IL、通用类型系统(Common Type System,CTS)及公共语言规范(Common Language Specification,CLS)如何提供.NET语言之间的互操作性。最后解释各种语言如何使用.NET,包括Visual Basic和C++。

之后,我们将介绍.NET的其他特性,包括程序集、命名空间和.NET基类。最后本章简要探讨一下C#开发人员可以创建的应用程序类型。

1.1  C#.NET的关系

C#是一种相当新的编程语言,C#的重要性体现在以下两个方面:

       它是专门为与Microsoft的.NET Framework一起使用而设计的。(.NET Framework是一个功能非常丰富的平台,可开发、部署和执行分布式应用程序)。

       它是一种基于现代面向对象设计方法的语言,在设计它时,Microsoft还吸取了其他类似语言的经验,这些语言是近20年来面向对象规则得到广泛应用后才开发出来的。

有一个很重要的问题要弄明白:C#就其本身而言只是一种语言,尽管它是用于生成面向.NET环境的代码,但它本身不是.NET的一部分。.NET支持的一些特性,C#并不支持。而C#语言支持的另一些特性,.NET却不支持(例如运算符重载)!

但是,因为C#语言是和.NET一起使用的,所以如果要使用C#高效地开发应用程序,理解Framework就非常重要,所以本章将介绍.NET的内涵。

1.2  公共语言运行库

.NET Framework的核心是其运行库的执行环境,称为公共语言运行库(CLR).NET运行库。通常将在CLR的控制下运行的代码称为托管代码(managed code)

但是,在CLR执行开发的源代码之前,需要编译它们(在C#中或其他语言中)。在.NET中,编译分为两个阶段:

(1) 把源代码编译为Microsoft中间语言(IL)

(2) CLR把IL编译为平台专用的代码。

这个两阶段的编译过程非常重要,因为Microsoft中间语言(托管代码)是提供.NET的许多优点的关键。

托管代码的优点

Microsoft中间语言与Java字节代码共享一种理念:它们都是一种低级语言,语法很简单(使用数字代码,而不是文本代码),可以非常快速地转换为内部机器码。对于代码来说,这种精心设计的通用语法,有很重要的优点。

1. 平台无关性

首先,这意味着包含字节代码指令的同一文件可以放在任一平台中,运行时编译过程的最后阶段可以很容易完成,这样代码就可以运行在该特定的平台上。换言之,编译为中间语言就可以获得.NET平台无关性,这与编译为Java字节代码就会得到Java平台无关性是一样的。

注意.NET的平台无关性目前只是一种可能,因为在编写本书时,.NET只能用于Windows平台,但人们正在积极准备,使它可以用于其他平台(参见Mono项目,它用于创建.NET的开放源代码的实现,参见http://www.go-mono.com/)

2. 提高性能

前面把IL和Java做了比较,实际上,IL比Java字节代码的作用还要大。IL总是即时编译的(称为JIT编译),而Java字节代码常常是解释性的,Java的一个缺点是,在运行应用程序时,把Java字节代码转换为内部可执行代码的过程会导致性能的损失(但在最近,Java在某些平台上能进行JIT编译)。

JIT编译器并不是把整个应用程序一次编译完(这样会有很长的启动时间),而是只编译它调用的那部分代码(这是其名称由来)。代码编译过一次后,得到的内部可执行代码就存储起来,直到退出该应用程序为止,这样在下次运行这部分代码时,就不需要重新编译了。Microsoft认为这个过程要比一开始就编译整个应用程序代码的效率高得多,因为任何应用程序的大部分代码实际上并不是在每次运行过程中都执行。使用JIT编译器,从来都不会编译这种代码。

这解释了为什么托管IL代码的执行几乎和内部机器代码的执行速度一样快,但是并没有说明为什么Microsoft认为这会提高性能。其原因是编译过程的最后一部分是在运行时进行的,JIT编译器确切地知道程序运行在什么类型的处理器上,可以利用该处理器提供的任何特性或特定的机器代码指令来优化最后的可执行代码。

传统的编译器会优化代码,但它们的优化过程是独立于代码所运行的特定处理器的。这是因为传统的编译器是在发布软件之前编译为内部机器可执行的代码。即编译器不知道代码所运行的处理器类型,例如该处理器是x86兼容处理器还是Alpha处理器,这超出了基本操作的范围。例如Visual Studio 6为一般的Pentium机器进行了优化,所以它生成的代码就不能利用Pentium III处理器的硬件特性。相反,JIT编译器不仅可以进行Visual Studio 6所能完成的优化工作,还可以优化代码所运行的特定处理器。

3. 语言的互操作性

使用IL不仅支持平台无关性,还支持语言的互操作性。简言之,就是能将任何一种语言编译为中间代码,编译好的代码可以与从其他语言编译过来的代码进行交互操作。

那么除了C#之外,还有什么语言可以通过.NET进行交互操作呢?下面就简要讨论其他常见语言如何与.NET交互操作。

(1) Visual Basic 2005

Visual Basic 6在升级到Visual Basic .NET 2002时,经历了一番脱胎换骨的变化,才集成到.NET Framework的第一版中。Visual Basic 语言对Visual Basic 6进行了很大的演化,也就是说,Visual Basic 6并不适合运行.NET程序。例如,它与COM的高度集成,且只把事件处理程序作为源代码显示给开发人员,大多数后台代码不能用作源代码。另外,它不支持继承,Visual Basic使用的标准数据类型也与.NET不兼容。

Visual Basic 6在2002年升级为Visual Basic .NET,对Visual Basic进行的改变非常大,完全可以把Visual Basic当作是一种新语言。现有的Visual Basic 6代码不能编译为Visual Basic 2005代码(或Visual Basic .NET 2002和2003代码),把Visual Basic 6程序转换为Visual Basic 2005时,需要对代码进行大量的改动,但大多数修改工作都可以由Visual Studio 2005(Visual Studio的升级版本,用于与.NET一起使用)自动完成。如果要把一个Visual Basic 6项目读取到Visual Studio 2005中,Visual Studio 2005就会升级该项目,也就是说把Visual Basic 6源代码重写为Visual Basic 2005源代码。虽然这意味着其中的工作已大大减轻,但用户仍需要检查新的Visual Basic 2005代码,以确保项目仍可正确工作,因为这种转换并不十分完美。

这种语言升级的一个副作用是不能再把Visual Basic 2005编译为内部可执行代码了。Visual Basic 2005只编译为中间语言,就像C#一样。如果需要继续使用Visual Basic 6编写程序,就可以这么做,但生成的可执行代码会完全忽略.NET Framework,如果继续把Visual Studio作为开发环境,就需要安装Visual Studio 6。

(2)Visual C++ 2005

Visual C++ 6有许多Microsoft对Windows的特定扩展。通过Visual C++ .NET,又加入了更多的扩展内容,来支持.NET Framework。现有的C++源代码会继续编译为内部可执行代码,不会有修改,但它会独立于.NET运行库运行。如果要让C++代码在.NET Framework中运行,就要在代码的开头添加下述命令:

#using <mscorlib.dll>

还可以把标记/clr传递给编译器,这样编译器假定要编译托管代码,因此会生成中间语言,而不是内部机器码。C++的一个有趣的问题是在编译托管代码时,编译器可以生成包含内嵌本机可执行代码的IL。这表示在C++代码中可以把托管类型和非托管类型合并起来,因此托管C++代码:

class MyClass

{

定义了一个普通的C++类,而代码:

__gc class MyClass

{

生成了一个托管类,就好像使用C#或Visual Basic 2005编写类一样。实际上,托管C++比C#更优越的一点是可以在托管C++代码中调用非托管C++类,而不必采用COM交互功能。

如果在托管类型上试图使用.NET不支持的特性(例如,模板或类的多继承),编译器就会出现一个错误。另外,在使用托管类时,还需要使用非标准的C++特性(例如上述代码中的__gc关键字)

因为C++允许低级指针操作,C++编译器不能生成可以通过CLR内存类型安全测试的代码。如果CLR把代码标识为内存类型安全是非常重要的,就需要用其他一些语言编写源代码,例如C# 或Visual Basic 2005。

(3)Visual J# 2005

最新添加的语言是Visual J# 2005。在.NET Framework 1.1版本推出之前,用户必须下载相应的软件,才能使用J#。现在J#语言内置于.NET Framework中。因此,J#用户可以利用Visual Studio 2005的所有常见特性。Microsoft希望大多数J++用户认为他们在使用.NET时,将很容易使用J#。J#不使用Java运行库,而是使用与其他.NET兼容语言一样的基类库。这说明,与C#和Visual Basic 2005一样,可以使用J#创建ASP.NET Web应用程序、Windows窗体、XML Web服务和其他应用程序。

(4)脚本语言

脚本语言仍在使用之中,但由于.NET的推出,一般认为它们的重要性在降低。另一方面,JScript升级到JScript.NET。现在ASP.NET页面可以用JScript.NET编写,可以把JScript.NET当作一种编译语言来运行,而不是解释性的语言,也可以编写强类型化的JScript.NET代码。有了ASP.NET后,就没有必要在服务器端的Web页面上使用脚本语言了,但VBA仍用作Office文档和Visual Studio宏语言。

(5)COMCOM+

从技术上讲,COM 和 COM+并不是面向.NET的技术,因为基于它们的组件不能编译为IL(但如果原来的COM组件是用C++编写的,使用托管C++,在某种程度上可以这么做)。但是,COM+仍然是一个重要的工具,因为其特性没有在.NET中完全实现。另外,COM组件仍可以使用——.NET组合了COM的互操作性,从而使托管代码可以调用COM组件,COM组件也可以调用托管代码(见第33章)。在一般情况下,把新组件编写为.NET组件,其多数目的是比较方便,因为这样可以利用.NET基类和托管代码的其他优点。

1.3  详细介绍中间语言

通过前面的学习,我们理解了Microsoft中间语言显然在.NET Framework中有非常重要的作用。C#开发人员应明白,C#代码在执行前要编译为中间语言(实际上,C#编译器仅编译为托管代码),这是有意义的,现在应详细讨论一下IL的主要特征,因为面向.NET的所有语言在逻辑上都需要支持IL的主要特征。

下面就是中间语言的主要特征:

       面向对象和使用接口

       值类型和引用类型之间的巨大差别

       强数据类型

       使用异常来处理错误

       使用特性(attribute)

下面详细讨论这些特征。

1.3.1  面向对象和接口的支持

.NET的语言无关性还有一些实际的限制。中间语言在设计时就打算实现某些特殊的编程方法,这表示面向它的语言必须与编程方法兼容,Microsoft为IL选择的特定道路是传统的面向对象的编程,带有类的单一继承性。

注意:

不熟悉面向对象功能概念的读者应参考附录A,获得更多的信息。附录A可以从www.wrox. com上下载。

除了传统的面向对象编程外,中间语言还引入了接口的概念,它们显示了在带有COM的Windows下的第一个实现方式。.NET接口与COM接口不同,它们不需要支持任何COM基础结构,例如,它们不是派生自IUnknown,也没有对应的GUID。但它们与COM接口共享下述理念:提供一个契约,实现给定接口的类必须提供该接口指定的方法和属性的实现方式。

前面介绍了使用.NET意味着要编译为中间语言,即需要使用传统的面向对象的方法来编程。但这并不能提供语言的互操作性。毕竟,C++和Java都使用相同的面向对象的范型,但它们仍不是可交互操作的语言。下面需要详细探讨一下语言互操作性的概念。

首先,需要确定一下语言互操作性的含义。毕竟,COM允许以不同语言编写的组件一起工作,即可以调用彼此的方法。这就足够了吗?COM是一个二进制标准,允许组件实例化其他组件,调用它们的方法或属性,而无需考虑编写相关组件的语言。但为了实现这个功能,每个对象都必须通过COM运行库来实例化,通过接口来访问。根据相关组件的线程模型,不同线程上内存空间和运行组件之间要编组数据,这还可能造成很大的性能损失。在极端情况下,组件保存为可执行文件,而不是DLL文件,还必须创建单独的进程来运行它们。重要的是组件要能与其他组件通信,但仅通过COM运行库进行通信。无论COM是用于允许使用不同语言的组件直接彼此通信,或者创建彼此的实例,系统都把COM作为中间件来处理。不仅如此,COM结构还不允许利用继承实现,即它丧失了面向对象编程的许多优势。

一个相关的问题是,在调试时,仍必须单独调试用不同语言编写的组件。这样就不可能在调试器上调试不同语言的代码了。语言互操作性的真正含义是用一种语言编写的类应能直接与用另一种语言编写的类通信。特别是:

       用一种语言编写的类应能继承用另一种语言编写的类。

       一个类应能包含另一个类的实例,而不管它们是使用什么语言编写的。

       一个对象应能直接调用用其他语言编写的另一个对象的方法。

       对象(或对象的引用)应能在方法之间传递。

       在不同的语言之间调用方法时,应能在调试器中调试这些方法调用,即调试不同语言编写的源代码。

这是一个雄心勃勃的目标,但令人惊讶的是,.NET和中间语言已经实现了这个目标。在调试器上调试方法时,Visual Studio 2005 IDE提供了这样的工具(不是CLR提供的)。

1.3.2  相异值类型和引用类型

与其他编程语言一样,中间语言提供了许多预定义的基本数据类型。它的一个特性是值类型和引用类型有明显的区别。对于值类型,变量直接保存其数据,而对于引用类型,变量仅保存地址,对应的数据可以在该地址中找到。

在C++中,引用类型类似于通过指针来访问变量,而在Visual Basic中,与引用类型最相似的是对象,Visual Basic 6是通过引用来访问对象。中间语言也有数据存储的规范:引用类型的实例总是存储在一个名为托管堆的内存区域中,值类型一般存储在堆栈中(但如果值类型在引用类型中声明为字段,它们就内联存储在堆中)。第2章“C#基础”讨论堆栈和堆,及其工作原理。

1.3.3  强数据类型

中间语言的一个重要方面是它基于强数据类型。所有的变量都清晰地标记为属于某个特定数据类型(在中间语言中没有Visual Basic和脚本语言的Variant数据类型)。特别是中间语言一般不允许对模糊的数据类型执行任何操作。

例如,Visual Basic 6开发人员习惯于传递变量,而无需考虑它们的类型,因为Visual Basic6会自动进行所需的类型转换。C++开发人员习惯于在不同类型之间转换指针类型。执行这类操作将大大提高性能,但破坏了类型的安全性。因此,这类操作只能在某些编译为托管代码的语言中的特殊情况下进行。确实,指针(相对于引用)只能在标记了的C#代码块中使用,但在Visual Basic中不能使用(但一般在托管C++中允许使用)。在代码中使用指针会立即导致CLR提供的内存类型安全性检查失败。

注意,一些与.NET兼容的语言,例如Visual Basic 2005,在类型化方面上要求仍比较松,但这是可以的,因为编译器在后台确保在生成的IL上强制类型安全。

尽管强迫实现类型的安全性最初会降低性能,但在许多情况下,我们从.NET提供的、依赖于类型安全的服务中获得的好处更多。这些服务包括:

       语言的互操作性

       垃圾收集

       安全性

       应用程序域

下面讨论强数据类型化对这些.NET特性非常重要的原因。

1. 语言互操作性中强数据类型的重要性

如果类派生其他类,或包含其他类的实例,它就需要知道其他类使用的所有数据类型,这就是强数据类型非常重要的原因。实际上,过去没有任何系统指定这些信息,从而成为语言继承和交互操作的真正障碍。这类信息不只是在一个标准的可执行文件或DLL中出现。

假定Visual Basic 2005类中的一个方法被定义为返回一个整型——Visual Basic 2005可以使用的标准数据类型之一。但C#没有该名称的数据类型。显然,我们只能从该类中派生,再使用这个方法,如果编译器知道如何把Visual Basic 2005的整型类型映射为C#定义的某种已知类型,就可以在C#代码中使用返回的类型。这个问题在.NET中是如何解决的?

(1) 通用类型系统(CTS)

这个数据类型问题在.NET中使用通用类型系统(CTS)得到了解决。CTS定义了可以在中间语言中使用的预定义数据类型,所有面向.NET Framework的语言都可以生成最终基于这些类型的编译代码。

例如,Visual Basic 2005的整型实际上是一个32位有符号的整数,它实际映射为中间语言类型Int32。因此在中间语言代码中就指定这种数据类型。C#编译器可以使用这种类型,所以就不会有问题了。在源代码中,C#用关键字int来表示Int32,所以编译器就认为Visual Basic 2005方法返回一个int类型的值。

通用类型系统不仅指定了基本数据类型,还定义了一个内容丰富的类型层次结构,其中包含设计合理的位置,在这些位置上,代码允许定义它自己的类型。通用类型系统的层次结构反映了中间语言的单一继承的面向对象的方法,如图1-1所示。

  1-1

这个树形结构中的类型说明如表1-1所示。

  1-1

   

   

Type

代表任何类型的基类

Value Type

代表任何值类型的基类

Reference Types

通过引用来访问,且存储在堆中的任何数据类型

Built-in Value Types

包含大多数标准基本类型,可以表示数字、Boolean值或字符

Enumerations

枚举值的集合

User-defined Value Types

在源代码中定义,且保存为值类型的数据类型。在C#中,它表示结构

Interface Types

接口

Pointer Types

指针

Self-describing Types

为垃圾回收器提供对它们本身有益的信息的数据类型(参见下一节)

Arrays

包含对象数组的类型

Class Types

可自我描述的类型,但不是数组

Delegates

用于把引用包含在方法中的类型

User-definedReference Types

在源代码中定义,且保存为引用类型的数据类型。在C#中,它表示类

Boxed Value Types

值类型,临时打包放在一个引用中,以便于存储在堆中

这里没有列出内置的所有值类型,因为第3章将详细介绍它们。在C#中,编译器识别的每个预定义类型都映射为一个IL内置类型。这与Visual Basic 2005是一样的。

(2) 公共语言规范(CLS)

公共语言规范(Common Language Specification,CLS)和通用类型系统一起确保语言的互操作性。CLS是一个最低标准集,所有面向.NET的编译器都必须支持它。因为IL是一种内涵非常丰富的语言,大多数编译器的编写人员有可能把给定编译器的功能限制为只支持IL和CLS提供的一部分特性。只要编译器支持已在CLS中定义的内容,这就是很不错的。

提示:

编写非CLS兼容代码应该是完全可以接受的,只是编写了这种代码后,就不能保证编译好的IL代码完全支持语言的互操作性。

下面的一个例子是有关区分大小写字母的。IL是区分大小写的语言。使用这些语言的开发人员常常利用区分大小写所提供的灵活性来选择变量名。但Visual Basic 2005是不区分大小写的语言。CLS就要指定CLS兼容代码不使用任何只根据大小写来区分的名称。因此,Visual Basic 2005代码可以与CLS兼容代码一起使用。

这个例子说明了CLS的两种工作方式。首先是各个编译器的功能不必强大到支持.NET的所有功能,这将鼓励人们为其他面向.NET的编程语言开发编译器。第二,它提供如下保证:如果限制类只能使用CLS兼容的特性,就要保证用其他语言编写的代码可以使用这个类。

这种方法的优点是使用CLS兼容特性的限制只适用于公共和受保护的类成员和公共类。在类的私有实现方式中,可以编写非CLS代码,因为其他程序集(托管代码的单元,参见本章后面的内容)中的代码不能访问这部分代码。

这里不深入讨论CLS规范。在一般情况下,CLS对C#代码的影响不会太大,因为C#中的非CLS兼容特性非常少。

2. 垃圾收集

垃圾收集器用来在.NET中进行内存管理,特别是它可以恢复正在运行中的应用程序需要的内存。到目前为止,Windows平台已经使用了两种技术来释放进程向系统动态请求的内存:

       完全以手工方式使应用程序代码完成这些工作。

       让对象维护引用计数。

让应用程序代码负责释放内存是低级、高性能的语言使用的技术,例如C++。这种技术很有效,且可以让资源在不需要时就释放(一般情况下),但其最大的缺点是频繁出现错误。请求内存的代码还必须明确通知系统它什么时候不再需要该内存。但这是很容易被遗漏的,从而导致内存泄漏。

尽管现代的开发环境提供了帮助检测内存泄漏的工具,但它们很难跟踪错误,因为直到内存已大量泄漏从而使Windows拒绝为进程提供资源时,它们才会发挥作用。到那个时候,由于对内存的需求,会使整个计算机变得相当慢。

维护引用计数是COM对象采用的一种技术,其方法是每个COM组件都保留一个计数,记录客户机目前对它的引用数。当这个计数下降到0时,组件就会删除自己,并释放相应的内存和资源。它带来的问题是仍需要客户机通知组件它们已经完成了内存的使用。只要有一个客户机没有这么做,对象就仍驻留在内存中。在某些方面,这是比C++内存泄漏更为严重的问题,因为COM对象可能存在于它自己的进程中,从来不会被系统删除(在C++内存泄漏问题上,系统至少可以在进程中断时释放所有的内存)。

.NET运行库采用的方法是垃圾收集器,这是一个程序,其目的是清理内存,方法是所有动态请求的内存都分配到堆上(这对所有的语言都一样,但在.NET中,CLR维护它自己的托管堆,以供.NET应用程序使用),当.NET检测到给定进程的托管堆已满,需要清理时,就调用垃圾收集器。垃圾收集器处理目前代码中的所有变量,检查对存储在托管堆上的对象的引用,确定哪些对象可以从代码中访问—— 即哪些对象有引用。没有引用的对象就不能再从代码中访问,因而被删除。Java就使用与此类似的垃圾收集系统。

之所以在.NET中使用垃圾收集器,是因为中间语言已用来处理进程。其规则要求,第一,不能引用已有的对象,除非复制已有的引用。第二,中间语言是类型安全的语言。在这里,其含义是如果存在对对象的任何引用,该引用中就有足够的信息来确定对象的类型。

垃圾收集器机制不能和诸如非托管C++这样的语言一起使用,因为C++允许指针自由地转换数据类型。

垃圾收集器的一个重要方面是它是不确定的。换言之,不能保证什么时候会调用垃圾收集器:.NET运行库决定需要它时,就可以调用它(除非明确调用垃圾收集器)。但可以重写这个过程,在代码中调用垃圾收集器。

3. 安全性

.NET很好地补足了Windows提供的安全机制,因为它提供的安全机制是基于代码的安全性,而Windows仅提供了基于角色的安全性。

基于角色的安全性建立在运行进程的账户的身份基础上,换言之,就是谁拥有和运行进程。另一方面,基于代码的安全性建立在代码实际执行的任务和代码的可信程度上。由于中间语言提供了强大的类型安全性,所以CLR可以在运行代码前检查它,以确定是否有需要的安全权限。.NET还提供了一种机制,可以在运行代码前指定代码需要什么安全权限。

基于代码的安全性非常重要,原因是它降低了运行怀疑其出处的代码的风险(例如代码是从Internet上下载来的)。即使代码运行在管理员账户下,也有可能使用基于代码的安全性,来确定这段代码是否仍不能执行管理员账户一般允许执行的某些类型的操作,例如读写环境变量、读写注册表或访问.NET反射特性。

安全问题详见本书后面的第16章。

4. 应用程序域

应用程序域是.NET中的一个重要技术改进,它用于减少运行应用程序的系统开销,这些应用程序需要与其他程序分离开来,但同时还需要彼此通信。典型的例子是Web服务器应用程序,它需要同时响应许多浏览器请求。因此,要有许多组件实例同时响应这些同时运行的请求。

在.NET没有开发出来前,可以让这些实例共享同一个进程,但此时一个运行的实例就有可能导致整个网站的崩溃;也可以把这些实例孤立在不同的进程中,但这样做会增加相关性能的系统开销。

到现在为止,孤立代码的惟一方式是通过进程来实现的。在运行一个新的应用程序时,它会在一个进程环境内运行。Windows通过地址空间把进程分隔开来。这样,每个进程有4GB的虚拟内存来存储其数据和可执行代码(4GB对应于32位系统,64位系统要用更多的内存)。Windows利用额外的间接方式把这些虚拟内存映射到物理内存或磁盘空间的一个特殊区域中,每个进程都会有不同的映射,虚拟地址空间块映射的物理内存之间不能有重叠,这种情况如图1-2所示。

  1-2

在一般情况下,任何进程都只能通过指定虚拟内存中的一个地址来访问内存——即进程不能直接访问物理内存,因此一个进程不可能访问分配给另一个进程的内存。这样就可以确保任何执行出错的代码不会损害其地址空间以外的数据(注意在Windows 95/98上,这些保护措施不像在Windows NT/2000/XP/2003上那样强大,所以理论上存在应用程序因写入不对应的内存而导致Windows崩溃的可能性)。

进程不仅是运行代码的实例相互隔离的一种方式,在Windows NT/2000/XP/2003系统上,它们还可以构成分配了安全权限和许可的单元。每个进程都有自己的安全标识,明确地表示Windows允许该进程可以执行的操作。

进程对确保安全有很大的帮助,而它们的一大缺点是性能。许多进程常常在一起工作,因此需要相互通信。一个常见的例子是进程调用一个COM组件,而该COM组件是可执行的,因此需要在它自己的进程上运行。在COM中使用代理时也会发生类似的情况。因为进程不能共享任何内存,所以必须使用一个复杂的编组过程在进程之间复制数据。这对性能有非常大的影响。如果需要使组件一起工作,但不希望性能有损失,惟一的方法是使用基于DLL的组件,让所有的组件在同一个地址空间中运行—— 其风险是执行出错的组件会影响其他组件。

应用程序域是分离组件的一种方式,它不会导致因在进程之间传送数据而产生的性能问题。其方法是把任何一个进程分解到多个应用程序域中,每个应用程序域大致对应一个应用程序,执行的每个线程都运行在一个具体的应用程序域中,如图1-3所示。

  1-3

如果不同的可执行文件都运行在同一个进程空间中,显然它们就能轻松地共享数据,因为理论上它们可以直接访问彼此的数据。虽然在理论上这是可以实现的,但是CLR会检查每个正在运行的应用程序的代码,以确保这些代码不偏离它自己的数据区域,保证不发生直接访问其他进程的数据的情况。这初看起来是不可能的,如何告诉程序要做什么工作,而又不真正运    行它?

实际上,这么做通常是可能的,因为中间语言拥有强大的类型安全功能。在大多数情况下,除非代码明确使用不安全的特性,例如指针,否则它使用的数据类型可以确保内存不会被错误地访问。例如,.NET数组类型执行边界检查,以禁止执行超出边界的数组操作。如果运行的应用程序的确需要与运行在不同应用程序域中的其他应用程序通信或共享数据,就必须调用.NET的远程服务。

被验证不能访问超出其应用程序域的数据(而不是通过明确的远程机制)的代码就是内存类型安全的代码,这种代码与运行在同一个进程中但应用程序域不同的类型安全代码一起运行是安全的。

1.3.4  通过异常方法处理错误

.NET Framework可以根据异常使用相同的机制处理错误情况,这与Java和C++是一样的。C++开发人员应注意到,由于IL有非常强大的类型系统,所以在IL中以C++的方式使用异常不会带来相关的性能问题。另外,.NET和C#也支持finally块,这是许多C++开发人员长久以来的愿望。

第12章会详细讨论异常。简要地说,代码的某些领域被看作是异常处理程序例程,每个例程都能处理某种特殊的错误情况(例如,找不到文件,或拒绝执行某些操作的许可)。这些条件可以定义得很宽或很窄。异常结构确保在发生错误情况时,执行进程立即跳到异常处理程序例程上,处理错误情况。

异常处理的结构还提供了一种方便的方式,当对象包含错误情况的准确信息时,该对象就可以传送给错误处理例程。这个对象包括给用户提供的相应信息和在代码的什么地方检测到错误的确切信息。

大多数异常处理结构,包括异常发生时的程序流控制,都是由高级语言处理的,例如C#、Visual Basic 2005和C++,任何中间语言命令都不支持它。例如,C#使用try{}、catch{}和 finally{}代码块来处理它,详见第12章。

.NET提供了一种基础结构,让面向.NET的编译器支持异常处理。特别是它提供了一组.NET类来表示异常,语言的互操作性则允许错误处理代码处理被抛出的异常对象,无论错误处理代码使用什么语言编写,都是这样。语言的无关性没有体现在C++和Java的异常处理中,但在COM的错误处理机制中有一定限度的体现。COM的错误处理机制包括从方法中返回错误代码以及传递错误对象。在不同的语言中,异常的处理是一致的,这是多语言开发的重要一环。

1.3.5  特性的使用

特性(attribute)是使用C++编写COM组件的开发人员很熟悉的一个功能(使用Microsoft的COM接口定义语言(Interface Definition Language,IDL))。特性最初是为了在程序中提供与某些项相关的额外信息,以供编译器使用。

.NET支持特性,因此现在C++、C#和Visual Basic 2005也支持特性。但在.NET中,对特性的革新是建立了一个机制,通过该机制可以在源代码中定义自己的特性。这些用户定义的特性将和对应数据类型或方法的元数据放在一起,这对于文档说明书十分有用,它们和反射技术一起使用,以根据特性执行编程任务。另外,与.NET的语言无关性的基本原理一样,特性也可以在一种语言的源代码中定义,而被用另一种语言编写的代码读取。

本书的第11章详细介绍了特性。

1.4  程序集

程序集(assembly)是包含编译好的、面向.NET Framework的代码的逻辑单元。本章不详细论述程序集,而在第15章中论述,下面概述其中的要点。

程序集是完全自我描述性的,也是一个逻辑单元而不是物理单元,它可以存储在多个文件中(动态程序集的确存储在内存中,而不是存储在文件中)。如果一个程序集存储在多个文件中,其中就会有一个包含入口点的主文件,该文件描述了程序集中的其他文件。

注意可执行代码和库代码使用相同的程序集结构。惟一的区别是可执行的程序集包含一个主程序入口点,而库程序集则不包含。

程序集的一个重要特性是它们包含的元数据描述了对应代码中定义的类型和方法。程序集也包含描述程序集本身的元数据,这种程序集元数据包含在一个称为程序集清单的区域中,可以检查程序集的版本及其完整性。

注意:

ildasm是一个基于Windows的实用程序,可以用于检查程序集的内容,包括程序集清单和元数据。第15章将介绍ildasm

程序集包含程序的元数据,表示调用给定程序集中的代码的应用程序或其他程序集不需要指定注册表或其他数据源,以便确定如何使用该程序集。这与以前的COM有很大的不同,以前,组件的GUID和接口必须从注册表中获取,在某些情况下,方法和属性的详细信息也需要从类型库中读取。

把数据分散在3个以上的不同位置上,可能会出现信息不同步的情况,从而妨碍其他软件成功地使用该组件。有了程序集后,就不会发生这种情况,因为所有的元数据都与程序的可执行指令存储在一起。注意,即使程序集存储在几个文件中,数据也不会出现不同步的问题。这是因为包含程序集入口的文件也存储了其他文件的细节、散列和内容,如果一个文件被替换,或者被塞满,系统肯定会检测出来,并拒绝加载程序集。

程序集有两种类型:共享程序集和私有程序集。

1.4.1  私有程序集

私有程序集是最简单的一种程序集类型。私有程序集一般附带在某些软件上,且只能用于该软件中。附带私有程序集的常见情况是,以可执行文件或许多库的方式提供应用程序,这些库包含的代码只能用于该应用程序。

系统可以保证私有程序集不被其他软件使用,因为应用程序只能加载位于主执行文件所在文件夹或其子文件夹中的程序集。

用户一般会希望把商用软件安装在它自己的目录下,这样软件包没有覆盖、修改或加载另一个软件包的私有程序集的风险。私有程序集只能用于自己的软件包,这样,用户对什么软件使用它们就有了更多的控制。因此,不需要采取安全措施,因为这没有其他商用软件用某个新版本的程序集覆盖原来的私有程序集的风险(但软件是专门执行怀有恶意的损害性操作的情况除外)。名称也不会有冲突。如果私有程序集中的类正巧与另一个人的私有程序集中的类同名,是不会有问题的,因为给定的应用程序只能使用私有程序集的名称。

因为私有程序集完全是自含式的,所以安装它的过程就很简单。只需把相应的文件放在文件系统的对应文件夹中即可(不需要注册表项),这个过程称为“0影响(xcopy)安装”。

1.4.2  共享程序集

共享程序集是其他应用程序可以使用的公共库。因为其他软件可以访问共享程序集,所以需要采取一定的保护措施来防止以下风险:

       名称冲突,另一个公司的共享程序集执行的类型与自己的共享程序集中的类型同名。因为客户机代码理论上可以同时访问这些程序集,所以这是一个严重的问题。

       程序集被同一个程序集的不同版本覆盖——新版本与某些已有的客户机代码不兼容。

这些问题的解决方法是把共享程序集放在文件系统的一个特定的子目录树中,称为全局程序集高速缓存(GAC)。与私有程序集不同,不能简单地把共享程序集复制到对应的文件夹中,而需要专门安装到高速缓存中,这个过程可以用许多.NET工具来完成,其中包含对程序集的检查、在程序集高速缓存中设置一个小的文件夹层次结构,以确保程序集的完整性。

为了避免名称冲突,共享程序集应根据私有密钥加密法指定一个名称(私有程序集只需要指定与其主文件名相同的名称即可)。该名称称为强名(strong name),并保证其惟一性,它必须由要引用共享程序集的应用程序来引用。

与覆盖程序集相关的问题,可以通过在程序集清单中指定版本信息来解决,也可以通过同时安装来解决。

1.4.3  反射

因为程序集存储了元数据,包括在程序集中定义的所有类型和这些类型的成员的细节,所以可以编程访问这些元数据。这个技术称为反射,第11章详细介绍了它们。该技术很有趣,因为它表示托管代码实际上可以检查其他托管代码,甚至检查它自己,以确定该代码的信息。它们常常用于获取特性的详细信息,也可以把反射用于其他目的,例如作为实例化类或调用方法的一种间接方式,如果把方法上的类名指定为字符串,就可以选择类来实例化方法,以便在运行时调用,而不是在编译时调用,例如根据用户的输入来调用(动态绑定)。

1.5  .NET Framework

至少从开发人员的角度来看,编写托管代码的最大好处是可以使用.NET基类库。

.NET基类是一个内容丰富的托管代码类集合,它可以完成以前要通过Windows API来完成的绝大多数任务。这些类派生自与中间语言相同的对象模型,也基于单一继承性。无论.NET基类是否合适,都可以实例化对象,也可以从它们派生自己的类。

.NET基类的一个优点是它们非常直观和易用。例如,要启动一个线程,可以调用Thread类的Start()方法。要禁用TextBox,应把TextBox对象的Enabled属性设置为false。Visual Basic和Java开发人员非常熟悉这种方式。它们的库都很容易使用,但对于C++开发人员来说这是极大的解脱,因为他们多年来一直在使用诸如GetDIBits()、RegisterWndClassEx()和IsEqualIID()这样的API函数,以及需要传递Windows句柄的函数。

另一方面,C++开发人员总是很容易访问整个Windows API,而Visual Basic 6和Java开发人员只能访问其语言所能访问的基本操作系统功能。.NET基类的新增内容就是把Visual Basic和Java库的易用性和Windows API函数的丰富功能结合起来。但Windows仍有许多功能不能通过基类来使用,而需要调用API函数。但一般情况下,这只限于比较复杂的特性。在日常的使用中,会发现基类非常丰富。如果需要调用API函数,.NET提供了所谓的“平台调用”,来确保对数据类型进行正确的转换,这样无论是使用C#、C++或Visual Basic 2005进行编码,该任务都不会比直接从已有的C++代码中调用函数更困难。

注意:

WinCV是一个基于Windows的实用程序,可以用于浏览基类库中的类、结构、接口和枚举。本书将在第14章介绍WinCV

第3章主要介绍基类。完成了C#语言语法的概述后,本书的其余内容将主要说明如何使用.NET基类库中的各种类,即各种基类是如何工作的。.NET基类包括:

       IL提供的核心功能,例如,通用类型系统中的基本数据类型,详见第3章。

       Windows GUI支持和控件(23)

       Web窗体(ASP.NET,第26~27)

       数据访问(ADO.NET,第19~21)

       目录访问(22)

       文件系统和注册表访问(34)

       网络和Web浏览(35)

       .NET特性和反射(11)

       访问Windows操作系统的各个方面(例如环境变量等,第16)

       COM互操作性(3033)

附带说一下,根据Microsoft源文件,大部分.NET基类实际上都是用C#编写的!

1.6  命名空间

命名空间是.NET避免类名冲突的一种方式。例如,命名空间可以避免下述情况:定义一个类来表示一个顾客,称此类为Customer,同时其他人也在做相同的事(这有一个类似的场景——顾客有相当多的业务)。

命名空间不过是数据类型的一种组合方式,但命名空间中所有数据类型的名称都会自动加上该命名空间的名字作为其前缀。命名空间还可以相互嵌套。例如,大多数用于一般目的的.NET基类位于命名空间System中,基类Array在这个命名空间中,所以其全名是System.Array。

.NET需要在命名空间中定义所有的类型,例如,可以把Customer类放在命名空间YourCompanyName中,则这个类的全名就是YourCompanyName.Customer。

注意:

如果没有显式提供命名空间,类型就添加到一个没有名称的全局命名空间中。

Microsoft建议在大多数情况下,都至少要提供两个嵌套的命名空间名,第一个是公司名,第二个是技术名称或软件包的名称,而类是其中的一个成员,例如YourCompanyName
.Sales Services.Customer。在大多数情况下,这么做可以保证类的名称不会与其他组织编写的类名冲突。

第2章将详细介绍命名空间。

1.7  C#创建.NET应用程序

C#可以用于创建控制台应用程序:仅使用文本、运行在DOS窗口中的应用程序。在进行单元测试类库、创建Unix/Linux daemon进程时,就要使用控制台应用程序。但是,我们常常使用C#创建利用许多与.NET相关的技术的应用程序,下面简要论述可以用C#创建的不同类型的应用程序。

1.7.1  创建ASP.NET应用程序

ASP是用于创建带有动态内容的Web页面的一种Microsoft技术。ASP页面基本是一个嵌有服务器端VBScript或JavaScript代码块的HTML文件。当客户浏览器请求一个ASP页面时,Web服务器就会发送页面的HTML部分,并处理服务器端脚本。这些脚本通常会查询数据库的数据,在HTML中标记数据。ASP是客户建立基于浏览器的应用程序的一种便利方式。

但ASP也有缺点。首先,ASP页面有时显示得比较慢,因为服务器端代码是解释性的,而不是编译的。第二,ASP文件很难维护,因为它不是结构化的,服务器端的ASP代码和一般的HTML会混合在一起。第三,ASP有时开发起来会比较困难,因为它不支持错误处理和语法检查。特别是如果使用VBScript,并希望在页面中进行错误处理,就必须使用OnErrorResumeNext语句,通过Err.Number检查调用每个组件,以确保该调用正常进行。

ASP.NETASP的修订版本,它解决了ASP的许多问题。但ASP.NET页面并没有替代ASP,而是可以与原来的ASP应用程序在同一个服务器上同时并存。当然,也可以用C#编写ASP.NET

后面的章节(第26~27章)会详细讨论ASP.NET,这里仅解释它的一些重要特性。

1. ASP.NET的特性

首先,也是最重要的是,ASP.NET页面是结构化的。这就是说,每个页面都是一个继承了.NET类System.Web.UI.Page的类,可以重写在Page对象的生存期中调用的一系列方法, (可以把这些事件看成是页面所特有的,对应于原ASP的global.asa文件中的OnApplication_StartOnSession_Start事件)。因为可以把一个页面的功能放在有明确含义的事件处理程序中,所以ASP.NET比较容易理解。

ASP.NET页面的另一个优点是可以在Visual Studio 2005中创建它们,在该环境下,可以创建ASP.NET页面使用的业务逻辑和数据访问组件。Visual Studio 2005项目(也称为解决方案)包含了与应用程序相关的所有文件。而且,也可以在编辑器中调试传统的ASP页面,在以前使用Visual InterDev时,把InterDev和项目的Web服务器配置为支持调试常常是一个让人头痛的问题。

最清楚的是,ASP.NET的后台编码功能允许进一步采用结构化的方式。ASP.NET允许把页面的服务器端功能单独放在一个类中,把该类编译为DLL,并把该DLL放在HTML部分下面的一个目录中。放在页面顶部的后台编码指令将把该文件与其DLL关联起来。当浏览器请求该页面时,Web服务器就会在页面的后台DLL中引发类中的事件。

最后ASP.NET在性能的提高上非常明显。传统的ASP页面是和每个页面请求一起进行解释,而Web服务器是在编译后高速缓存ASP.NET页面。这表示以后对ASP.NET页面的请求就比ASP页面的执行速度快得多。

ASP.NET还易于编写通过浏览器显示窗体的页面,这在内联网环境中会使用。传统的方式是基于窗体的应用程序提供一个功能丰富的用户界面,但较难维护,因为它们运行在非常多的不同机器上。因此,当用户界面是必不可少的,并可以为用户提供扩展支持时,人们就会依赖基于窗体的应用程序。

但随着Internet Explorer 5的出现,而Navigator 6的性能乏善可陈时,基于窗体的应用程序的优点就非常突出了。IE 5对DHTML的健全的支持,允许程序员创建基于Web的应用程序,这些应用程序都能很好地对应于其客户端程序。当然,这种应用程序需要在IE上进行标准化,而不支持Navigator。在许多行业中,这种标准化现在是很常见的。

2. Web窗体

为了简化Web页面的结构,Visual Studio 2005提供了Web窗体。它们允许以创建Visual Basic 6或 C++ Builder窗口的方式图形化地建立ASP.NET页面;换言之,就是把控件从工具箱拖放到窗体上,再考虑窗体的代码,为控件编写事件处理程序。在使用C#创建Web窗体时,就是创建一个继承于Page基类的C#类,以及把这个类看作是后台编码的ASP.NET页面。当然不必使用C#创建Web窗体,而可以使用Visual Basic 2005或另一种.NET语言来创建。

过去,Web开发的困难使一些开发小组不愿意使用Web。为了成功地进行Web开发,必须了解非常多的不同技术,例如VBScript、ASP、DHTML、JavaScript等。把窗体概念应用于Web页面,Web窗体就可以使Web开发容易许多。

Web服务器控件

用于添加到Web窗体上的控件与ActiveX控件并不是同一种控件,它们是ASP.NET命名空间中的XML标记,当请求一个页面时,Web浏览器会动态地把它们转换为HTML和客户端脚本。Web服务器能以不同的方式显示相同的服务器端控件,产生一个对应于请求者特定Web浏览器的转换。这意味着现在很容易为Web页面编写相当复杂的用户界面,而不必担心如何确保页面运行在可用的任何浏览器上,因为Web窗体会完成这些任务。

可以使用C#或Visual Basic 2005扩展Web窗体工具箱。创建一个新服务器端控件,仅是执行.NET的System.Web.UI.WebControls.WebControl类而已。

3. XML Web服务

目前,HTML页面解决了World Wide Web上的大部分通信问题。有了XML,计算机就可以用一种独立于设备的格式,在Web上彼此通信。将来,计算机可以使用Web和XML交流信息,而不是专用的线路和专用的格式,例如EDI (Electronic Data Interchange)。XML Web服务是为面向Web的服务而设计的,即远程计算机彼此提供可以分析和重新格式化的动态信息,最后显示给用户。XML Web服务是计算机给Web上的其他计算机以XML格式显示信息的一种便利方式。

在技术上,.NET上的XML Web服务是给请求的客户返回XML而不是HTML的ASP.NET页面。这种页面有后台编码的DLL,它包含了派生自WebService类的类。Visual Studio 2005 IDE提供的引擎简化了Web服务的开发。

公司选择使用XML Web服务主要有两个原因。第一是因为它们依赖于HTTP,而XML Web服务可以把现有的网络(HTTP)用作传输信息的媒介。第二是因为XML Web服务使用XML,该数据格式是自我描述的、非专用的、独立于平台的。

1.7.2  创建Windows窗体

C#和.NET非常适合于Web开发,它们还为所谓的“胖客户端”应用程序提供了极好的支持,这种“胖客户端”应用程序必须安装在处理大多数操作的最终用户的机器上,这种支持来源于Windows窗体。

Windows窗体是Visual Basic 6窗体的.NET版本,要设计一个图形化的窗口界面,只需把控件从工具箱拖放到Windows窗体上即可。要确定窗口的行为,应为该窗体的控件编写事件处理例程。Windows Form项目编译为.EXE,该EXE必须与.NET运行库一起安装在最终用户的计算机上。与其他.NET项目类型一样,Visual Basic 2005和C#都支持Windows Form项目。第23章将详细介绍Windows窗体。

1.7.3  Windows控件

Web窗体和Windows窗体的开发方式一样,但应为它们添加不同类型的控件。Web窗体使用Web服务器控件,Windows窗体使用Windows控件。

Windows控件比较类似于ActiveX控件。在执行Windows控件后,它会编译为必须安装到客户机器上的DLL。实际上,.NET SDK提供了一个实用程序,为ActiveX控件创建包装器,以便把它们放在Windows窗体上。与Web控件一样,Windows控件的创建需要派生于特定的类System.Windows.Forms.Control

1.7.4  Windows服务

Windows服务(最初称为NT服务)是一个在Windows NT/2000/XP/2003 (但没有Windows 9x)后台运行的程序。当希望程序连续运行,响应事件,但没有用户的明确启动操作时,就应使用Windows服务。例如Web服务器上的World Wide Web服务,它们监听来自客户的Web请求。

用C#编写服务是非常简单的。System.ServiceProcess命名空间中的.NET Framework基类可以处理许多与服务相关的样本任务,另外,Visual Studio 2005允许创建C# Windows Service项目,为基本Windows服务编写C#源代码。第36章将详细介绍如何编写C# Windows服务。

1.8  C#.NET企业体系结构中的作用

C#需要.NET运行库,在几年内大多数客户机­—— 特别是大多数家用PC—— 就可以安装.NET了。而且,安装C#应用程序在方式上类似于安装.NET可重新分布的组件。因此,企业环境中会有许多C#应用程序。实际上,C#为希望建立健全的n层客户机/服务器应用程序的公司提供了一个绝佳的机会。

C#ADO.NET合并后,就可以快速而经常地访问数据库了,例如SQL Server和Oracle数据库。返回的数据集很容易通过ADO.NET对象模型来处理,并自动显示为XML,一般通过办公室内联网来传输。

一旦为新项目建立了数据库模式,C#就会为执行一层数据访问对象提供一个极好的媒介,每个对象都能提供对不同的数据库表的插入、更新和删除访问。

因为这是第一个基于组件的C语言,所以C#非常适合于执行业务对象层。它为组件之间的通信封装了杂乱的信息,让开发人员把注意力集中如何在把数据访问对象组合在一起,在方法中精确地强制执行公司的业务规则。而且使用特性,C#业务对象可以配备方法级的安全检查、对象池和由COM+服务提供的JIT活动。另外,.NET附带的实用程序允许新的.NET业务对象与原来的COM组件交互。

要使用C#创建企业应用程序,可以为数据访问对象创建一个Class Library项目,为业务对象创建另一个Class Library项目。在开发时,可以使用Console项目测试类上的方法。喜欢编程的人可以建立能自动从批处理文件中执行的Console项目,测试工作代码是否中断。

注意,C# 和 .NET都会影响物理封装可重用类的方式。过去,许多开发人员把许多类放在一个物理组件中,因为这样安排会使部署容易得多;如果有版本冲突问题,就知道在何处进行检查。因为部署.NET企业组件仅是把文件复制到目录中,所以现在开发人员就可以把他们的类封装到逻辑性更高的离散组件中,而不会遇到DLL Hell。

最后,用C#编写的ASP.NET页面构成了用户界面的绝妙媒介。ASP.NET页面是编译过的,所以执行得比较快。它们可以在VS 2005 IDE中调试,所以更加健壮。它们支持所有的语言特性,例如早期绑定、继承和模块化,所以用C#编写的ASP.NET页面是很整洁的,很容易维护。

经验丰富的开发人员对大做广告的新技术和语言都持非常怀疑的态度,不愿意利用新平台,这仅仅是因为他们不愿意。如果读者是一位IT部门的企业开发人员,或者通过World Wide Web提供应用程序服务,即使一些比较奇异的特性如XML Web服务和服务器端控件不算在内,也可以确保C#和.NET至少提供了四个优点:

       组件冲突将很少见,部署工作将更容易,因为同一组件的不同版本可以在同一台机器上并行运行,而不会发生冲突。

       ASP代码不再很难读懂。

       可以在.NET基类中使用许多功能。

       对于需要Windows窗体用户界面的应用程序来说,利用C#可以很容易编写这类应用   程序。

在某种程度上,以前Windows窗体并未受到重视,因为没有Web窗体和基于Internet的应用程序。但如果用户缺乏JavaScript、ASP或相关技术的专业知识,Windows窗体仍是方便而快速地创建用户界面的一种可行选择。记住管理好代码,使用户界面的逻辑与业务逻辑和数据访问代码分隔开来,这样才能在将来的某一刻把应用程序迁移到浏览器上。另外,Windows窗体还为家用应用程序和一些小公司长期保留了重要的用户界面。Windows窗体的新智能客户特性(很容易以在线和离线方式工作)将能开发出新的、更好的应用程序。

1.9  小结

本章介绍了许多基础知识,简要回顾了.NET Framework的重要方面以及它与C#的关系。首先讨论了所有面向.NET的语言如何编译为中间语言,之后由公共语言运行库进行编译和执行。我们还讨论了.NET的下述特性在编译和执行过程中的作用:

       程序集和.NET基类

       COM组件

       JIT编译

       应用程序域

       垃圾收集

图1-4简要说明了这些特性在编译和执行过程中是如何发挥作用的。

  1-4

本章还讨论了IL的特性,特别是其强数据类型和面向对象的特性。探讨了这些特性如何影响面向.NET的语言,包括C#,并阐述了IL的强类型本质如何支持语言的互操作性,以及CLR服务,例如垃圾收集和安全性。

本章的最后讨论了C#如何用作基于几个.NET技术(包括ASP.NET)的应用程序的基础。

第2章将介绍如何用C#语言编写代码。

2 C#基础

理解了C#的用途后,就可以学习如何使用它。本章将介绍C#编程的基础知识,并假定您具备C#编程的基本知识,这是后续章节的基础。本章的主要内容如下:

       声明变量

       变量的初始化和作用域

       C#的预定义数据类型

       C#程序中使用循环和条件语句指定执行流

       枚举

       命名空间

       Main()方法

       基本的命令行C#编译器选项

       使用System.Console执行控制台I/O

       C#Visual Studio .NET中使用文档编制功能

       C#标识符和关键字

       C#编程的推荐规则和约定

阅读完本章后,您就有足够的C#知识编写简单的程序了,但还不能使用继承或其他面向对象的特征。这些内容将在本书后面的几章中讨论。

2.1  引言

如前所述,C#是一种面向对象的语言。在快速浏览C#语句的基础时,我们假定您已经很好地掌握了面向对象(OO)编程的概念。换言之,我们希望您懂得类、对象、接口和继承的含义。如果以前使用过C++或Java,就应有很好的面向对象编程(OOP)的基础。但是,如果您不具备OOP的背景知识,这个主题有许多很好的信息资源。本书的附录A就详细介绍了OOP。附录A可以从www.wrox.com上下载。

如果您对VB6、C++或 Java中的一种语言有丰富的编程经验,就应注意在介绍C#基础知识时,我们对C#、C++、Java和VB6进行了许多比较。但是,您也许愿意阅读一本有关C#和自己所选语言的比较的图书,来学习C#。如果是这样,可以从Wrox Press网站(www.wrox.com)上下载不同的文档来学习C#。

2.2  第一个C#程序

下面采用传统的方式,看看一个最简单的C#程序——这是一个把信息写到屏幕上的控制台应用程序。

2.2.1  代码

在文本编辑器(例如Notepad)中键入下面的代码,把它保存为.cs文件(例如First.cs):

using System;

namespace Wrox.ProCSharp.Basics

{

   class MyFirstCSharpClass

   {

      static void Main()

      {

         Console.WriteLine("This isn't at all like Java!");

         Console.ReadLine();

         return;

      }

   }

}

注意:

在后面的几章中,介绍了许多代码示例。编写C#程序最常用的技巧是使用Visual Studio 2005生成一个基本项目,再把自己的代码添加进去。但是,前面几章的目的是讲授C#语言,并使过程尽可能简单,在第14章之前避免涉及Visual Studio 2005。我们使代码显示为简单的文件,这样您就可以使用任何文本编辑器键入它们,并在命令行上对其进行编译。

2.2.2  编译并运行程序

对源文件运行C#命令行编译器(csc.exe),编译这个程序:

csc First.cs

如果使用csc命令在命令行上编译代码,就应注意.NET命令行工具,包括csc,只有在设置了某些环境变量后才能使用。根据安装.NET(和Visual Studio 2005)的方式,这里显示的结果可能与您机器上的结果不同。

注意:

如果没有设置环境变量,有两种解决方法。第一种方法是在运行csc之前,在命令行上运行批处理文件%Microsoft Visual Studio 2005%"Common7"Tools"vcvars32.bat。其中%Microsoft Visual Studio 2005是安装Visual Studio 2005的文件夹。第二种方法(更简单)是使用Visual Studio 2005命令行代替通常的命令提示窗口。Visual Studio 2005命令提示在“开始”菜单—“程序”—Microsoft Visual Studio 2005Microsoft Visual Studio Tools子菜单下。它只是一个命令提示窗口,打开时会自动运行vcvars32.bat

编译代码,会生成一个可执行文件First.exe。在命令行或Windows Explorer上,象运行任何可执行文件那样运行该文件,得到如下结果:

csc First.cs

Microsoft (R) Visual C# .NET Compiler version 8.00.40607.16

for Microsoft (R) .NET Framework version 2.0.40607

Copyright (C) Microsoft Corporation 2001-2003. All rights reserved.

First.exe

This isn't at all like Java!

这些信息也许不那么真实!这与Java有一些非常相似的地方,但有一两个地方与Java或C++不同(如大写的Main函数)。下面通过这个程序快速介绍C#程序的基本结构。

2.2.3  详细介绍

首先对C#语法作几个解释。在C#中,与其他C风格的语言一样,每个语句都必须用一个分号(;)结尾,语句可以写在多个代码行上,不需要使用续行字符(例如VB中的下划线)。用花括号({ ... })把语句组合为块。单行注释以两个斜杠字符开头(//),多行注释以一个斜杠和一个星号(/*)开头,以一个星号和一个斜杠(*/)结尾。在这些方面,C#与C++和Java一样,但与VB不同。分号和花括号使C#代码与VB代码有完全不同的外观。如果您以前使用的是VB,就应特别注意每个语句结尾的分号。对于使用C风格语言的新用户,忽略分号常常是导致编译错误的一个最主要的原因。

在上面的代码示例中,前几行代码是处理命名空间(如本章后面所述),这是把相关类组合在一起的方式。Java和C++开发人员应很熟悉这个概念,但对于VB6开发人员来说是新概念。C#命名空间与C++命名空间或Java的包基本相同,但VB6中没有对应的概念。namespace关键字声明了应与类相关的命名空间。其后花括号中的所有代码都被认为是在这个命名空间中。编译器在using指令指定的命名空间中查找没有在当前命名空间中定义、但在代码中引用的类。这非常类似于Java中的import语句和C++中的using namespace语句。

using System;

namespace Wrox.ProCSharp.Basics

{

在First.cs文件中使用using指令的原因是下面要使用一个库类System.Console。using System指令允许把这个类简写为Console(类似于System命名空间中的其他类)。标准的System命名空间包含了最常用的.NET类型。我们用C#做的所有工作都依赖于.NET基类,认识到这一点是非常重要的;在本例中,我们使用了System命名空间中的Console类,以写入控制台窗口。

注意:

几乎所有的C#程序都使用System命名空间中的类,所以假定本章所有的代码文件都包含using System;语句。

C#没有用于输入和输出的内置关键字,而是完全依赖于.NET类。

接着,声明一个类,它表面上称为MyFirstClass。但是,因为该类位于Wrox.ProCSharp.Basics命名空间中,所以其完整的名称是Wrox.ProCSharp.Basics.MyFirstCSharpClass:

   class MyFirstCSharpClass

   {

与Java一样,所有的C#代码都必须包含在一个类中,C#中的类类似于Java和C++中的类,大致相当于VB6子句的类模块。类的声明包括class关键字,其后是类名和一对花括号。与类相关的所有代码都应放在这对花括号中。

下面声明方法Main()。每个C#可执行文件(例如控制台应用程序、Windows应用程序和Windows服务)都必须有一个入口点—— Main方法(注意M大写):

      static void Main()

      {

这个方法在程序启动时调用,类似于C++和Java中的main函数,或VB6模块中的Sub Main。该方法要么不能有返回值void,要么返回一个整数(int)。C#方法对应于C++ 和 Java中的方法(有时把C++中的方法称为成员函数),它还对应于VB的Function 或VB的Sub。这取决于方法是否有返回值(与VB不同,C#在函数和子例程之间没有概念上的区别)。

注意,C#中的方法定义如下所示。

[modifiers] return_type MethodName([parameters])

{

    // Method body. NB. This code block is pseudo-code

}

第一个方括号中的内容表示可选关键字。修饰符(modifiers)用于指定用户所定义的方法的某些特性,例如可以在什么地方调用该方法。在本例中,有两个修饰符public和static。修饰符public表示可以在任何地方访问该方法,所以可以在类的外部调用。这与C++和Java中的public相同,与VB中的Public相同。修饰符static表示方法不能在类的特定实例上执行,因此不必先实例化类再调用。这是非常重要的,因为我们创建的是一个可执行文件,而不是类库。这与C++和Java中的static关键字相同,但VB中没有对应的关键字(在VB中,Static关键字有不同的含义)。把返回类型设置为void,在本例中,不包含任何参数。

最后,看看代码语句。

         Console.WriteLine("This isn't at all like Java!");

         Console.ReadLine();

         return;

在本例中,我们只调用了System.Console类的WriteLine()方法,把一行文本写到控制台窗口上。WriteLine()是一个静态方法,在调用之前不需要实例化Console对象。

Console.ReadLine()读取用户的输入,添加这行代码会让应用程序等待用户按下回车键,之后退出应用程序。在Visual Studio 2005中,控制台窗口会消失。

然后调用return退出该方法(因为这是Main方法)。在方法的首部指定void,因此没有返回值。Return语句等价于C++和Java中的return,也等价于VB中的Exit Sub或Exit Function。

对C#基本语法有了大致的认识后,下面就要详细讨论C#的各个方面了。因为没有变量是不可能编写出任何重要的程序的,所以首先介绍C#中的变量。

2.3  变量

在C#中声明变量使用下述语法:

datatype identifier;

例如:

int i; 

该语句声明int变量i。编译器不会让我们使用这个变量,除非我们用一个值初始化了该变量。但这个声明会在堆栈中给它分配4个字节,以保存其值。

声明i之后,就可以使用赋值运算符(=)给它分配一个值:

i = 10;

还可以在一行代码中声明变量,并初始化它的值:

int i = 10; 

其语法与C++和Java语法相同,但与VB中声明变量的语法完全不同。如果用户以前使用的是VB6,应记住C#不区分对象和简单的类型,所以不需要类似Set的关键字,即使是要把变量指向一个对象,也不需要Set关键字。无论变量的数据类型是什么,声明变量的C#语法都是相同的。

如果在一个语句中声明和初始化了多个变量,那么所有的变量都具有相同的数据类型:

int x = 10, y =20;   // x and y are both ints

要声明类型不同的变量,需要使用单独的语句。在多个变量的声明中,不能指定不同的数据类型:

int x = 10;

bool y = true;            // Creates a variable that stores true or false

int x = 10, bool y = true;   // This won't compile!

注意上面例子中的//和其后的文本,它们是注释。//字符串告诉编译器,忽略其后的文本。本章后面会详细讨论代码中的注释。

2.3.1  变量的初始化

变量的初始化是C#强调安全性的另一个例子。简单地说,C#编译器需要用某个初始值对变量进行初始化,之后才能在操作中引用该变量。大多数现代编译器把没有初始化标记为警告,但C#编译器把它当作错误来看待。这就可以防止我们无意中从其他程序遗留下来的内存中获取垃圾值。

C#有两个方法可确保变量在使用前进行了初始化:

       变量是类或结构中的字段,如果没有显式初始化,在默认状态下创建这些变量时,其值就是0

       方法的局部变量必须在代码中显式初始化,之后才能在语句中使用它们的值。此时,初始化不是在声明该变量时进行的,但编译器会通过方法检查所有可能的路径,如果检测到局部变量在初始化之前就使用了它的值,就会产生错误。

C#的方法与C++的方法相反,在C++中,编译器让程序员确保变量在使用之前进行了初始化,在VB中,所有的变量都会自动把其值设置为0。

例如,在C#中不能使用下面的语句:

public static int Main()

{

   int d;

   Console.WriteLine(d);   // Can't do this! Need to initialize d before use

   return 0;

}

注意在这段代码中,演示了如何定义Main(),使之返回一个int类型的数据,而不是void。

在编译这些代码时,会得到下面的错误消息:

Use of unassigned local variable 'd'

同样的规则也适用于引用类型。考虑下面的语句:

Something objSomething;

在C++中,上面的代码会在堆栈中创建Something类的一个实例。在C#中,这行代码仅会为Something对象创建一个引用,但这个引用还没有指向任何对象。对该变量调用方法或属性会导致错误。

在C#中实例化一个引用对象需要使用new关键字。如上所述,创建一个引用,使用new关键字把该引用指向存储在堆上的一个对象:

objSomething = new Something();   // This creates a Something on the heap

2.3.2  变量的作用域

变量的作用域是可以访问该变量的代码区域。一般情况下,确定作用域有以下规则:

       只要字段所属的类在某个作用域内,其字段(也称为成员变量)也在该作用域内(C++Java VB中也是这样)

       局部变量存在于表示声明该变量的块语句或方法结束的封闭花括号之前的作用域内。

       forwhile或类似语句中声明的局部变量存在于该循环体内(C++程序员注意,这与C++ANSI标准相同。Microsoft C++编译器的早期版本不遵守该标准,但在循环停止后这种变量仍存在)

1. 局部变量的作用域冲突

大型程序在不同部分为不同的变量使用相同的变量名是很常见的。只要变量的作用域是程序的不同部分,就不会有问题,也不会产生模糊性。但要注意,同名的局部变量不能在同一作用域内声明两次,所以不能使用下面的代码:

int x = 20;

// some more code

int x = 30;

考虑下面的代码示例:

using System;

namespace Wrox.ProCSharp.Basics

{

   public class ScopeTest

   {

      public static int Main()

      {

         for (int i = 0; i < 10; i++)

         {

            Console.WriteLine(i);

         }   // i goes out of scope here

         // We can declare a variable named i again, because

         // there's no other variable with that name in scope

         for (int i = 9; i >= 0; i--)

         {

            Console.WriteLine(i);

         }   // i goes out of scope here

         return 0;

      }

   }

}

这段代码使用一个for循环打印出从0~9的数字,再打印从9~0的数字。重要的是在同一个方法中,代码中的变量i声明了两次。可以这么做的原因是在两次声明中,i都是在循环内部声明的,所以变量i对于循环来说是局部变量。

下面看看另一个例子:

      public static int Main()

      {

         int j = 20;

         for (int i = 0; i < 10; i++)

         {

            int j = 30;   // Can't do this - j is still in scope

            Console.WriteLine(j + i);

         }

         return 0;

      }

如果试图编译它,就会产生如下错误:

ScopeTest.cs(12,14): error CS0136: A local variable named 'j' cannot be declared in this scope because it would give a different meaning to 'j', which is already used in a 'parent or current' scope to denote something else

其原因是:变量j是在for循环开始前定义的,在执行for循环时应处于其作用域内,在Main方法结束执行后,变量j才超出作用域,第二个j(不合法)则在循环的作用域内,该作用域嵌套在Main方法的作用域内。编译器无法区别这两个变量,所以不允许声明第二个变量。这也是与C++不同的地方,在C++中,允许隐藏变量。

2. 字段和局部变量的作用域冲突

在某些情况下,可以区分名称相同(尽管其完全限定的名称不同)、作用域相同的两个标识符。此时编译器允许声明第二个变量。原因是C#在变量之间有一个基本的区分,它把声明为类型级的变量看作是字段,而把在方法中声明的变量看作局部变量。

考虑下面的代码:

using System;

namespace Wrox.ProCSharp.Basics

{

   class ScopeTest2

   {

      static int j = 20;

      public static void Main()

      {

         int j = 30;

         Console.WriteLine(j);

         return;

      }

   }

}

即使在Main方法的作用域内声明了两个变量j,这段代码也会编译—— j被定义在类级上,在该类删除前是不会超出作用域的(在本例中,当Main方法中断,程序结束时,才会删除该类)。此时,在Main方法中声明的新变量j隐藏了同名的类级变量,所以在运行这段代码时,会显示数字30。

但是,如果要引用类级变量,该怎么办?可以使用语法object.fieldname,在对象的外部引用类的字段或结构。在上面的例子中,我们访问静态方法中的一个静态字段(静态字段详见下一节),所以不能使用类的实例,只能使用类本身的名称:

      public static void Main()

      {

         int j = 30;

         Console.WriteLine(ScopeTest2.j);

      }

      ...

如果要访问一个实例字段(该字段属于类的一个特定实例),就需要使用this关键字。this的作用与C++和Java中的this相同,与VB中的Me相同。

2.3.3  常量

在声明和初始化变量时,在变量的前面加上关键字const,就可以把该变量指定为一个常量。顾名思义,常量是其值在使用过程中不会发生变化的变量:

const int a = 100;   // This value cannot be changed

VB和C++开发人员会非常熟悉常量。但C++开发人员应注意,C#不支持C++常量的所有细微的特性。在C++中,变量不仅可以声明为常量,而且根据声明,还可以有常量指针,指向常量的变量指针、常量方法(不改变包含对象的内容),方法的常量参数等。这些细微的特性在C#中都删除了,只能把局部变量和字段声明为常量。

常量具有如下特征:

       常量必须在声明时初始化。指定了其值后,就不能再修改了。

       常量的值必须能在编译时用于计算。因此,不能用从一个变量中提取的值来初始化常量。如果需要这么做,应使用只读字段(详见第3)

       常量总是静态的。但注意,不必(实际上,是不允许)在常量声明中包含修饰符static

在程序中使用常量至少有3个好处:

       常量用易于理解的清楚的名称替代了含义不明确的数字或字符串,使程序更易于阅读。

       常量使程序更易于修改。例如,在C#程序中有一个SalesTax常量,该常量的值为6%。如果以后销售税率发生变化,把新值赋给这个常量,就可以修改所有的税款计算结果,而不必查找整个程序,修改税率为0.06的每个项。

       常量更容易避免程序出现错误。如果要把另一个值赋给程序中的一个常量,而该常量已经有了一个值,编译器就会报告错误。

2.4  预定义数据类型

前面介绍了如何声明变量和常量,下面要详细讨论C#中可用的数据类型。与其他语言相比,C#对其可用的类型及其定义进行了过分的修饰。

2.4.1  值类型和引用类型

在开始介绍C#中的数据类型之前,理解C#把数据类型分为两种是非常重要的:

       值类型

       引用类型

下面几节将详细介绍值类型和引用类型的语法。从概念上看,其区别是值类型直接存储其值,而引用类型存储对值的引用。与其他语言相比,C#中的值类型基本上等价于VB或C++中的简单类型(整型、浮点型,但没有指针或引用)。引用类型与VB中的引用类型相同,与C++中通过指针访问的类型类似。

这两种类型存储在内存的不同地方:值类型存储在堆栈中,而引用类型存储在托管堆上。注意区分某个类型是值类型还是引用类型,因为这种存储位置的不同会有不同的影响。例如,int是值类型,这表示下面的语句会在内存的两个地方存储值20:

// i and j are both of type int

i = 20;

j = i;

但考虑下面的代码。这段代码假定已经定义了一个类Vector,Vector是一个引用类型,它有一个int类型的成员变量Value:

Vector x, y

x = new Vector ();

x.Value = 30;   // Value is a field defined in Vector class

y = x;

Console.WriteLine(y.Value);

y.Value = 50;

Console.WriteLine(x.Value);

要理解的重要一点是在执行这段代码后,只有一个Vector对象。x和y都指向包含该对象的内存位置。因为x和y是引用类型的变量,声明这两个变量只是保留了一个引用——而不会实例化给定类型的对象。这与在C++中声明指针和VB中的对象引用是相同的——在C++和VB中,都不会创建对象。要创建对象,就必须使用new关键字,如上所示。因为x和y引用同一个对象,所以对x的修改会影响y,反之亦然。因此上面的代码会显示30和50。

注意:

C++开发人员应注意,这个语法类似于引用,而不是指针。我们使用.(句点)符号,而不是->来访问对象成员。在语法上,C#引用看起来更类似于C++引用变量。但是,抛开表面的语法,实际上它类似于C++指针。

如果变量是一个引用,就可以把其值设置为null,表示它不引用任何对象:

y = null;

这类似于Java中把引用设置为null,C++中把指针设置为NULL,或VB中把对象引用设置为Nothing。如果将引用设置为null,显然就不可能对它调用任何非静态的成员函数或字段,这么做会在运行时抛出一个异常。

在像C++这样的语言中,开发人员可以选择是直接访问某个给定的值,还是通过指针来访问。VB的限制更多:COM对象是引用类型,简单类型总是值类型。C#在这方面类似于VB:变量是值还是引用仅取决于其数据类型,所以,int总是值类型。不能把int变量声明为引用(在第5章介绍装箱时,可以在类型为object的引用中封装值类型)。

在C#中,基本数据类型如bool和long都是值类型。如果声明一个bool变量,并给它赋予另一个bool变量的值,在内存中就会有两个bool值。如果以后修改第一个bool变量的值,第二个bool变量的值也不会改变。这些类型是通过值来复制的。

相反,大多数更复杂的C#数据类型,包括我们自己声明的类都是引用类型。它们分配在堆中,其生存期可以跨多个函数调用,可以通过一个或几个别名来访问。CLR执行一种精细的算法,来跟踪哪些引用变量仍是可以访问的,哪些引用变量已经不能访问了。CLR会定期进行清理,删除不能访问的对象,把它们占用的内存返回给操作系统。这是通过垃圾收集器实现的。

把基本类型(如int和bool)规定为值类型,而把包含许多字段的较大类型(通常在有类的情况下)规定为引用类型,C#设计这种方式的原因是可以得到最佳性能。如果要把自己的类型定义为值类型,就应把它声明为一个结构。

2.4.2  CTS类型

如第1章所述,C#认可的基本预定义类型并没有内置于语言中,而是内置于.NET Framework中。例如,在C#中声明一个int类型的数据时,声明的实际上是.NET结构System.Int32的一个实例。这听起来似乎很深奥,但其意义深远:这表示在语法上,可以把所有的基本数据类型看作是支持某些方法的类。例如,要把int i转换为string,可以编写下面的代码:

string s = i.ToString();

应强调的是,在这种便利语法的背后,类型实际上仍存储为基本类型。基本类型在概念上用.NET结构表示,所以肯定没有性能损失。

下面看看C#中定义的类型。我们将列出每个类型,以及它们的定义和对应.NET类型(CTS 类型)的名称。C#有15个预定义类型,其中13个是值类型,2个是引用类型(string和object)。

2.4.3  预定义的值类型

内置的值类型表示基本数据类型,例如整型和浮点类型、字符类型和bool类型。

1. 整型

C#支持8个预定义整数类型,如表2-1所示。

  2-1

   

CTS

   

   

sbyte

System.SByte

8位有符号的整数

–128 127 (–2727–1)

short

System.Int16

16位有符号的整数

–32 768 32 767 (–215215–1)

int

System.Int32

32位有符号的整数

–2 147 483 648 2 147 483 647(–231231–1)

long

System.Int64

64位有符号的整数

–9 223 372 036 854 775 8089 223 372 036 854 775 807(–263263–1)

byte

System.Byte

8位无符号的整数

0255(028–1)

ushort

System.Uint16

16位无符号的整数

065535(0216–1)

uint

System.Uint32

32位无符号的整数

04 294 967 295(0232–1)

ulong

System.Uint64

64位无符号的整数

018 446 744 073 709 551 615(0264–1)

Windows的将来版本将支持64位处理器,可以把更大的数据块移入移出内存,获得更快的处理速度。因此,C#支持8至64位的有符号和无符号的整数。

当然,VB开发人员会发现有许多类型名称是新的。C++和Java开发人员应注意:一些C#类型名称与C++和Java类型一致,但类型有不同的定义。例如,在C#中,int总是32位带符号的整数,而在C++中,int是带符号的整数,但其位数取决于平台(在Windows上是32位)。在C#中,所有的数据类型都以与平台无关的方式定义,以备将来C#和.NET迁移到其他平台上。

byte是0~255(包括255)的标准8位类型。注意,在强调类型的安全性时,C#认为byte类型和char类型完全不同,它们之间的编程转换必须显式写出。还要注意,与整数中的其他类型不同,byte类型在默认状态下是无符号的,其有符号的版本有一个特殊的名称sbyte。

在.NET中,short不再很短,现在它有16位,Int类型更长,有32位。 long类型最长,有64位。所有整数类型的变量都能赋予10进制或16进制的值,后者需要0x前缀:

long x = 0x12ab;

如果对一个整数是int、uint、long或是ulong没有任何显式的声明,则该变量默认为int类型。为了把键入的值指定为其他整数类型,可以在数字后面加上如下字符:

uint ui = 1234U;

long l = 1234L;

ulong ul = 1234UL;

也可以使用小写字母u和l,但后者会与整数1混淆。

2. 浮点类型

C#提供了许多整型数据类型,也支持浮点类型,如表2-2所示。C和C++程序员很熟悉     它们。

  2-2

名称

CTS类型

 

范围 (大致)

float

System.Single

32位单精度浮点数

7

±1.5 × 10-45 ±3.4 × 1038

double

System.Double

64位双精度浮点数

15/16

±5.0 × 10-324 ±1.7 × 10308

float数据类型用于较小的浮点数,因为它要求的精度较低。double数据类型比float数据类型大,提供的精度也大一倍(15位)。

如果在代码中没有对某个非整数值(如12.3)硬编码,则编译器一般假定该变量是double。如果想指定值为float,可以在其后加上字符F(或f):

float f = 12.3F;

3. decimal类型

另外,decimal类型表示精度更高的浮点数,如表2-3所示。

  2-3

   

CTS类型

   

   

范围(大致)

decimal

System.
Decimal

128位高精度十进制数表示法

28

±1.0×10-28±7.9 × 1028

CTS和C#一个重要的优点是提供了一种专用类型表示财务计算,这就是decimal类型,使用decimal类型提供的28位的方式取决于用户。换言之,可以用较大的精确度(带有美分)来表示较小的美元值,也可以在小数部分用更多的舍入来表示较大的美元值。但应注意,decimal类型不是基本类型,所以在计算时使用该类型会有性能损失。

要把数字指定为decimal类型,而不是double、 float或整型,可以在数字的后面加上字符M(或m),如下所示。

decimal d = 12.30M;

4. bool类型

C#的 bool 类型用于包含bool值true或false,如表2-4所示。

  2-4

     

CTS   

bool

System.Boolean

truefalse

bool值和整数值不能相互转换。如果变量(或函数的返回类型)声明为bool类型,就只能使用值true或false。如果试图使用0表示false,非0值表示true,就会出错。

5. 字符类型

为了保存单个字符的值,C#支持char数据类型,如表2-5所示。

  2-5

    

CTS   

char

System.Char

表示一个16位的(Unicode)字符

虽然这个数据类型在表面上类似于C和C++中的char类型,但它们有重大区别。C++的char表示一个8位字符,而C#的char包含16位。其部分原因是不允许在char类型与8位byte类型之间进行隐式转换。

尽管8位足够编码英语中的每个字符和数字0~9了,但它们不够编码更大的符号系统中的每个字符(例如中文)。为了面向全世界,计算机行业正在从8位字符集转向16位的Unicode模式,ASCII编码是Unicode的一个子集。

char类型的字面量是用单引号括起来的,例如'A'。如果把字符放在双引号中,编译器会把它看作是字符串,从而产生错误。

除了把char表示为字符字面量之外,还可以用4位16进制的Unicode值(例如'"u0041'),带有数据类型转换的整数值(例如(char)65),或16进制数('"x0041')表示它们。它们还可以用转义序列表示,如表2-6所示。

  2-6

   

"'

单引号

""

双引号

""

反斜杠

"0

"a

警告

"b

退格

"f

换页

"n

换行

"r

回车

"t

水平制表符

"v

垂直制表符

C++开发人员应注意,因为C#本身有一个string类型,所以不需要把字符串表示为char类型的数组。

2.4.4  预定义的引用类型

C#支持两个预定义的引用类型,如表2-7所示。

  2-7

   

CTS

   

object

System.Object

根类型,CTS中的其他类型都是从它派生而来的(包括值类型)

string

System.String

Unicode字符串

1. object类型

许多编程语言和类结构都提供了根类型,层次结构中的其他对象都从它派生而来。C#和.NET也不例外。在C#中,object类型就是最终的父类型,所有内在和用户定义的类型都从它派生而来。这是C#的一个重要特性,它把C#与VB和C++区分开来,但其行为与Java非常类似。所有的类型都隐含地最终派生于System.Object类,这样,object类型就可以用于两个目的:

       可以使用object引用绑定任何子类型的对象。例如,第5章将说明如何使用object类型把堆栈中的一个值对象装箱,再移动到堆中。对象引用也可以用于反射,此时必须有代码来处理类型未知的对象。这类似于C++中的void指针或VB中的Variant数据类型。

       object类型执行许多基本的一般用途的方法,包括Equals()GetHashCode()GetType()ToString()。用户定义的类需要使用一种面向对象技术——重写(见第4),提供其中一些方法的替代执行代码。例如,重写ToString()时,要给类提供一个方法,提供类本身的字符串表示。如果类中没有提供这些方法的实现代码,编译器就会使用object类型中的实现代码,它们在类中的执行不一定正确。

后面的章节将详细讨论object类型。

2. string类型

有C和C++开发经验的人员可能在使用C风格的字符串时不太顺利。C或C++字符串不过是一个字符数组,因此客户机程序员必须做许多工作,才能把一个字符串复制到另一个字符串上,或者连接两个字符串。实际上,对于一般的C++程序员来说,执行包装了这些操作细节的字符串类是一个非常头痛的耗时过程。VB程序员的工作就比较简单,只需使用string类型即可。而Java程序员就更幸运了,其String类在许多方面都类似于C#字符串。

C#有string关键字,在翻译为.NET类时,它就是System.String。有了它,像字符串连接和字符串复制这样的操作就很简单了:

string str1 = "Hello ";

string str2 = "World";

string str3 = str1 + str2; // string concatenation

尽管这是一个值类型的赋值,但string是一个引用类型。String对象保留在堆上,而不是堆栈上。因此,当把一个字符串变量赋给另一个字符串时,会得到对内存中同一个字符串的两个引用。但是,string与引用类型在常见的操作上有一些区别。例如,修改其中一个字符串,就会创建一个全新的string对象,而另一个字符串没有改变。考虑下面的代码:

using System;

class StringExample

{

   public static int Main()

   {

      string s1 = "a string";

      string s2 = s1;

      Console.WriteLine("s1 is " + s1);

      Console.WriteLine("s2 is " + s2);

      s1 = "another string";

      Console.WriteLine("s1 is now " + s1);

      Console.WriteLine("s2 is now " + s2);

      return 0;

   }

}

其输出结果为:

s1 is a string

s2 is a string

s1 is now another string

s2 is now a string

换言之,改变s1的值对s2没有影响,这与我们期待的引用类型正好相反。当用值"a string"初始化s1时,就在堆上分配了一个string对象。在初始化s2时,引用也指向这个对象,所以s2的值也是"a string"。但是现在要改变s1的值,而不是替换原来的值时,堆上就会为新值分配一个新对象。s2变量仍指向原来的对象,所以它的值没有改变。这实际上是运算符重载的结果,运算符重载详见第5章。基本上,string类实现为其语义遵循一般的、直观的字符串规则。

字符串字面量放在双引号中("...");如果试图把字符串放在单引号中,编译器就会把它当作char,从而引发错误。C#字符串和char一样,可以包含Unicode、16进制数转义序列。因为这些转义序列以一个反斜杠开头,所以不能在字符串中使用这个非转义的反斜杠字符。而需要用两个反斜杠字符("")来表示它:

string filepath = "C:""ProCSharp""First.cs";

即使用户相信自己可以在任何情况下都记住要这么做,但键入两个反斜杠字符会令人迷惑。幸好,C#提供了另一种替代方式。可以在字符串字面量的前面加上字符@,在这个字符后的所有字符都看作是其原来的含义——它们不会解释为转义字符:

string filepath = @"C:"ProCSharp"First.cs";

甚至允许在字符串字面量中包含换行符:

string jabberwocky = @"'Twas brillig and the slithy toves

Did gyre and gimble in the wabe.";

那么jabberwocky的值就是:

'Twas brillig and the slithy toves

Did gyre and gimble in the wabe.

2.5  流控制

本节将介绍C#语言的重要语句:控制程序流的语句,它们不是按代码在程序中的排列位置顺序执行的。

2.5.1  条件语句

条件语句可以根据条件是否满足或根据表达式的值控制代码的执行分支。C#有两个分支代码的结构:if语句,测试特定条件是否满足;switch语句,它比较表达式和许多不同的值。

1. if语句

对于条件分支,C#继承了C和C++的if...else结构。对于用过程语言编程的人来说,其语法是非常直观的:

if (condition)

   statement(s)

else

   statement(s)

如果在条件中要执行多个语句,就需要用花括号({ ... })把这些语句组合为一个块。(这也适用于其他可以把语句组合为一个块的C#结构,例如for和while循环)。

bool isZero;

if (i == 0)

{

   isZero = true;

   Console.WriteLine("i is Zero");

}

else

{

   isZero = false;

   Console.WriteLine("i is Non-zero");

}

其语法与C++和Java类似,但与VB不同。VB开发人员注意,C#中没有与VB的EndIf对应的语句,其规则是if的每个子句都只包含一个语句。如果需要多个语句,如上面的例子所示,就应把这些语句放在花括号中,这会把整组语句当作一个语句块来处理。

还可以单独使用if语句,不加else语句。也可以合并else if子句,测试多个条件。

using System;

namespace Wrox.ProCSharp.Basics

{

   class MainEntryPoint

   {

      static void Main(string[] args)

      {

         Console.WriteLine("Type in a string");

         string input;

         input = Console.ReadLine();

         if (input == "")

         {

            Console.WriteLine("You typed in an empty string");

         }

         else if (input.Length < 5)

         {

            Console.WriteLine("The string had less than 5 characters");

         }

         else if (input.Length < 10)

         {

            Console.WriteLine("The string had at least 5 but less than 10

               characters");

         }

         Console.WriteLine("The string was " + input);

      }

   }

}

添加到if子句中的else if语句的个数没有限制。

注意在上面的例子中,我们声明了一个字符串变量input,让用户在命令行上输入文本,把文本填充到input中,然后测试该字符串变量的长度。代码还说明了在C#中如何进行字符串处理。例如,要确定input的长度,可以使用input.Length。

对于if,要注意的一点是如果条件分支中只有一条语句,就无需使用花括号:

if (i == 0)

   Console.WriteLine("i is Zero");                 // This will only execute if i == 0

Console.WriteLine("i can be anything");       // Will execute whatever the

                                                // value of i

但是,为了保持一致,许多程序员只要使用if语句,就使用花括号。

前面介绍的if语句还演示了比较值的一些C#运算符。特别注意,与C++和Java一样,C#使用“==”对变量进行等于比较。此时不要使用“=”,“=”用于赋值。

在C#中,if子句中的表达式必须等于布尔值。C++程序员应特别注意这一点;与C++不同,C#中的if语句不能直接测试整数(例如从函数中返回的值),而必须明确地把返回的整数转换为布尔值true 或 false,例如,比较值0和null:

if (DoSomething() != 0)

{

   // Non-zero value returned

}

else

{

   // Returned zero

}

这个限制用于防止C++中某些常见的运行错误,特别是在C++中,当应使用“==”时,常常误输入“=”,导致不希望的赋值。在C#中,这常常会导致一个编译错误,因为除非在处理bool值,否则“=”不会返回bool。

2. switch语句

switch…case语句适合于从一组互斥的分支中选择一个执行分支。C++和Java程序员应很熟悉它,该语句类似于VB中的Select Case语句。

其形式是switch参数的后面跟一组case子句。如果switch参数中表达式的值等于某个case子句旁边的某个值,就执行该case子句中的代码。此时不需要使用花括号把语句组合到块中;只需使用break语句标记每个case代码的结尾即可。也可以在switch语句中包含一个default子句,如果表达式不等于任何case子句的值,就执行default子句的代码。下面的switch语句测试integerA变量的值:

switch (integerA)

{

   case 1:

      Console.WriteLine("integerA =1");

      break;

   case 2:

      Console.WriteLine("integerA =2");

      break;

   case 3:

      Console.WriteLine("integerA =3");

      break;

   default:

      Console.WriteLine("integerA is not 1,2, or 3");

      break;

}

注意case的值必须是常量表达式——不允许使用变量。

C和C++程序员应很熟悉switch…case语句,而C#的switch…case语句更安全。特别是它禁止所有case中的失败条件。如果激活了块中靠前的一个case子句,后面的case子句就不会被激活,除非使用goto语句特别标记要激活后面的case子句。编译器会把没有break语句的每个case子句标记为错误:

Control cannot fall through from one case label ('case 2:') to another

在有限的几种情况下,这种失败是允许的,但在大多数情况下,我们不希望出现这种失败,而且这会导致出现很难察觉的逻辑错误。让代码正常工作,而不是出现异常,这样不是更好吗?

但在使用goto语句时(C#支持),会在switch…cases中重复出现失败。如果确实想这么做,就应重新考虑设计方案了。下面的代码说明了如何使用goto模拟失败,得到的代码会非常混乱:

// assume country and language are of type string

switch(country)

{

   case "America":

      CallAmericanOnlyMethod();

      goto case "Britain";

   case "France":

      language = "French";

      break;

   case "Britain":

      language = "English";

      break;

}

但这有一种例外情况。如果一个case子句为空,就可以从这个case跳到下一个case上,这样就可以用相同的方式处理两个或多个case子句了(不需要goto语句)。

switch(country)

{

   case "au":

   case "uk":

   case "us":

      language = "English";

      break;

   case "at":

   case "de":

      language = "German";

      break;

}

在C#中,switch语句的一个有趣的地方是case子句的排放顺序是无关紧要的,甚至可以把default子句放在最前面!因此,任何两个case都不能相同。这包括值相同的不同常量,所以不能这样编写:

// assume country is of type string

const string england = "uk";

const string britain = "uk";

switch(country)

{

   case england:

   case britain:       // this will cause a compilation error

      language = "English";

      break;

}

上面的代码还说明了C#中的switch语句与C++中的switch语句的另一个不同之处:在C#中,可以把字符串用作测试变量。

2.5.2  循环

C#提供了4种不同的循环机制(for、while、do...while和foreach),在满足某个条件之前,可以重复执行代码块。for、while和do...while循环与C++中的对应循环相同。

1. for循环

C#的for循环提供的迭代循环机制是在执行下一次迭代前,测试是否满足某个条件,其语法如下:

for (initializer; condition; iterator)

   statement(s)

其中:

       initializer是指在执行第一次迭代前要计算的表达式(通常把一个局部变量初始化为循环计数器)

       condition是在每次迭代新循环前要测试的表达式(它必须等于true,才能执行下一次迭)

       iterator是每次迭代完要计算的表达式(通常是递增循环计数器)。当condition等于false时,迭代停止。

for循环是所谓的预测试循环,因为循环条件是在执行循环语句前计算的,如果循环条件为假,循环语句就根本不会执行。

for循环非常适合于一个语句或语句块重复执行预定的次数。下面的例子就是for循环的典型用法,这段代码输出从0~99的整数:

for (int i = 0; i < 100; i = i+1)   // this is equivalent to

                           // For i = 0 To 99 in VB.

{

   Console.WriteLine(i);

}

这里声明了一个int类型的变量i,并把它初始化为0,用作循环计数器。接着测试它是否小于100。因为这个条件等于true,所以执行循环中的代码,显示值0。然后给该计数器加1,再次执行该过程。当i等于100时,循环停止。

实际上,上述编写循环的方式并不常用。C#在给变量加1时有一种简化方式,即不使用i = i+1,而简写为i++:

for (int i = 0; i < 100; i++)

{

//etc.

C#的for循环语法比VB中的ForNext循环的功能强大得多,因为迭代器可以是任何语句。在VB中,只能对循环控制变量加减某个数字。在C#中,则可以做任何事,例如,让循环控制变量乘以2。

嵌套的for循环非常常见,在每次迭代外部的循环时,内部循环都要彻底执行完毕。这种模式通常用于在矩形多维数组中遍历每个元素。最外部的循环遍历每一行,内部的循环遍历某行上的每个列。下面的代码显示数字行,它还使用另一个Console方法Console.Write(),该方法的作用与Console.WriteLine()相同,但不在输出中添加回车换行符:

using System;

namespace Wrox.ProCSharp.Basics

{

   class MainEntryPoint

   {

      static void Main(string[ ] args)

      {

         // This loop iterates through rows...

         for (int i = 0; i < 100; i+=10)

         {

            // This loop iterates through columns...

            for (int j = i; j < i + 10; j++)

            {

               Console.Write("  " + j);

            }

            Console.WriteLine();

         }

      }

   }

}

尽管j是一个整数,但它会自动转换为字符串,以便进行连接。C++开发人员要注意,这比在C++中处理字符串容易得多,VB开发人员则已经习惯于此了。

C程序员应注意上述例子中的一个特殊功能。在每次迭代后续的外部循环时,最内部循环的计数器变量都要重新声明。这种语法不仅在C#中可行,在C++中也是合法的。

上述例子的结果是:

csc NumberTable.cs

Microsoft (R) Visual C# .NET Compiler version 8.00.40607.16

for Microsoft (R) .NET Framework version 2.0.40607

Copyright (C) Microsoft Corporation 2001-2003. All rights reserved.

  0  1  2  3  4  5  6  7  8  9

  10  11  12  13  14  15  16  17  18  19

  20  21  22  23  24  25  26  27  28  29

  30  31  32  33  34  35  36  37  38  39

  40  41  42  43  44  45  46  47  48  49

  50  51  52  53  54  55  56  57  58  59

  60  61  62  63  64  65  66  67  68  69

  70  71  72  73  74  75  76  77  78  79

  80  81  82  83  84  85  86  87  88  89

  90  91  92  93  94  95  96  97  98  99

尽管在技术上,可以在for循环的测试条件中计算其他变量,而不计算计数器变量,但这不太常见。也可以在for循环中忽略一个表达式(甚或所有表达式)。但此时,要考虑使用while循环。

2. while循环

while循环与C++和Java中的while循环相同,与VB中的While...Wend循环相同。与for循环一样,while也是一个预测试的循环。其语法是类似的,但while循环只有一个表达式:

while(condition)

   statement(s);

与for循环不同的是,while循环最常用于下述情况:在循环开始前,不知道重复执行一个语句或语句块的次数。通常,在某次迭代中,while循环体中的语句把布尔标记设置为false,结束循环,如下面的例子所示。

bool condition = false;

while (!condition)

{

   // This loop spins until the condition is true

   DoSomeWork();

   condition = CheckCondition();   // assume CheckCondition() returns a bool

}

所有的C#循环机制,包括while循环,如果只重复执行一条语句,而不是一个语句块,都可以省略花括号。许多程序员都认为最好在任何情况下都加上花括号。

3. do…while循环

do...while循环是while循环的后测试版本。它与C++和Java中的do...while循环相同,与VB中的Loop...While循环相同,该循环的测试条件要在执行完循环体之后执行。因此do...while循环适合于至少执行一次循环体的情况:

bool condition;

do

{

   // this loop will at least execute once, even if Condition is false

   MustBeCalledAtLeastOnce();

   condition = CheckCondition();

} while (condition);

4. foreach循环

foreach循环是我们讨论的最后一种C#循环机制。其他循环机制都是C和C++的最早期版本,而foreach语句是新增的循环机制(借用于VB),也是非常受欢迎的一种循环。

foreach循环可以迭代集合中的每一项。现在不必考虑集合的概念,第9章将介绍集合。知道集合是一种包含其他对象的对象即可。从技术上看,要使用集合对象,它必须支持IEnumerable接口。集合的例子有C#数组、System.Collection命名空间中的集合类,以及用户定义的集合类。从下面的代码中可以了解foreach循环的语法,其中假定arrayOfInts是一个整型数组:

foreach (int temp in arrayOfInts)

{

   Console.WriteLine(temp);

}

其中,foreach循环每次迭代数组中的一个元素。它把每个元素的值放在int型的变量temp中,然后执行一次循环迭代。

注意,不能改变集合中各项(上面的temp)的值,所以下面的代码不会编译:

foreach (int temp in arrayOfInts)

{

   temp++;

   Console.WriteLine(temp);

}

如果需要迭代集合中的各项,并改变它们的值,就应使用for循环。

2.5.3  跳转语句

C#提供了许多可以立即跳转到程序中另一行代码的语句,在此,先介绍goto语句。

1. goto语句

goto语句可以直接跳转到程序中用标签指定的另一行(标签是一个标识符,后跟一个冒号):

goto Label1;

   Console.WriteLine("This won't be executed");

Label1:

   Console.WriteLine("Continuing execution from here");

goto语句有两个限制。不能跳转到像for循环这样的代码块中,也不能跳出类的范围,不能退出try...catch块后面的finally块(第12章将介绍如何用try...catch...finally块处理异常)。

goto语句的名声不太好,在大多数情况下不允许使用它。一般情况下,使用它肯定不是面向对象编程的好方式。但是有一个地方使用它是相当方便的——在switch语句的case子句之间跳转,这是因为C#的switch语句在故障处理方面非常严格。前面介绍了其语法。

2. break语句

前面简要提到过break语句——在switch语句中使用它退出某个case语句。实际上,break也可以用于退出for、foreach、while或do...while循环,循环结束后,就执行循环后面的语句。

如果该语句放在嵌套的循环中,就执行最内部循环后面的语句。如果break放在switch语句或循环外部,就会产生编译时错误。

3. continue语句

continue语句类似于break,也必须在for、foreach、while或 do...while循环中使用。但它只从循环的当前迭代中退出,然后在循环的下一次迭代开始重新执行,而不是退出循环。

4. return语句

return语句用于退出类的方法,把控制返回方法的调用者,如果方法有返回类型,return语句必须返回这个类型的值,如果方法没有返回类型,应使用没有表达式的return语句。

2.6  枚举

枚举是用户定义的整数类型。在声明一个枚举时,要指定该枚举可以包含的一组可接受的实例值。不仅如此,还可以给值指定易于记忆的名称。如果在代码的某个地方,要试图把一个不在可接受范围内的值赋予枚举的一个实例,编译器就会报告一个错误。这个概念对于VB程序员来说是新的。C++支持枚举,但C#枚举要比C++枚举强大得多。

从长远来看,创建枚举可以节省大量的时间,减少许多麻烦。使用枚举比使用无格式的整数至少有如下三个优势:

       如上所述,枚举可以使代码更易于维护,有助于确保给变量指定合法的、期望的值。

       枚举使代码更清晰,允许用描述性的名称表示整数值,而不是用含义模糊的数来表示。

       枚举使代码更易于键入。在给枚举类型的实例赋值时,VS .NET IDE会通过IntelliSense弹出一个包含可接受值的列表框,减少了按键次数,并能够让我们回忆起可选的值。

定义如下的枚举:

public enum TimeOfDay

{

   Morning = 0,

   Afternoon = 1,

   Evening = 2

}

在本例中,在枚举中使用一个整数值,来表示一天的每个阶段。现在可以把这些值作为枚举的成员来访问。例如,TimeOfDay.Morning返回数字0。使用这个枚举一般是把合适的值传送给方法,在switch语句中迭代可能的值。

class EnumExample

{

   public static int Main()

   {

      WriteGreeting(TimeOfDay.Morning);

      return 0;

   }

   static void WriteGreeting(TimeOfDay timeOfDay)

   {

      switch(timeOfDay)

      {

         case TimeOfDay.Morning:

            Console.WriteLine("Good morning!");

            break;

         case TimeOfDay.Afternoon:

            Console.WriteLine("Good afternoon!");

            break;

         case TimeOfDay.Evening:

            Console.WriteLine("Good evening!");

            break;

         default:

            Console.WriteLine("Hello!");

            break;

      }

   }

}

在C#中,枚举的真正强大之处是它们在后台会实例化为派生于基类System.Enum的结构。这表示可以对它们调用方法,执行有用的任务。注意因为.NET Framework的执行方式,在语法上把枚举当做结构是不会有性能损失的。实际上,一旦代码编译好,枚举就成为基本类型,与int和float类似。

可以获取枚举的字符串表示,例如使用前面的TimeOfDay枚举:

TimeOfDay time = TimeOfDay.Afternoon;

Console.WriteLine(time.ToString());

会返回字符串Afternoon。

另外,还可以从字符串中获取枚举值:

TimeOfDay time2 = (TimeOfDay) Enum.Parse(typeof(TimeOfDay), "afternoon", true);

Console.WriteLine((int)time2);

这段代码说明了如何从字符串获取枚举值,并转换为整数。要从字符串中转换,需要使用静态的Enum.Parse()方法,这个方法带3个参数,第一个参数是要使用的枚举类型。其句法是关键字typeof后跟放在括号中的枚举类名。typeof运算符将在第5章详细论述。第二个参数是要转换的字符串,第三个参数是一个bool,指定在进行转换时是否忽略大小写。最后,注意Enum.Parse()方法实际上返回一个对象引用—— 我们需要把这个字符串显式转换为需要的枚举类型(这是一个拆箱操作的例子)。对于上面的代码,将返回1,作为一个对象,对应于TimeOfDay. Afternoon的枚举值。在显式转换为int时,会再次生成1。

System.Enum上的其他方法可以返回枚举定义中的值的个数、列出值的名称等。详细信息参见MSDN文档。

2.7  数组

本章不打算详细介绍数组,因为第9章将详细论述数组和集合。但本章将介绍编写一维数组的句法。在声明C#中的数组时,要在各个元素的变量类型后面,加上一组方括号(注意数组中的所有元素必须有相同的数据类型)。

提示:

VB用户注意,C#中的数组使用方括号,而不是圆括号。C++用户很熟悉方括号,但应仔细查看这里给出的代码,因为声明数组变量的C#语法与C++语法并不相同。

例如,int表示一个整数,而int[]表示一个整型数组:

int[] integers;

要初始化特定大小的数组,可以使用new关键字,在类型名后面的方括号中给出大小:

// Create a new array of 32 ints

int[] integers = new int[32];

所有的数组都是引用类型,并遵循引用的语义。因此,即使各个元素都是基本的值类型,integers数组也是引用类型。如果以后编写如下代码:

int[] copy = integers;

该代码也只是把变量copy指向同一个数组,而不是创建一个新数组。

要访问数组中的单个元素,可以使用通常的语法,在数组名的后面,把元素的下标放在方括号中。所有的C#数组都使用基于0的下标方式,所以要用下标0引用第一个变量:

integers[0] = 35;

同样,用下标值31引用有32个元素的数组中的最后一个元素:

integers[31] = 432;

C#的数组句法也非常灵活,实际上,C#可以在声明数组时不进行初始化,这样以后就可以在程序中动态地指定其大小。利用这项技术,可以创建一个空引用,以后再使用new关键字把这个引用指向请求动态分配的内存位置:

int[] integers;

integers = new int[32];

可以使用下面的语法查看一个数组包含多少个元素:

int numElements = integers.Length;   // integers is any reference to an array

2.8  命名空间

如前所述,命名空间提供了一种组织相关类和其他类型的方式。与文件或组件不同,命名空间是一种逻辑组合,而不是物理组合。在C#文件中定义类时,可以把它包括在命名空间定义中。以后,在定义另一个类,在另一个文件中执行相关操作时,就可以在同一个命名空间中包含它,创建一个逻辑组合,告诉使用类的其他开发人员:这两个类是如何相关的以及如何使用它们:

namespace CustomerPhoneBookApp

{

   using System;

   public struct Subscriber

   {

      // Code for struct here...

   }

}

把一个类型放在命名空间中,可以有效地给这个类型指定一个较长的名称,该名称包括类型的命名空间,后面是句点(.)和类的名称。在上面的例子中,Subscriber结构的全名是CustomerPhoneBookApp.Subscriber。这样,有相同短名的不同的类就可以在同一个程序中使用了。

也可以在命名空间中嵌套其他命名空间,为类型创建层次结构:

namespace Wrox

{

   namespace ProCSharp

   {

      namespace Basics

      {

         class NamespaceExample

         {

            // Code for the class here...

         }

      }

   }

}

每个命名空间名都由它所在命名空间的名称组成,这些名称用句点分隔开,首先是最外层的命名空间,最后是它自己的短名。所以ProCSharp命名空间的全名是Wrox.ProCSharp,NamespaceExample类的全名是Wrox.ProCSharp.Basics.NamespaceExample。

使用这个语法也可以组织自己的命名空间定义中的命名空间,所以上面的代码也可以写为:

namespace Wrox.ProCSharp.Basics

{

   class NamespaceExample

   {

      // Code for the class here...

   }

}

注意不允许在另一个嵌套的命名空间中声明多部分的命名空间。

命名空间与程序集无关。同一个程序集中可以有不同的命名空间,也可以在不同的程序集中定义同一个命名空间中的类型。

2.8.1  using语句

显然,命名空间相当长,键入起来很繁琐,用这种方式指定某个类也是不必要的。如本章开头所述,C#允许简写类的全名。为此,要在文件的顶部列出类的命名空间,前面加上using关键字。在文件的其他地方,就可以使用其类型名称来引用命名空间中的类型了:

using System;

using Wrox.ProCSharp;

如前所述,所有的C#源代码都以语句using System;开头,这仅是因为Microsoft提供的许多有用的类都包含在System命名空间中。

如果using指令引用的两个命名空间包含同名的类,就必须使用完整的名称(或者至少较长的名称),确保编译器知道访问哪个类型,例如,类NamespaceExample同时存在于Wrox. ProCSharp.Basics和Wrox.ProCSharp.OOP命名空间中,如果要在命名空间Wrox.ProCSharp中创建一个类Test,并在该类中实例化一个NamespaceExample类,就需要指定使用哪个类:

using Wrox.ProCSharp;

class Test

{

   public static int Main()

   {

      Basics.NamespaceExample nSEx = new Basics.NamespaceExample();

      //do something with the nSEx variable

      return 0;

   }

}  

因为using语句在C#文件的开头,C和C++也把#include语句放在这里,所以从C++迁移到C#的程序员常把命名空间与C++风格的头文件相混淆。不要犯这种错误,using语句在这些文件之间并没有真正建立物理链接。C#也没有对应于C++头文件的部分。

公司应花一定的时间开发一种命名空间模式,这样其开发人员才能快速定位他们需要的功能,而且公司内部使用的类名也不会与外部的类库相冲突。本章后面将介绍建立命名空间模式的规则和其他命名约定。

2.8.2  命名空间的别名

using关键字的另一个用途是给类和命名空间指定别名。如果命名空间的名称非常长,又要在代码中使用多次,但不希望该命名空间的名称包含在using指令中(例如,避免类名冲突),就可以给该命名空间指定一个别名,其语法如下:

using alias = NamespaceName;

下面的例子(前面例子的修订版本)给Wrox.ProCSharp.Basics命名空间指定Introduction别名,并使用这个别名实例化了一个NamespaceExample对象,这个对象是在该命名空间中定义的。注意命名空间别名的修饰符是::。因此将先从Introduction命名空间别名开始搜索。如果在相同的作用域中引入了一个Introduction类,就会发生冲突。即使出现了冲突,::操作符也允许引用别名。NamespaceExample类有一个方法GetNamespace(),该方法调用每个类都有的GetType()方法,以访问表示类的类型的Type对象。下面使用这个对象来返回类的命名空间名:

using System;

using Introduction = Wrox.ProCSharp.Basics;

class Test

{

   public static int Main()

   {

      Introduction::NamespaceExample NSEx =

         new Introduction::NamespaceExample();

      Console.WriteLine(NSEx.GetNamespace());

      return 0;

   }

}  

  

namespace Wrox.ProCSharp.Basics

{

   class NamespaceExample

   {

      public string GetNamespace()

      {

         return this.GetType().Namespace;

      }

   }

}

2.9  Main()方法

本章的开头提到过,C#程序是从方法Main()开始执行的。这个方法必须是类或结构的静态方法,并且其返回类型必须是int或void。

虽然显式指定public修饰符是很常见的,因为按照定义,必须在程序外部调用该方法,但我们给该方法指定什么访问级别并不重要,即使把该方法标记为private,它也可以运行。

2.9.1  多个Main()方法

在编译C#控制台或Windows应用程序时,默认情况下,编译器会在与上述签名匹配的类中查找Main方法,并使这个类方法成为程序的入口。如果有多个Main方法,编译器就会返回一个错误,例如,考虑下面的代码MainExample.cs:

using System;

namespace Wrox.ProCSharp.Basics

{

   class Client

   {

      public static int Main()

      {

         MathExample.Main();

         return 0;

      }

   }

   class MathExample

   {

      static int Add(int x, int y)

      {

         return x + y;

      }

      public static int Main()

      {

         int i = Add(5,10);

         Console.WriteLine(i);

         return 0;

      }

   }

}

上述代码中包含两个类,它们都有一个Main()方法。如果按照通常的方式编译这段代码,就会得到下述错误:

csc MainExample.cs

Microsoft (R) Visual C# .NET Compiler version 8.00.40607.16

for Microsoft (R) .NET Framework version 2.00.40607

Copyright (C) Microsoft Corporation 2001-2003. All rights reserved.

MainExample.cs(7,23): error CS0017: Program 'MainExample.exe' has more than one entry point defined: 'Wrox.ProCSharp.Basics.Client.Main()'

MainExample.cs(21,23): error CS0017: Program 'MainExample.exe' has more than one entry point defined: 'Wrox.ProCSharp.Basics.MathExample.Main()'

但是,可以使用/main选项,其后跟Main()方法所属类的全名(包括命名空间),明确告诉编译器把哪个方法作为程序的入口点:

csc MainExample.cs /main:Wrox.ProCSharp.Basics.MathExample

2.9.2  Main()方法传送参数

前面的例子只介绍了不带参数的Main()方法。但在调用程序时,可以让CLR包含一个参数,将命令行参数转送给程序。这个参数是一个字符串数组,传统称args(但C#可以接受任何名称)。在启动程序时,可以使用这个数组,访问通过命令行传送过来的选项。

下面的例子ArgsExample.cs是在传送给Main方法的字符串数组中迭代,并把每个选项的值写入控制台窗口

using System;

namespace Wrox.ProCSharp.Basics

{

   class ArgsExample

   {

      public static int Main(string[] args)

      {

         for (int i = 0; i < args.Length; i++)

         {

            Console.WriteLine(args[i]);

         }

         return 0;

      }

   }

}

通常使用命令行就可以编译这段代码。在运行编译好的可执行文件时,可以在程序名的后面加上参数,例如:

ArgsExample /a /b /c

/a

/b

/c

2.10  有关编译C#文件的更多内容

前面介绍了如何使用csc.exe编译控制台应用程序,但其他类型的应用程序应如何编译?如果要引用一个类库,该怎么办?MSDN文档介绍了C#编译器的所有编译选项,这里只介绍其中最重要的选项。

要回答第一个问题,应使用/target选项(常简写为/t)来指定要创建的文件类型。文件类型可以是表2-8所示的类型中的一种。

  2-8

   

    

/t:exe

控制台应用程序 (默认)

/t:library

带有清单的类库

/t:module

没有清单的组件

/t:winexe

Windows应用程序 (没有控制台窗口)

如果想得到一个可由.NET运行库加载的非可执行文件(例如DLL),就必须把它编译为一个库。如果把C#文件编译为一个模块,就不会创建任何程序集。虽然模块不能由运行库加载,但可以使用/addmodule选项编译到另一个清单中。

另一个需要注意的选项是/out,该选项可以指定由编译器生成的输出文件名。如果没有指定/out选项,编译器就会使用输入的C#文件名,加上目标类型的扩展名来建立输出文件名(例如.exe表示Windows或控制台应用程序,.dll表示类库)。注意/out和/t(或/target)选项必须放在要编译的文件名前面。

默认状态下,如果在未引用的程序集中引用类型,可以使用/reference或/r选项,后跟程序集的路径和文件名。下面的例子说明了如何编译类库,并在另一个程序集中引用这个库。它包含两个文件:

       类库

       控制台应用程序,该应用程序调用库中的一个类。

第一个文件MathLibrary.cs包含DLL的代码,为了简单起见,它只包含一个公共类Math和一个方法,该方法把两个int类型的数据加在一起:

namespace Wrox.ProCSharp.Basics

{

   public class MathLib

   {

      public int Add(int x, int y)

      {

         return x + y;

      }

   }

}

使用下述命令把这个C#文件编译为. NET DLL:

csc /t:library MathLibrary.cs

控制台应用程序MathClient.cs将简单地实例化这个对象,调用其Add方法,在控制台窗口中显示结果:

using System;

namespace Wrox.ProCSharp.Basics

{

   class Client

   {

      public static void Main()

      {

         MathLib mathObj = new MathLib();

         Console.WriteLine(mathObj.Add(7,8));

      }

   }

}

使用/r选项编译这个文件,使之指向新编译的DLL:

csc MathClient.cs /r:MathLibrary.dll

当然,下面就可以像往常一样运行它了:在命令提示符上输入MathClient,其结果是显示数字15—— 加运算的结果。

2.11  控制台I/O

现在,您应基本熟悉了C#的数据类型以及如何在操作这些数据类型的程序中完成任务。本章还要使用Console类的几个静态方法来读写数据,这些方法在编写基本的C#程序时非常有效,下面就详细介绍它们。

要从控制台窗口中读取一行文本,可以使用Console.ReadLine()方法,它会从控制台窗口中取一个输入流(在用户按下回车键时停止),并返回输入的字符串。写入控制台也有两个对应的方法,前面已经使用过它们:

       Console. Write()方法将指定的值写入控制台窗口。

       Console.WriteLine()方法类似,但在输出结果的最后添加一个换行符。

所有预定义类型(包括object) 都有这些函数的各种形式(重载),所以在大多数情况下,在显示值之前不必把它们转换为字符串。

例如,下面的代码允许用户输入一行文本,并显示该文本:

string s = Console.ReadLine();

Console.WriteLine(s);

Console.WriteLine()还允许用与C的printf()函数类似的方式显示格式化的结果。要以这种方式使用WriteLine(),应传入许多参数。第一个参数是花括号中包含标记的字符串,在这个花括号中,要把后续的参数插入到文本中。每个标记都包含一个基于0的索引,表示列表中参数的序号。例如,"{0}"表示列表中的第一个参数,所以下面的代码:

int i = 10;

int j = 20;

Console.WriteLine("{0} plus {1} equals {2}", i, j, i + j);

会显示:

10 plus 20 equals 30

也可以为值指定宽度,调整文本在该宽度中的位置,正值表示右对齐,负值表示左对齐。为此可以使用格式{n,w},其中n是参数索引,w是宽度值。

int i = 940;

int j = 73;

Console.WriteLine(" {0,4}"n+{1,4}"n––––"n {2,4}", i, j, i + j);

结果如下:

  940

+  73

     

 1013

最后,还可以添加一个格式字符串,和一个可选的精度值。这里没有列出格式字符串的完整列表,因为如第8章所述,我们可以定义自己的格式字符串。但用于预定义类型的主要格式字符串如表2-9所示。

  2-9

  

   

C

本地货币格式

D

十进制格式,把整数转换为以10为基数的数,如果给定一个精度说明符,就加上前导0

E

科学计数法(指数)格式。精度说明符设置小数位数(默认为6)。格式字符串的大小写("e" "E")确定指数符号的大小写

F

固定点格式,精度说明符设置小数位数,可以为0

G

普通格式,使用E F格式取决于哪种格式较简单

N

数字格式,用逗号表示千分符,例如32,767.44

P

百分数格式

X

16进制格式,精度说明符用于加上前导0

注意格式字符串都不需要考虑大小写,除e/E之外。

如果要使用格式字符串,应把它放在给出参数个数和字段宽度的标记后面,并用一个冒号把它们分隔开。例如,要把decimal值格式化为货币格式,且使用计算机上的地区设置,其精度为两位小数,则使用C2:

decimal i = 940.23m;

decimal j = 73.7m;

Console.WriteLine(" {0,9:C2}"n+{1,9:C2}"n ––––––"n {2,9:C2}", i, j, i + j);

在美国,其结果是:

   $940.23

+   $73.70

                    

 $1,013.93

最后一个技巧是,可以使用占位符来代替这些格式字符串,例如:

double d = 0.234;

Console.WriteLine("{0:#.00}", d);

其结果为0.23,因为如果在符号(#)的位置上没有字符,就会忽略该符号(#),如果0的位置上有一个字符,就用这个字符代替0,否则就显示0。

2.12  使用注释

本节的内容表面上看起来很简单——给代码添加注释。

2.12.1  源文件中的内部注释

在本章开头提到过,C#使用传统的C风格注释方式:单行注释使用// ...,多行注释使用   /* ... */:

// This is a single-line comment

/* This comment

  spans multiple lines */

单行注释中的任何内容,即//后面的内容都会被编译器忽略。多行注释中/* 和 */之间的所有内容也会被忽略。显然不能在多行注释中包含*/组合,因为这会被当作注释的结尾。

实际上,可以把多行注释放在一行代码中:

Console.WriteLine(/*Here's a comment! */ "This will compile");

像这样的内联注释在使用时应小心,因为它们会使代码难以理解。但这样的注释在调试时是非常有用的,例如,在运行代码时要临时使用另一个值:

DoSomething(Width, /*Height*/ 100);

当然,字符串字面值中的注释字符会按照一般的字符来处理:

string s = "/* This is just a normal string */";

2.12.2  XML文档说明

如前所述,除了C风格的注释外,C#还有一个非常好的功能,本章将讨论这一功能。根据特定的注释自动创建XML格式的文档说明。这些注释都是单行注释,但都以3个斜杠(///)开头,而不是通常的两个斜杠。在这些注释中,可以把包含类型和类型成员的文档说明的XML标识符放在代码中。

编译器可以识别表2-10中所示的标识符。

  2-10

  

   

<c>

把行中的文本标记为代码,例如<c>int i = 10;</c>

<code>

把多行标记为代码

<example>

标记为一个代码示例

<exception>

说明一个异常类(编译器要验证其语法)

<include>

包含其他文档说明文件的注释(编译器要验证其语法)

<list>

把列表插入到文档说明中

<param>

标记方法的参数(编译器要验证其语法)

<paramref>

表示一个单词是方法的参数(编译器要验证其语法)

<permission>

说明对成员的访问(编译器要验证其语法)

<remarks>

给成员添加描述

<returns>

说明方法的返回值

<see>

提供对另一个参数的交叉引用(编译器要验证其语法)

<seealso>

提供描述中的“参见”部分(编译器要验证其语法)

<summary>

提供类型或成员的简短小结

<value>

描述属性

要了解它们的工作方式,可以在上一节的MathLibrary.cs文件中添加一些XML注释,并称之为Math.cs。我们给类及其Add方法添加一个<summary>注释,也给Add方法添加一个<returns>元素和两个<param>元素:

// Math.cs

namespace Wrox.ProCSharp.Basics

{

   ///<summary>

   ///   Wrox.ProCSharp.Basics.Math class.

   ///   Provides a method to add two integers.

   ///</summary>

   public class Math

   {

      ///<summary>

      ///   The Add method allows us to add two integers

      ///</summary>

      ///<returns>Result of the addition (int)</returns>

      ///<param FTEL="x">First number to add</param>

      ///<param FTEL="y">Second number to add</param>

      public int Add(int x, int y)

      {

         return x + y;

      }

   }

}

C#编译器可以把XML元素从特定的注释中提取出来,并使用它们生成一个XML文件。要让编译器为程序集生成XML文档说明,需在编译时指定/doc选项,其后需跟上要创建的文件名:

csc /t:library /doc:Math.xml Math.cs

如果XML注释没有生成格式正确的XML文档,编译器就生成一个错误。

上面的代码会生成一个XML文件Math.xml,如下所示。

<?xml version="1.0"?>

<doc>

   <assembly>

      <name>Math</name>

   </assembly>

   <members>

      <member FTEL="T:Wrox.ProCSharp.Basics.Math">

         <summary>

            Wrox.ProCSharp.Basics.Math class.

            Provides a method to add two integers.

         </summary>

      </member>

      <member FTEL=

            "M:Wrox.ProCSharp.Basics.Math.Add(System.Int32,System.Int32)">

         <summary>

            The Add method allows us to add two integers

         </summary>

         <returns>Result of the addition (int)</returns>

         <param FTEL="x">First number to add</param>

         <param FTEL="y">Second number to add</param>

      </member>

   </members>

</doc>

注意,编译器为我们做了一些工作——它创建了一个<assembly>元素,并为该文件中的每个类型或类型成员添加一个<member>元素。每个<member>元素都有一个name特性,其中包含成员的全名,前面有一个字母表示其类型:"T:"表示这是一个类型,"F:" 表示这是一个字段,"M:" 表示这是一个成员。

2.13  C#预处理器指令

除了前面介绍的常用关键字外,C#还有许多名为“预处理器指令”的命令。这些命令从来不会转化为可执行代码中的命令,但会影响编译过程的各个方面。例如,使用预处理器指令可以禁止编译器编译代码的某一部分。如果计划发布两个版本的代码,即基本版本和有更多功能的企业版本,就可以使用这些预处理器指令。在编译软件的基本版本时,使用预处理器指令还可以禁止编译器编译与额外功能相关的代码。另外,在编写提供调试信息的代码时,也可以使用预处理器指令。实际上,在销售软件时,一般不希望编译这部分代码。

预处理器指令的开头都有符号#。

注意:

C++开发人员应知道在CC++中,预处理器指令是非常重要的,但是,在C#中,并没有那么多的预处理器指令,它们的使用也不太频繁。C#提供了其他机制来实现许多C++指令的功能,例如定制特性。还要注意,C#并没有一个像C++那样的独立预处理器,所谓的预处理器指令实际上是由编译器处理的。尽管如此,C#仍保留了一些预处理器指令,因为这些命令对预处理有一定的影响。

下面简要介绍预处理器指令的功能。

2.13.1  #define#undef

#define的用法如下所示:

#define DEBUG

它告诉编译器存在给定名称的符号,在本例中是DEBUG。这有点类似于声明一个变量,但这个变量并没有真正的值,只是存在而已。这个符号不是实际代码的一部分,而只在编译器编译代码时存在。在C#代码中它没有任何意义。

#undef正好相反—— 删除符号的定义:

#undef DEBUG

如果符号不存在,#undef就没有任何作用。同样,如果符号已经存在,#define也不起作用。

必须把#define和#undef命令放在C#源代码的开头,在声明要编译的任何对象的代码之前。

#define本身并没有什么用,但当与其他预处理器指令(特别是#if)结合使用时,它的功能就非常强大了。

注意:

这里应注意一般的C#语法的一些变化。预处理器指令不用分号结束,一般是一行上只有一个命令。这是因为对于预处理器指令,C#不再要求命令用分号结束。如果它遇到一个预处理器指令,就会假定下一个命令在下一行上。

2.13.2  #if, #elif, #else#endif


 

这些指令告诉编译器是否要编译某个代码块。考虑下面的方法:

   int DoSomeWork(double x)

   {

      // do something

      #if DEBUG

         Console.WriteLine("x is " + x);

      #endif

   }

这段代码会像往常那样编译,但Console.WriteLine命令包含在#if子句内。这行代码只有在前面的#define命令定义了符号DEBUG后才执行。当编译器遇到#if语句后,将先检查相关的符号是否存在,如果符号存在,就只编译#if块中的代码。否则,编译器会忽略所有的代码,直到遇到匹配的#endif指令为止。一般是在调试时定义符号DEBUG,把不同的调试相关代码放在#if句中。在完成了调试后,就把#define语句注释掉,所有的调试代码会奇迹般地消失,可执行文件也会变小,最终用户不会被这些调试信息弄糊涂(显然,要做更多的测试,确保代码在没有定义DEBUG的情况下也能工作)。这项技术在C和C++编程中非常普通,称为条件编译(conditional compilation)。

#elif (=else if)和#else指令可以用在#if块中,其含义非常直观。也可以嵌套#if块:

#define ENTERPRISE

#define W2K

// further on in the file

#if ENTERPRISE

   // do something

   #if W2K

      // some code that is only relevant to enterprise

      // edition running on W2K

   #endif

#elif PROFESSIONAL

   // do something else

#else

   // code for the leaner version

#endif

注意:

C++中的情况不同,使用#if不是条件编译代码的惟一方式,C#还通过Conditional特性提供了另一种机制,详见第11章。

#if和 #elif还支持一组逻辑运算符!、==、!=和 ||。如果符号存在,就被认为是true,否则为false,例如:

#if W2K && (ENTERPRISE==false)   // if W2K is defined but ENTERPRISE isn't

2.13.3  #warning# error

另外两个非常有用的预处理器指令是#warning和#error,当编译器遇到它们时,会分别产生一个警告或错误。如果编译器遇到#warning指令,会给用户显示#warning指令后面的文本,之后编译继续进行。如果编译器遇到#error指令,就会给用户显示后面的文本,作为一个编译错误信息,然后会立即退出编译,不会生成IL代码。

使用这两个指令可以检查#define语句是不是做错了什么事,使用#warning语句可以让自己想起做过什么事:

#if DEBUG && RELEASE

   #error "You've defined DEBUG and RELEASE simultaneously! "

#endif

#warning "Don't forget to remove this line before the boss tests the code! "

   Console.WriteLine("*I hate this job*");

2.13.4  #region#endregion

#region和 #endregion指令用于把一段代码标记为有给定名称的一个块,如下所示。

#region Member Field Declarations

   int x;

   double d;

   Currency balance;

#endregion

这看起来似乎没有什么用,它不影响编译过程。这些指令的优点是它们可以被某些编辑器识别,包括Visual Studio .NET编辑器。这些编辑器可以使用这些指令使代码在屏幕上更好地布局。第14章会详细介绍它们。

2.13.5  #line

#line指令可以用于改变编译器在警告和错误信息中显示的文件名和行号信息。这个指令用得并不多。如果编写代码时,在把代码发送给编译器前,要使用某些软件包改变键入的代码,就可以使用这个指令,因为这意味着编译器报告的行号或文件名与文件中的行号或编辑的文件名不匹配。#line指令可以用于恢复这种匹配。也可以使用语法#line default把行号恢复为默认的行号:

#line 164 "Core.cs"   // we happen to know this is line 164 in the file

                   // Core.cs, before the intermediate

                   // package mangles it.

// later on

#line default      // restores default line numbering

2.13.6  #pragma

#pragma指令可以抑制或恢复指定的编译警告。与命令行选项不同,#pragma指令可以在类或方法上执行,对抑制什么警告和抑制的时间进行更精细的控制。下面的例子禁止字段使用警告,然后在编译MyClass类后恢复该警告。

#pragma warning disable 169

public class MyClass

{

  int neverUsedField;

}

#pragma warning restore 169

2.14  C#编程规则

本节介绍编写C#程序时应注意的规则。

2.14.1  用于标识符的规则

本节将讨论变量、类、方法等的命名规则。注意本节所介绍的规则不仅是规则,也是C#编译器强制使用的。

标识符是给变量、用户定义的类型(例如类和结构)和这些类型的成员指定的名称。标识符区分大小写,所以interestRate 和 InterestRate是不同的变量。确定在C#中可以使用什么标识符有两个规则:

       它们必须以一个字母或下划线开头,但可以包含数字字符;

       不能把C#关键字用作标识符。

C#包含如表2-11所示的保留关键字。

  2-11

abstract

do

In

protected

true

as

double

Int

public

try

base

else

Interface

readonly

typeof

bool

enum

Internal

ref

uint

break

event

Is

return

ulong

byte

explicit

lock

sbyte

unchecked

case

extern

long

sealed

unsafe

catch

false

namespace

short

ushort

char

finally

new

sizeof

using

checked

fixed

null

stackalloc

virtual

class

float

object

static

volatile

const

for

operator

string

void

continue

foreach

out

struct

while

decimal

goto

override

switch

default

if

params

this

delegate

Implicit

private

throw

如果需要把某一保留字用作标识符(例如,访问一个用另一种语言编写的类),可以在标识符的前面加上前缀@符号,指示编译器其后的内容是一个标识符,而不是C#关键字(所以abstract不是有效的标识符,而@abstract是)。

最后,标识符也可以包含Unicode字符,用语法"uXXXX来指定,其中XXXX是Unicode字符的四位16进制代码。下面是有效标识符的一些例子:

       Name

       überfluß

       _Identifier

       "u005fIdentifier

最后两个标识符是相同的,可以互换(005f是下划线字符的Unicode代码),所以在相同的作用域内不要声明两次。注意虽然从语法上看,标识符中可以使用下划线字符,但在大多数情况下,最好不要这么做,因为它不符合Microsoft的变量命名规则,这种命名规则可以确保开发人员使用相同的命名规则,易于阅读每个人编写的代码。

2.14.2  用法约定

在任何开发环境中,通常有一些传统的编程风格。这些风格不是语言的一部分,而是约定,例如,变量如何命名,类、方法或函数如何使用等。如果使用某语言的大多数开发人员都遵循相同的约定,不同的开发人员就很容易理解彼此的代码,有助于程序的维护。例如,Visual Basic 6的一个公共(但不统一)约定是,表示字符串的变量名以小写字母s或str开头,如Dim sResult As String或 Dim strMessage As String。约定主要取决于语言和环境。例如,在Windows平台上编程的C++开发人员一般使用前缀psz或 lpsz表示字符串:char *pszResult; char *lpszMessage;,但在UNIX机器上,则不使用任何前缀:char *Result; char *Message;。

从本书中的示例代码中可以总结出,C#中的约定是命名变量时不使用任何前缀:string Result; string Message;。

注意:

用带有前缀字母的变量名来表示某个数据类型,这种约定称为Hungarian表示法。这样,其他阅读该代码的开发人员就可以立即从变量名中了解它代表什么数据类型。在有了智能编辑器和IntelliSense之后,人们普遍认为Hungarian表示法是多余的。

但是,在许多语言中,用法约定是从语言的使用过程中逐渐演变而来的,Microsoft编写的C#和整个.NET Framework都有非常多的用法约定,详见.NET/C# MSDN文档说明。这说明,从一开始,.NET程序就有非常高的互操作性,开发人员可以以此来理解代码。用法规则还得益于20年来面向对象编程的发展,因此相关的新闻组已经仔细考虑了这些用法规则,而且已经为开发团体所接受。所以我们应遵守这些约定。

但要注意,这些规则与语言规范是不同的。用户应尽可能遵循这些规则。但如果有很好的理由不遵循它们,也不会有什么问题。例如,不遵循这些用法约定,也不会出现编译错误。一般情况下,如果不遵循用法规则,就必须有一个说得过去的理由。规则应是一个正确的决策,而不是让人头痛的东西。在阅读本书的后续内容时,应注意到在本书的许多示例中,都没有遵循该约定,这通常是因为某些规则适用于大型程序,而不适合于本书中的小示例。如果编写一个完整的软件包,就应遵循这些规则,但它们并不适合于只有20行代码的独立程序。在许多情况下,遵循约定会使这些示例难以理解。

编程风格的规则非常多。这里只介绍一些比较重要的规则,以及最适合于用户的规则。如果用户要让代码完全遵循用法规则,就需要参考MSDN文档说明。

1. 命名约定

使程序易于理解的一个重要方面是给对象选择命名的方式,包括变量名、方法名、类名、枚举名和命名空间的名称。

显然,这些名称应反映对象的功能,且不与其他名称冲突。在.NET Framework中,一般规则也是变量名要反映变量实例的功能,而不是反映数据类型。例如,Height就是一个比较好的变量名,而IntegerValue就不太好。但是,这种规则是一种理想状态,很难达到。在处理控件时,大多数情况下使用ConfirmationDialog 和 ChooseEmployeeListBox等变量名比较好,这些变量名说明了变量的数据类型。

名称的约定包括以下几个方面:

(1) 名称的大小写

在许多情况下,名称都应使用Pascal大小写命名形式。 Pascal 大小写形式是指名称中单词的第一个字母大写: EmployeeSalary, ConfirmationDialog, PlainTextEncoding。注意,命名空间、类、以及基类中的成员等的名称都应遵循该规则,最好不要使用带有下划线字符的单词,即名称不应是employee_salary。其他语言中常量的名称常常全部都是大写,但在C#中最好不要这样,因为这种名称很难阅读,而应全部使用Pascal 大小写形式的命名约定:

   const int MaximumLength;

我们还推荐使用另一种大小写模式:camel大小写形式。这种形式类似于Pascal 大小写形式,但名称中第一个单词的第一个字母不是大写:employeeSalary、confirmationDialog、plainTextEncoding。有三种情况可以使用camel大小写形式。

       类型中所有私有成员字段的名称都应是camel大小写形式:

public int subscriberId;

但要注意成员字段名常常用一个下划线开头:

public int_subscriberId;

       传递给方法的所有参数都应是camel大小写形式:

 public void RecordSale(string salesmanName, int quantity);

       camel大小写形式也可以用于区分同名的两个对象——比较常见的情况是属性封装一个字段:

 private string employeeName;

public string EmployeeName

{

  get  

     {

        return employeeName;

     }

  }

如果这么做,则私有成员总是使用camel大小写形式,而公共的或受保护的成员总是使用Pascal 大小写形式,这样使用这段代码的其他类就只能使用Pascal 大小写形式的名称了(除了参数名以外)。

还要注意大小写问题。C#是区分大小写的,所以在C#中,仅大小写不同的名称在语法上是正确的,如上面的例子。但是,程序集可能在VB .NET应用程序中调用,而VB .NET是不区分大小写的,如果使用仅大小写不同的名称,就必须使这两个名称不能在程序集的外部访问。(上例是可行的,因为仅私有变量使用了camel大小写形式的名称)。否则,VB .NET中的其他代码就不能正确使用这个程序集。

(2) 名称的风格

名称的风格应保持一致。例如,如果类中的一个方法叫ShowConfirmationDialog(),其他方法就不能叫ShowDialogWarning()或 WarningDialogShow(),而应是ShowWarningDialog()。

(3) 命名空间的名称

命名空间的名称非常重要,一定要仔细设计,以避免一个命名空间中对象的名称与其他对象同名。记住,命名空间的名称是.NET区分共享程序集中对象名的惟一方式。如果软件包的命名空间使用的名称与另一个软件包相同,而这两个软件包都安装在一台计算机上,就会出问题。因此,最好用自己的公司名创建顶级的命名空间,再嵌套后面技术范围较窄、用户所在小组或部门、或类所在软件包的命名空间。Microsoft建议使用如下的命名空间:<CompanyName>. <TechnologyName>,例如:

WeaponsOfDestructionCorp.RayGunControllers

WeaponsOfDestructionCorp.Viruses

(4) 名称和关键字

名称不应与任何关键字冲突,这是非常重要的。实际上,如果在代码中,试图给某个对象指定与C#关键字同名的名称,就会出现语法错误,因为编译器会假定该名称表示一个语句。但是,由于类可能由其他语言编写的代码访问,所以不能使用其他.NET语言中的关键字作为对象的名称。一般说来,C++关键字类似于C#关键字,不太可能与C++混淆,Visual C++常用的关键字则用两个下划线字符开头。与C#一样,C++关键字都是小写字母,如果要遵循公共类和成员使用Pascal风格的名称的约定,则在它们的名称中至少有一个字母是大写,因此不会与C++关键字冲突。另一方面,VB的问题会多一些,因为VB的关键字要比C#的多,而且它不区分大小写,不能依赖于Pascal风格的名称来区分类和成员。

表2-12列出了VB中的关键字和标准函数调用,无论对C#公共类使用什么大小写组合,这些名称都不应使用。

  2-12

Abs

Do

Loc

RGB

Add

Double

Local

Right

AddHandler

Each

Lock

RmDir

AddressOf

Else

LOF

Rnd

And

Empty

Long

SaveSettings

Ansi

End

Loop

Second

AppActivate

Enum

LTrim

Seek

Append

EOF

Me

Select

As

Erase

Mid

SetAttr

Asc

Err

Minute

SetException

Assembly

Error

MIRR

Shared

Atan

Event

MkDir

Shell

(续表)  

Auto

Exit

Module

Short

Beep

Exp

Month

Sign

Binary

Explicit

MustInherit

Sin

BitAnd

ExternalSource

MustOverride

Single

BitNot

False

MyBase

SLN

BitOr

FileAttr

MyClass

Space

BitXor

FileCopy

Namespace

Spc

Boolean

FileDateTime

New

Split

ByRef

FileLen

Next

Sqrt

Byte

Filter

Not

Static

ByVal

Finally

Nothing

Step

Call

Fix

NotInheritable

Stop

Case

For

NotOverridable

Str

Catch

Format

Now

StrComp

CBool

FreeFile

NPer

StrConv

CByte

Friend

NPV

Strict

CDate

Function

Null

String

CDbl

FV

Object

Structure

CDec

Get

Oct

Sub

ChDir

GetAllSettings

Off

Switch

ChDrive

GetAttr

On

SYD

Choose

GetException

Open

SyncLock

Chr

GetObject

Option

Tab

CInt

GetSetting

Optional

Tan

Class

GetType

Or

Text

Clear

GoTo

Overloads

Then

CLng

Handles

Overridable

Throw

Collection

Hour

ParamArray

Timer

Command

If

Pmt

TimeSerial

Compare

Iif

PPmt

TimeValue

Const

Implements

Preserve

To

Cos

Imports

Print

Today

CreateObject

In

Private

Trim

CShort

Inherits

Property

Try

CSng

Input

Public

TypeName

CStr

InStr

Put

TypeOf

(续表)  

CurDir

Int

PV

UBound

Date

Integer

QBColor

UCase

DateAdd

Interface

Raise

Unicode

DateDiff

Ipmt

RaiseEvent

Unlock

DatePart

IRR

Randomize

Until

DateSerial

Is

Rate

Val

DateValue

IsArray

Read

Weekday

Day

IsDate

ReadOnly

While

DDB

IsDbNull

ReDim

Width

Decimal

IsNumeric

Remove

With

Declare

Item

RemoveHandler

WithEvents

Default

Kill

Rename

Write

Delegate

Lcase

Replace

WriteOnly

DeleteSetting

Left

Reset

Xor

Dim

Lib

Resume

Year

Dir

Line

Return

2. 属性和方法的使用

类中出现混乱的一个方面是一个数是用属性还是方法来表示。这没有硬性规定,但一般情况下,如果该对象的外观和操作都像一个变量,就应使用属性来表示它(属性详见第3章),即:

       客户机代码应能读取它的值,最好不要使用只写属性,例如,应使用SetPassword()方法,而不是Password只写属性。

       读取该值不应花太长的时间。实际上,如果它是一个属性,通常表示读取过程花的时间相对较短。

       读取该值不应有任何不希望的负面效应。设置属性的值,不应有与该属性不直接相关的负面效应。设置对话框的宽度会改变该对话框在屏幕上的外观,这是可以的,因为它与属性是相关的。

       应可以用任何顺序设置属性。在设置属性时,最好不要因为还没有设置另一个相关的属性而抛出一个异常。例如,如果为了使用访问数据库的类,需要设置ConnectionStringUserNamePassword,应确保了已经执行了该类,这样用户才能按照任何顺序设置它们。

       顺序读取属性也应有相同的效果。如果属性的值可能会出现预料不到的改变,就应把它编写为一个方法。在监视汽车运动的类中,把speed编写为属性就不是一种好的方式,而应使用GetSpeed(),另一方面,应把Weight EngineSize编写为属性,因为对于给定的对象,它们是不会改变的。

如果要编码的对象满足上述所有条件,就应对它使用属性,否则就应使用方法。

3. 字段的用法

字段的用法非常简单。字段应总是私有的,但在某些情况下也可以把常量或只读字段设置为公有,原因是如果把字段设置为公有,就可以在以后扩展或修改类。

遵循上面的规则就可以编写出好的代码,而且这些规则应与面向对编程的风格一起使用。

Microsoft在保持一致性方面相当谨慎,在编写.NET基类时就可以遵循它自己的规则。在编写.NET代码时应很好地遵循这些规则,对于基类来说,就是类、成员、命名空间的命名方式和类层次结构的工作方式等,如果编写代码的风格与基类的编写风格相同,就不会犯什么错误。

2.15  小结

本章介绍了一些C#基本语法,包括编写简单的C#程序需要掌握的内容。我们讲述了许多基础知识,但其中有许多是熟悉C风格语言(甚或JavaScript)的开发人员能立即领悟的。本章的主要内容包括:

       变量的作用域和访问级别

       声明各种数据类型的变量

       C#程序中控制执行流

       注释和XML文档说明

       预处理器指令

       用法规则和命名约定,在编写C#代码时应遵循这些规则,使代码符合一般的.NET规范,这样其他人就很容易理解您所编写的代码了。

C#语法与C++/Java语法非常类似,但仍存在一些小区别。在许多领域,将这些语法与功能结合起来,会使编码更快速,例如高质量的字符串处理功能。C#还有一个强大的已定义类型系统,该系统基于值类型和引用类型的区别。下面两章将进一步介绍C#的面向对象编程特性。 

3对象和类型

到目前为止,我们介绍了组成C#语言的主要内容——变量的声明、数据类型和程序流语句,并简要介绍了一个只包含Main()方法的完整小例子。但还没有介绍如何把这些内容组合在一起,构成一个完整的程序,其关键就在于对类的处理。这就是本章的主题。本章的主要内容如下:

       类和结构的区别

       字段、属性和方法

       按值和引用传送参数

       方法重载

       构造函数和静态构造函数

       只读字段

       Object类,其他类型都从该类派生而来

第4章将介绍继承以及与继承相关的特性。

提示:

本章将讨论与类相关的基本语法,但假定您已经熟悉了使用类的基本原则,例如,知道构造函数和属性的含义,因此我们只是大致论述如何把这些原则应用于C#代码。如果您不熟悉类的概念,请参阅附录A,并可从网站www.wrox.com上下载本书的代码。

本章介绍的这些概念不一定得到了大多数面向对象语言的支持。例如对象构造函数是您熟悉的、使用广泛的一个概念,但静态构造函数就是C#的新增内容,所以我们将解释静态构造函数的工作原理。

3.1  类和结构

类和结构实际上都是创建对象的模板,每个对象都包含数据,并提供了处理和访问数据的方法。类定义了每个类对象(称为实例)可以包含什么数据和功能。例如,如果一个类表示一个顾客,就可以定义字段CustomerID、FirstName、LastName和Address,以包含该顾客的信息。还可以定义处理存储在这些字段中的数据的功能。接着,就可以实例化这个类的对象,以表示某个顾客,并为这个实例设置这些字段,使用其功能。

class PhoneCustomer

{

   public const string DayOfSendingBill ="Monday";

 public int CustomerID;

   public string FirstName;

   public string LastName;

}

结构在内存中的存储方式(类是存储在堆(heap)上的引用类型,而结构是存储在堆栈(stack)上的值类型)、访问方式和一些特征(如结构不支持继承)与类不同。较小的数据类型使用结构可提高性能。但在语法上,结构与类非常相似,主要的区别是使用关键字struct代替class来声明结构。例如,如果希望所有的PhoneCustomer实例都存储在堆栈上,而不是存储在托管堆上,就可以编写下面的语句:

struct PhoneCustomerStruct

{

   public const string DayOfSendingBill = "Monday";

   public int CustomerID;

   public string FirstName;

   public string LastName;

}

对于类和结构,都使用关键字new来声明实例:这个关键字创建对象并对其进行初始化。在下面的例子中,类和结构的字段值都默认为0:

PhoneCustomer myCustomer = new PhoneCustomer();     //works for a class

PhoneCustomerStruct myCustomer2 = new PhoneCustomerStruct();   // works for a struct

在大多数情况下,类要比结构常用得多。因此,我们先讨论类,然后指出类和结构的区别,以及选择使用结构而不使用类的特殊原因。但除非特别说明,否则就可以假定用于类的代码也适用于结构。

3.2  类成员

类中的数据和函数称为类的成员。Microsoft的正式术语对数据成员和函数成员进行了区分。除了这些成员外,类还可以包含嵌套的类型(例如其他类)。类中的所有成员都可以声明为public(此时可以在类的外部直接访问它们)或private(此时,它们只能由类中的其他代码来访问)。与VB、C++和Java一样,C#在这个方面还有变化,例如protected(表示成员仅能由该成员所在的类及其派生类访问),第4章将详细解释各种访问级别。

3.2.1  数据成员

数据成员包含了类的数据—— 字段、常量和事件。数据成员可以是静态数据(与整个类相关)或实例数据(类的每个实例都有它自己的数据副本)。通常,对于面向对象的语言,类成员总是实例成员,除非用static进行了显式的声明。

字段是与类相关的变量。在前面的例子中已经使用了PhoneCustomer类中的字段:

一旦实例化PhoneCustomer对象,就可以使用语法Object.FieldName来访问这些字段:

PhoneCustomer Customer1 = new PhoneCustomer();

Customer1.FirstName = "Simon";

常量与类的关联方式同变量与类的关联方式一样。使用const关键字来声明常量。如果它们声明为public,就可以在类的外部访问。

class PhoneCustomer

{

   public const string DayOfSendingBill = "Monday";

   public int CustomerID;

   public string FirstName;

   public string LastName;

}

事件是类的成员,在发生某些行为(例如改变类的字段或属性,或者进行了某种形式的用户交互操作)时,它可以让对象通知调用程序。客户可以包含称为“事件处理程序”的代码来响应该事件。第6章将详细介绍事件。

3.2.2  函数成员

函数成员提供了操作类中数据的某些功能,包括方法、属性、构造函数和终结器(finalizer)、运算符以及索引器。

方法是与某个类相关的函数,它们可以是实例方法,也可以是静态方法。实例方法处理类的某个实例,静态方法提供了更一般的功能,不需要实例化一个类(例如Console.WriteLine()方法)。下一节介绍方法。

属性是可以在客户机上访问的函数组,其访问方式与访问类的公共字段类似。C#为读写类上的属性提供了专用语法,所以不必使用那些名称中嵌有Get或Set的偷工减料的方法。因为属性的这种语法不同于一般函数的语法,在客户代码中,虚拟的对象被当做实际的东西。

构造函数是在实例化对象时自动调用的函数。它们必须与所属的类同名,且不能有返回类型。构造函数用于初始化字段的值。

终结器类似于构造函数,但是在CLR检测到不再需要某个对象时调用。它们的名称与类相同,但前面有一个~符号。C++程序员应注意,终结器在C#中比在C++中用得少得多,因为CLR会自动进行垃圾收集,另外,不可能预测什么时候调用终结器。第7章将介绍终结器。

运算符执行的最简单的操作就是+和–。在对两个整数进行相加操作时,严格地说,就是对整数使用+运算符。C#还允许指定把已有的运算符应用于自己的类(运算符重载)。第5章将详细论述运算符。

索引器允许对象以数组或集合的方式进行索引。第5章介绍索引器。

1. 方法

在VB、C和C++中,可以定义与类完全不相关的全局函数,但在C#中不能这样做。在C#中,每个函数都必须与类或结构相关。

注意,正式的C#术语实际上并没有区分函数和方法。在这个术语中,“函数成员”不仅包含方法,而且也包含类或结构的一些非数据成员。它包括索引器、运算符、构造函数和析构函数等,甚至还有属性。这些都不是数据成员,字段、常量和事件才是数据成员。本章将详细讨论方法。

(1) 方法的声明

在C#中,定义方法的语法与C风格的语言相同,与C++和Java中的语法也相同。与C++的主要语法区别是,在C#中,每个方法都单独声明为public或private,不能使用public:块把几个方法定义组合起来。另外,所有的C#方法都在类定义中声明和定义。在C#中,不能像在C++中那样把方法的实现代码分隔开来。

在C#中,方法的定义包括方法的修饰符(例如方法的可访问性)、返回值的类型,然后是方法名、输入参数的列表(用圆括号括起来)和方法体(用花括号括起来)。

[modifiers] return_type MethodName([parameters])

{

   // Method body

}

每个参数都包括参数的类型名及在方法体中的引用名称。但如果方法有返回值,return语句就必须与返回值一起使用,以指定出口点,例如:

public bool IsSquare(Rectangle rect)

{

   return (rect.Height == rect.Width);

}

这段代码使用一个表示矩形的.NET基类System.Drawing.Rectangle。

如果方法没有返回值,就把返回类型指定为void,因为不能省略返回类型。如果方法不带参数,仍需要在方法名的后面写上一对空的圆括号()(就像本章前面的Main()方法)。此时return语句就是可选的—— 当到达右花括号时,方法会自动返回。注意方法可以包含任意多个return语句:

public bool IsPositive(int value)

{

   if (value < 0)

      return false;

   return true;

}

(2) 调用方法

C#中调用方法的语法与C++和Java中的一样,C#和VB的惟一区别是在C#中调用方法时,必须使用圆括号,这要比VB 6中有时需要括号,有时不需要括号的规则简单一些。

下面的例子MathTest说明了类的定义和实例化、方法的定义和调用的语法。除了包含Main()方法的类之外,它还定义了类MathTest,该类包含两个方法和一个字段。

using System;

namespace Wrox.ProCSharp. MathTestSample

{

   class MainEntryPoint

   {

      static void Main()

      {

         // Try calling some static functions

         Console.WriteLine("Pi is " + MathTest.GetPi());

         int x = MathTest.GetSquareOf(5);

         Console.WriteLine("Square of 5 is " + x);

         // Instantiate at MathTest object

         MathTest math = new MathTest();         // this is C#'s way of

                                             // instantiating a reference type

        

         // Call non-static methods

         math.value = 30;

         Console.WriteLine(

            "Value field of math variable contains " + math.value);

         Console.WriteLine("Square of 30 is " + math.GetSquare());

      }

   }

   // Define a class named MathTest on which we will call a method

   class MathTest

   {

      public int value;

      public int GetSquare()

      {

         return value*value;

      }

      public static int GetSquareOf(int x)

      {

         return x*x;

      }

      public static double GetPi()

      {

         return 3.14159;

      }

   }

}

运行mathTest示例,会得到如下结果:

csc MathTest.cs

Microsoft (R) Visual C# .NET Compiler version 8.00.40607.16

for Microsoft (R) .NET Framework version 2.0.40607

Copyright (C) Microsoft Corporation 2001-2003. All rights reserved.

MathTest.exe

Pi is 3.14159

Square of 5 is 25

Value field of math variable contains 30

Square of 30 is 900

从代码中可以看出,MathTest类包含一个字段和一个方法,该字段包含一个数字,该方法计算数字的平方。这个类还包含两个静态方法,一个返回pi的值,另一个计算把作为参数传入的数字的平方。

这个类有一些功能并不是C#程序设计的好例子。例如,GetPi()通常作为const字段来执行,而好的设计应使用目前还没有介绍的概念。

C++和Java开发人员应很熟悉这个例子的大多数语法。如果您有VB的编程经验,只需把MathTest类看作一个执行字段和方法的VB类模块。但无论使用什么语言,都要注意两个要点。

(3) 给方法传递参数

参数可以通过引用或值传递给方法。在变量通过引用传递给方法时,被调用的方法得到的就是这个变量,所以在方法内部对变量进行的任何改变在方法退出后仍旧发挥作用。而如果变量是通过值传送给方法的,被调用的方法得到的是变量的一个副本,也就是说,在方法退出后,对变量进行的修改会丢失。对于复杂的数据类型,按引用传递的效率更高,因为在按值传递时,必须复制大量的数据。

在C#中,所有的参数都是通过值来传递的,除非特别说明。这与C++是相同的,但与VB相反。但是,在理解引用类型的传递过程时需要注意。因为引用类型的对象只包含对象的引用,它们只给方法传递这个引用,而不是对象本身,所以对底层对象的修改会保留下来。相反,值类型的对象包含的是实际数据,所以传递给方法的是数据本身的副本。例如,int通过值传递给方法,方法对该int的值所作的任何改变都没有改变原int对象的值。但如果数组或其他引用类型(如类)传递给方法后,方法会使用该引用改变这个数组中的值,而新值会反射到原来的数组对象上。

下面的例子ParameterTest.cs说明了这一点:

using System;

namespace Wrox.ProCSharp. ParameterTestSample

{

   class ParameterTest

   {

      static void SomeFunction(int[] ints, int i)

      {

         ints[0] = 100;

         i = 100;

      }

  

      public static int Main()

      {

         int i = 0;

         int[] ints = { 0, 1, 2, 4, 8 };

         // Display the original values

         Console.WriteLine("i = " + i);

         Console.WriteLine("ints[0] = " + ints[0]);

         Console.WriteLine("Calling SomeFunction...");

         // After this method returns, ints will be changed,

         // but i will not

         SomeFunction(ints, i);

         Console.WriteLine("i = " + i);

         Console.WriteLine("ints[0] = " + ints[0]);

         return 0;

      }

   }

}

结果如下:

csc ParameterTest.cs

Microsoft (R) Visual C# .NET Compiler version 8.00.40607.16

for Microsoft (R) .NET Framework version 2.0.40607

Copyright (C) Microsoft Corporation 2001-2003. All rights reserved.

ParameterTest

i = 0

ints[0] = 0

Calling SomeFunction...

i = 0

ints[0] = 100

注意,i的值保持不变,而在ints中改变的值在原来的数组中也改变了。

注意字符串是不同的,因为字符串是不能改变的(如果改变字符串的值,就会创建一个全新的字符串),所以字符串无法显示一般引用类型的行为方式。在方法调用中,对字符串所作的任何改变都不会影响原来的字符串。这一点将在第8章详细讨论。

(4)ref参数

通过值传送变量是默认的,也可以迫使值参数通过引用传送给方法。为此,要使用ref关键字。如果把一个参数传递给方法,且这个方法的输入参数前带有ref关键字,则该方法对变量所作的任何改变都会影响原来对象的值:

static void SomeFunction(int[] ints, ref int i)

{

   ints[0] = 100;

   i = 100;       //the change to i will persist after SomeFunction() exits

}

在调用该方法时,还需要添加ref关键字:

SomeFunction(ints, ref i);

在C#中添加ref关键字等同于在C++中使用&语法指定按引用传递参数。但是,C#在调用方法时要求使用ref关键字,使操作更明确(因此有助于防止错误)。

最后,C#仍要求对传递给方法的参数进行初始化,理解这一点也是非常重要的。在传递给方法之前,无论是按值传递,还是按引用传递,都必须初始化变量。

(5)out关键字

在C风格的语言中,函数能从一个例程中输出多个值,这种情况很常见。这是使用输出参数实现的——方法是把输出值赋给通过引用传递给方法的变量。通常,变量通过引用传送的初值是不重要的,这些值会被函数重写,函数甚至从来没有使用过它们。

如果可以在C#中使用这种约定,就会非常方便。但C#要求变量在被引用前必须用一个初值进行初始化。在把输入变量传递给函数前,可以用没有意义的值初始化它们,函数将使用真实、有意义的值初始化它们,这样做是没有必要的,有时甚至会引起混乱。但有一种方法能够简化C#编译器所坚持的输入参数的初始化。

编译器使用out关键字来初始化。当在方法的输入参数前面加上out关键字时,传递给该方法的变量可以不初始化。该变量通过引用传送,所以在从被调用的方法中返回时,方法对该变量进行的任何改变都会保留下来。在调用该方法时,还需要使用out关键字,这正如在定义该方法时一样:

static void SomeFunction(out int i)

{

   i = 100;

}

public static int Main()

{

   int i; // note how i is declared but not initialized

   SomeFunction(out i);

   Console.WriteLine(i);

   return 0;

}

out关键字是C#中的新增内容,在VB和C++中没有对应的关键字,该关键字的引入使C#更安全,更不容易出错。如果在函数体中没有给out参数分配一个值,该方法就不能编译。

(6) 方法的重载

C#支持方法的重载——方法的几个有不同签名(名称、参数个数、参数类型)的版本,但不支持C++或VB中的默认参数。为了重载方法,只需声明同名但参数个数或类型不同的方法即可:

class ResultDisplayer

{

   void DisplayResult(string result)

   {

      // implementation

   }

   void DisplayResult(int result)

   {

      // implementation

   }

}

因为C#不直接支持可选参数,所以需要使用方法重载来达到此目的:

class MyClass

{

   int DoSomething(int x)   // want 2nd parameter with default value 10

   {

      DoSomething(x, 10);

   }

   int DoSomething(int x, int y)

   {

      // implementation

   }

}

在任何语言中,对于方法重载来说,如果调用了错误的重载方法,就有可能出现运行错误。第4章将讨论如何使代码避免这些错误。现在,知道C#在重载方法的参数方面有一些小区别即可:

       两个方法不能仅在返回类型上有区别。

       两个方法不能仅根据参数是声明为ref还是out来区分。

2. 属性

属性(property)不太常见,因为它们表示的概念是C#从VB中提取的,而不是从C++/Java中提取的。属性的概念是:它是一个方法或一对方法,在客户机代码看来,它们是一个字段。例如Windows窗体的Height属性。假定有下面的代码:

// mainForm is of type System.Windows.Form

mainForm.Height = 400;

执行这段代码,窗口的高度设置为400,因此窗口会在屏幕上重新设置大小。在语法上,上面的代码类似于设置一个字段,但实际上是调用了属性访问器,它包含的代码重新设置了窗体的大小。

在C#中定义属性,可以使用下面的语法:

public string SomeProperty

{

   get

   {

      return "This is the property value";

   }

   set

   {

      // do whatever needs to be done to set the property

   }

}

get访问器不带参数,且必须返回属性声明的类型。也不应为set访问器指定任何显式参数,但编译器假定它带一个参数,其类型也与属性相同,并表示为value。例如,下面的代码包含一个属性ForeName,它设置了一个字段foreName,该字段有一个长度限制。

private string foreName;

public string ForeName

{

   get

   {

      return foreName;

   }

   set

   {

      if (value.Length > 20)

         // code here to take error recovery action

         // (eg. throw an exception)

      else

         foreName = value;

   }

}

注意这里的命名模式。我们采用C#的区分大小写模式,使用相同的名称,但公共属性采用Pascal大小写命名规则,而私有属性采用camel大小写命名规则。一些开发人员喜欢使用前面有下划线的字段名_foreName,这会为识别字段提供极大的便利。

VB6程序员应注意,C#不区分VB6中的Set和Let,在C#中,写入访问器总是用关键字set标识。

(1) 只读和只写属性

在属性定义中省略set访问器,就可以创建只读属性。因此,把上面例子中的ForeName变成只读属性:

private string foreName;

public string ForeName

{

   get

   {

      return foreName;

   }

}

同样,在属性定义中省略get访问器,就可以创建只写属性。但是,这是不好的编程方式,因为这可能会使客户机代码的作者感到迷惑。一般情况下,如果要这么做,最好使用一个方法替代。

(2) 属性的访问修饰符

C#允许给属性的get和set 访问器设置不同的访问修饰符,所以属性可以有公共的get访问器和私有或受保护的set访问器。这有助于控制属性的设置方式或时间。在下面的例子中,注意set访问器有一个私有访问修饰符,而get访问器没有任何访问修饰符。这表示get访问器具有属性的访问级别。在get 和 set 访问器中,必须有一个具备属性的访问级别。如果get访问器的访问级别是protected,就会产生一个编译错误,因为这会使两个访问器的访问级别都不是属性。

public string Name

{

   get

   {

      return _name;

   }

   set

   {

      _name = value;

   }

}

(3) 内联

一些开发人员可能会担心,在上一节中,我们列举了标准C#编码方式导致了非常小的函数的许多情形,例如通过属性访问字段,而不是直接访问字段。这些额外的函数调用是否会增加系统开销,导致性能下降?其实,不需要担心这种编程方式会在C#中带来性能损失。C#代码会编译为IL,然后在运行期间进行正常的JIT编译,获得内部可执行代码。JIT编译器可生成高度优化的代码,并在适当的时候内联代码(即用内联代码来替代函数调用)。如果某个方法或属性的执行代码仅是调用另一个方法,或返回一个字段,则该方法或属性肯定是内联的。但要注意,在何处内联代码的决定完全由CLR做出。我们无法使用像C++中inline这样的关键字来控制哪些方法是内联的。

3. 构造函数

在C#中声明基本构造函数的语法与在Java 和C++中相同。下面声明一个与包含的类同名的方法,但该方法没有返回类型:

public class MyClass

{

   public MyClass()

   {

   }

   // rest of class definition

与Java 和 C++相同,没有必要给类提供构造函数,在我们的例子中没有提供这样的构造函数。一般情况下,如果没有提供任何构造函数,编译器会在后台创建一个默认的构造函数。这是一个非常基本的构造函数,它只能把所有的成员字段初始化为标准的默认值(例如,引用类型为空引用,数字数据类型为0,bool为false)。这通常就足够了,否则就需要编写自己的构造函数。

注意:

对于C++程序员来说,C#中的基本字段在默认情况下初始化为0,而C++中的基本字段不进行初始化,不需要像C++那样频繁地在C#中编写构造函数。

构造函数的重载遵循与其他方法相同的规则。换言之,可以为构造函数提供任意多的重载,只要它们的签名有明显的区别即可:

   public MyClass()   // zero-parameter constructor

   {

      // construction code

   }

   public MyClass(int number)   // another overload

   {

      // construction code

   }

但注意,如果提供了带参数的构造函数,编译器就不会自动提供默认的构造函数,只有在没有定义任何构造函数时,编译器才会自动提供默认的构造函数。在下面的例子中,因为定义了一个带一个参数的构造函数,所以编译器会假定这是可以使用的惟一构造函数,不会隐式地提供其他构造函数:

public class MyNumber

{

   private int number;

   public MyNumber(int number)  

   {

      this.number = number;

   }

}

上面的代码还说明,一般使用this关键字区分成员字段和同名的参数。如果试图使用无参数的构造函数实例化MyNumber对象,就会得到一个编译错误:

MyNumber numb = new MyNumber();   // causes compilation error

注意,可以把构造函数定义为private或protected,这样不相关的类就不能访问它们:

public class MyNumber

{

   private int number;

   private MyNumber(int number)   // another overload

   {

      this.number = number;

   }

}

在这个例子中,我们并没有为MyNumber定义任何公共或受保护的构造函数。这就使MyNumber不能使用new运算符在外部代码中实例化(但可以在MyNumber上编写一个公共静态属性或方法,以进行实例化)。这在下面两种情况下是有用的:

       类仅用作某些静态成员或属性的容器,因此永远不会实例化。

       希望类仅通过调用某个静态成员函数来实例化(这就是所谓对象实例化的类代理方法)

(1) 静态构造函数

C#的一个新特征是也可以给类编写无参数的静态构造函数。这种构造函数只执行一次,而前面的构造函数是实例构造函数,只要创建类的对象,它都会执行。静态构造函数在C++和VB6中没有对应的函数。

class MyClass

{

   static MyClass()

   {

      // initialization code

   }

   // rest of class definition

}

编写静态构造函数的一个原因是,类有一些静态字段或属性,需要在第一次使用类之前,从外部源中初始化这些静态字段和属性。

.NET运行库没有确保静态构造函数什么时候执行,所以不要把代码放在某个特定的时刻(例如,加载程序集时)执行的静态构造函数中。也不能预计不同类的静态构造函数按照什么顺序执行。但是,可以确保静态构造函数至多运行一次,即在代码引用类之前执行。在C#中,静态构造函数通常在第一次调用类的成员之前执行。

注意,静态构造函数没有访问修饰符,其他C#代码从来不调用它,但在加载类时,总是由.NET运行库调用它,所以像public和private这样的访问修饰符就没有意义了。同样,静态构造函数不能带任何参数,一个类也只能有一个静态构造函数。很显然,静态构造函数只能访问类的静态成员,不能访问实例成员。

注意,无参数的实例构造函数可以在类中与静态构造函数安全共存。尽管参数列表是相同的,但这并不矛盾,因为静态构造函数是在加载类时执行,而实例构造函数是在创建实例时执行,所以构造函数的执行不会有冲突。

如果多个类都有静态构造函数,先执行哪个静态构造函数是不确定的。此时应根据其他静态构造函数的执行情况,在静态构造函数中添加代码。另一方面,如果静态字段有默认值,它们就在调用静态构造函数之前指定。

下面用一个例子来说明静态构造函数的用法。假定这个例子叫StaticConstructorSample,基于包含用户设置的程序(假定存储在某个配置文件中)。为了简单一些,假定只有一个用户设置—— BackColor,表示要在应用程序中使用的背景色。因为这里不想编写从外部数据源中读取数据的代码,所以假定该设置在工作日的背景色是红色,在周末的背景色是绿色。程序仅在控制台窗口中显示设置—— 但这足以说明静态构造函数是如何工作的。

namespace Wrox.ProCSharp.StaticConstructorSample

{

   public class UserPreferences

   {

      public static readonly Color BackColor;

      static UserPreferences()

      {

         DateTime now = DateTime.Now;

         if (now.DayOfWeek == DayOfWeek.Saturday

            || now.DayOfWeek == DayOfWeek.Sunday)

            BackColor = Color.Green;

         else

            BackColor = Color.Red;

      }

      private UserPreferences()

      {

      }

   }

}

这段代码说明了颜色设置如何存储在静态变量中,该静态变量在静态构造函数中进行初始化。把这个字段声明为只读类型,表示其值只能在构造函数中设置。本章后面将详细介绍只读字段。这段代码使用了Microsoft在Framework类库中支持的两个有用的结构System.DateTime和System.Drawing.Color。DateTime结构实现了静态属性Now和实例属性DayOfWeek,Now属性返回当前的时间,DayOfWeek属性可以计算出某个日期是星期几。Color(详见第25章)用于存储颜色,它实现了各种静态属性,例如本例使用的Red和Green,返回常用的颜色。为了使用Color结构,需要在编译时引用System.Drawing.dll程序集,且必须为System.Drawing命名空间添加一个using语句:

using System;

using System.Drawing;

用下面的代码测试静态构造函数:

   class MainEntryPoint

   {

      static void Main(string[] args)

      {

         Console.WriteLine("User-preferences: BackColor is: " +

                            UserPreferences.BackColor.ToString());

      }

   }

编译并运行这段代码,会得到如下结果:

C:>StaticConstructor

User-preferences: BackColor is: Color [Red]

(2) 从其他构造函数中调用构造函数

有时,在一个类中有几个构造函数,以容纳某些可选参数,这些构造函数都包含一些共同的代码。例如,下面的情况:

class Car

{

   private string description;

   private uint nWheels;

   public Car(string model, uint nWheels)

   {

      this.description = description;

      this.nWheels = nWheels;

   }

   public Car(string model)

   {

      this.description = description;

      this.nWheels = 4;

   }

// etc.

这两个构造函数初始化了相同的字段,显然,最好把所有的代码放在一个地方。C#有一个特殊的语法,称为构造函数初始化器,可以实现此目的:

class Car

{

   private string description;

   private uint nWheels;

   public Car(string model, uint nWheels)

   {

      this.description = description;

      this.nWheels = nWheels;

   }

   public Car(string model) : this(model, 4)

   {

   }

   // etc  

这里,this关键字仅调用参数最匹配的那个构造函数。注意,构造函数初始化器在构造函数之前执行。现在假定运行下面的代码:

Car myCar = new Car("Proton Persona");

在本例中,在带一个参数的构造函数执行之前,先执行带2个参数的构造函数(但在本例中,因为带一个参数的构造函数没有代码,所以没有区别)。

C#构造函数初始化符可以包含对同一个类的另一个构造函数的调用(使用前面介绍的语法),也可以包含对直接基类的构造函数的调用(使用相同的语法,但应使用base关键字代替this)。初始化符中不能有多于一个的调用。

在C#中,构造函数初始化符的语法类似于C++中的构造函数初始化列表,但C++开发人员要注意,除了语法类似之外,C#初始化符所包含的代码遵循完全不同的规则。可以使用C++初始化列表指定成员变量的初始值,或调用基类构造函数,而C#初始化符中的代码只能调用另一个构造函数。这就要求C#类在构造时遵循严格的顺序,但C++就没有这个要求。这个问题详见第4章,那时就会看到,C#强制遵循的顺序只不过是良好的编程习惯而已。

3.2.3  只读字段

常量的概念就是一个包含不能修改的值的变量,常量是C#与大多数编程语言共有的。但是,常量不必满足所有的要求。有时可能需要一些变量,其值不应改变,但在运行之前其值是未知的。C#为这种情形提供了另一个类型的变量:只读字段。

readonly关键字比const灵活得多,允许把一个字段设置为常量,但可以执行一些运算,以确定它的初始值。其规则是可以在构造函数中给只读字段赋值,但不能在其他地方赋值,只读字段还可以是一个实例字段,而不是静态字段,类的每个实例可以有不同的值。与const字段不同,如果要把只读字段设置为静态,就必须显式声明。

如果有一个编辑文档的MDI程序,因为要注册,需要限制可以同时打开的文档数。现在假定要销售该软件的不同版本,而且顾客可以升级他们的版本,以便同时打开更多的文档。显然,不能在源代码中对最大文档数进行硬编码。而是需要一个字段表示这个最大文档数。这个字段必须是只读的——每次安装程序时,从注册表键或其他文件存储中读取。代码如下所示:

   public class DocumentEditor

   {

      public static readonly uint MaxDocuments;

      static DocumentEditor()

      {

         MaxDocuments = DosomethingToFindOutMaxNumber();

      }

在本例中,字段是静态的,因为每次运行程序的实例时,只需存储最大文档数一次。这就是在静态构造函数中初始化它的原因。如果只读字段是一个实例字段,就要在实例构造函数中初始化它。例如,假定编辑的每个文档都有一个创建日期,但不允许用户修改它(因为这会覆盖过去的日期)。注意,该字段也是公共的,我们不需要把只读字段设置为私有,因为按照定义,它们不能在外部修改(这个规则也适用于常量)。

如前所述,日期用基类System.DateTime表示。下面的代码使用带有3个参数(年份、月份和月份中的日)的System.DateTime构造函数,可以从MSDN文档中找到这个构造函数和其他DateTime构造函数的更多信息。

   public class Document

   {

      public readonly DateTime CreationDate;

      public Document()

      {

         // read in creation date from file. Assume result is 1 Jan 2002

         // but in general this can be different for different instances

         // of the class 

         CreationDate = new DateTime(2002, 1, 1);

         }

      }

在上面的代码中,CreationDate和MaxDocuments的处理方式与其他字段相同,但因为它们是只读的,所以不能在构造函数外部赋值:

void SomeMethod()

{

   MaxDocuments = 10;       // compilation error here. MaxDocuments is readonly

}

还要注意,在构造函数中不必给只读字段赋值,如果没有赋值,它的值就是其数据类型的默认值,或者在声明时给它初始化的值。这适用于静态和实例只读字段。

3.3  结构

前面介绍了类如何封装程序中的对象,也介绍了如何将它们保存在堆中,通过这种方式可以在数据的生存期上获得很大的灵活性,但性能会有一定的损失。因托管堆的优化,这种性能损失比较小。但是,有时仅需要一个小的数据结构。此时,类提供的功能多于我们需要的功能,由于性能的原因,最好使用结构。看看下面的例子:

class Dimensions

{

   public double Length;

   public double Width;

}

上面的示例定义了类Dimensions,它只存储了一个项的长度和宽度,也许可以编写一个安排设备的程序,让人们试着重新安排计算机上的设备,并存储每个设备项的维数。看起来这样就会违背编程规则,使字段变为公共字段,但我们实际上并不需要类的全部功能。现在只有两个数字,把它们当作一对来处理,要比单个处理方便一些。既不需要很多方法,也不需要从类中继承,也不希望.NET运行库在堆中遇到麻烦和性能问题,只需存储两个double类型的数据即可。

如本章前面所述,为此,只需修改代码,用关键字struct代替class,定义一个结构而不     是类:

   struct Dimensions

   {

      public double Length;

      public double Width;

   }

为结构定义函数与为类定义函数完全相同。下面的代码演示了结构的构造函数和属性:

   struct Dimensions

   {

      public double Length;

      public double Width;

      Dimensions(double length, double width)

      { Length= length; Width= width; }

      public int Diagonal

      {

         {

            get

            {

                return Math.Sqrt(Length* Length + Width* Width);

             }

         }

       }

   }

在许多方面,可以把C#中的结构看作是缩小的类。它们基本上与类相同,但更适合于把一些数据组合起来的场合。它们与类的区别在于:

       结构是值类型,不是引用类型。它们存储在堆栈中或存储为内联(inline)(如果它们是另一个对象的一部分,就会保存在堆中),其生存期的限制与简单的数据类型一样。

       结构不支持继承。

       结构的构造函数的工作方式有一些区别。尤其是编译器总是提供一个无参数的默认构造函数,这是不允许替换的。

       使用结构,可以指定字段如何在内存中布局(11章在介绍属性时将详细论述这个问题)

因为结构实际上是把数据项组合在一起,有时大多数甚至全部字段都声明为public。严格说来,这与编写.NET代码的规则相背—— 根据Microsoft,字段(除了const字段之外)应总是私有的,并由公共属性封装。但是,对于简单的结构,许多开发人员都认为公共字段是可接受的编程方式。

注意:

C++开发人员要注意,C#中的结构在实现方式上与类大不相同。这与C++的情形完全不同,在C++中,类和结构是相同的对象。

下面将详细说明类和结构之间的区别。

3.3.1  结构是值类型

虽然结构是值类型,但在语法上常常可以把它们当作类来处理。例如,在上面的Dimensions类的定义中,可以编写下面的代码:

   Dimensions point = new Dimensions();

   point.Length = 3;

   point.Width = 6;

注意,因为结构是值类型,所以new运算符与类和其他引用类型的工作方式不同。new运算符并不分配堆中的内存,而是调用相应的构造函数,根据传送给它的参数,初始化所有的字段。对于结构,可以编写下述代码:

   Dimensions point;

   point.Length = 3;

   point.Width = 6;

如果Dimensions是一个类,就会产生一个编译错误,因为point包含一个未初始化的引用——不指向任何地方的一个地址,所以不能给其字段设置值。但对于结构,变量声明实际上是为整个结构分配堆栈中的空间,所以就可以赋值了。但要注意下面的代码会产生一个编译错误,编译器会抱怨用户使用了未初始化的变量:

   Dimensions point;

   Double D = point.Length;

结构遵循其他数据类型都遵循的规则:在使用前所有的元素都必须进行初始化。在结构上调用new运算符,或者给所有的字段分别赋值,结构就可以完全初始化了。当然,如果结构定义为类的成员字段,在初始化包含对象时,该结构会自动初始化为0。

结构是值类型,所以会影响性能,但根据使用结构的方式,这种影响可能是正面的,也可能是负面的。正面的影响是为结构分配内存时,速度非常快,因为它们将内联或者保存在堆栈中。在结构超出了作用域被删除时,速度也很快。另一方面,只要把结构作为参数来传递或者把一个结构赋给另一个结构(例如A=B,其中A和B是结构),结构的所有内容就被复制,而对于类,则只复制引用。这样,就会有性能损失,根据结构的大小,性能损失也不同。注意,结构主要用于小的数据结构。但当把结构作为参数传递给方法时,就应把它作为ref参数传递,以避免性能损失——此时只传递了结构在内存中的地址,这样传递速度就与在类中的传递速度一样快了。另一方面,如果这样做,就必须注意被调用的方法可以改变结构的值。

3.3.2  结构和继承

结构不是为继承设计的。不能从一个结构中继承,惟一的例外是结构(和C#中的其他类型一样)派生于类System.Object。因此,结构也可以访问System.Object的方法。在结构中,甚至可以重写System.Object中的方法—— 例如重写ToString()方法。结构的继承链是:每个结构派生于System.ValueType,System.ValueType派生于System.Object。ValueType并没有给Object添加任何新成员,但提供了一些更适合结构的执行代码。注意,不能为结构提供其他基类:每个结构都派生于ValueType。

3.3.3  结构的构造函数

为结构定义构造函数的方式与为类定义构造函数的方式相同,但不允许定义无参数的构造函数。这看起来似乎没有意义,其原因隐藏在.NET运行库的执行方式中。下述情况非常少见:.NET运行库不能调用用户提供的定制无参数构造函数,因此Microsoft采用一种非常简单的方式,禁止在C#中的结构内使用无参数的构造函数。

前面说过,默认构造函数把所有的字段都初始化为0,且总是隐式地给出,即使提供了其他带参数的构造函数,也是如此。也不能提供字段的初始值,以此绕过默认构造函数。下面的代码会产生编译错误:

   struct Dimensions

   {

      public double Length = 1;       // error. Initial values not allowed

      public double Width = 2;        // error. Initial values not allowed

当然,如果Dimensions声明为一个类,这段代码就不会有编译错误。

另外,可以像类那样为结构提供Close()或Dispose()方法。

3.4  部分类

partial关键字允许把类、结构或接口放在多个文件中。一般情况下,一个类存储在单个文件中。但有时,多个开发人员需要访问同一个类,或者某种类型的代码生成器生成了一个类的某部分,所以把类放在多个文件中是有益的。

partial关键字的用法是:把partial放在类、结构或接口的前面。在下面的例子中,TheBigClass类位于两个不同的源文件BigClassPart1.cs和BigClassPart2.cs中:

//BigClassPart1.cs

partial class TheBigClass

{

  public void MethodOne()

  {

  }

}

//BigClassPart2.cs

partial class TheBigClass

{

  public void MethodTwo()

  {

  }

}

编译包含这两个源文件的项目时,会创建一个TheBigClass类,它有两个方法MethodOne() 和MethodTwo()。

如果声明类时使用了下面的关键字,这些关键字将应用于同一个类的所有部分:

    public

    private

    protected

    internal

    abstract

    sealed

    基类

    new

    一般约束

在嵌套的类型中,只要partial关键字位于class关键字的前面,就可以嵌套部分类。在把部分类编译到类型中时,会合并属性、XML注释、接口、一般类型的参数属性和成员。有如下两个源文件:

//BigClassPart1.cs

[CustomAttribute]

partial class TheBigClass : TheBigBaseClass, IBigClass

{

  public void MethodOne()

  {

  }

}

//BigClassPart2.cs

[AnotherAttribute]

partial class TheBigClass : IOtherBigClass

{

  public void MethodTwo()

  {

  }

}

编译后,源文件变成:

[CustomAttribute]

[AnotherAttribute]

partial class TheBigClass : TheBigBaseClass, IBigClass, IOtherBigClass

{

  public void MethodOne()

  {

  }

  public void MethodTwo()

  {

  }

}

3.5  静态类

本章前面讨论了静态构造函数,它们可以初始化静态的成员变量。如果类只包含静态的方法和属性,该类就可以是静态的。静态类在功能上与使用私有静态构造函数创建的类相同。不能创建静态类的实例。使用static关键字,编译器可以检查以后是否给该类添加了实例成员。如果是,就生成一个编译错误。这可以确保不创建静态类的实例。静态类的语法如下所示:

static class StaticUtilities

{

  public static void HelperMethod()

  {

  }

}

调用HelperMethod()不需要StaticUtilities类型的对象。使用类型名即可进行该调用:

StaticUtilities.HelperMethod();

3.6  Object

前面提到,所有的.NET类都派生于System.Object。实际上,如果在定义类时没有指定基类,编译器就会自动假定这个类派生于Object。本章没有使用继承,所以前面介绍的每个类都派生于System.Object(如前所述,对于结构,这个派生是间接的:结构总是派生于System.ValueType,System.ValueType派生于System.Object)

其重要性在于,除了自己定义的方法和属性外,还可以访问为Object定义的许多公共或受保护的成员方法。这些方法可以用于自己定义的所有其他类中。

3.6.1  System.Object方法

Object中定义的方法如表3-1所示。

  3-1

   

访问修饰符

   

string ToString()

public virtual

返回对象的字符串表示

int GetHashTable()

public virtual

在实现字典(散列表)时使用

bool Equals(object obj)

public virtual

对对象的实例进行相等比较

bool Equals(object objA,object objB)

public static

对对象的实例进行相等比较

bool ReferenceEquals(object objA, object objB)

public static

比较两个引用是否指向同一个对象

Type GetType()

public

返回对象类型的详细信息

object MemberwiseClone()

protected

进行对象的浅表复制

void Finalize()

protected virtual

该方法是析构函数的.NET版本

我们还没有完整地介绍C#语言,所以用户还不能理解使用这些方法的方式。下面将简要总结每个方法的作用,但ToString()方法要详细论述。

       ToString()方法:是获取对象的字符串表示的一种便捷方式。当只需要快速获取对象的内容,以用于调试时就可以使用这个方法。在数据的格式化方面,它提供的选择非常少:例如,日期在原则上可以表示为许多不同的格式,但DateTime.ToString()没有在这方面提供任何选择。如果需要更专业的字符串表示,例如考虑用户的格式化配置或文化(区域),就应实现IFormattable接口(详见第8)

       GetHashTable()方法:如果对象放在名为映射(也称为散列表或字典)的数据结构中,就可以使用这个方法。处理这些结构的类使用该方法确定把对象放在结构的什么地方。如果希望把类用作字典的一个键,就需要重写GetHashTable()方法。对该方法重载的执行方式有一些相当严格的限制,这些将在第9章介绍字典时讨论。

       Equals()(两个版本)ReferenceEquals()方法:如果把3个用于比较对象相等性的不同方法组合起来,就说明.NET Framework在比较相等性方面有相当复杂的模式。这3个方法和比较运算符==在使用方式上有微妙的区别。而且,在重写带一个参数的虚拟Equals()方法时也有一些限制,因为System.Collections命名空间中的一些基类要调用该方法,并希望它以特定的方式执行。第5章在介绍运算符时将探讨这些方法的使用。

       Finalize()方法:第7章将介绍这个方法,它最接近C++风格的析构函数,在引用对象被回收,以清理资源时调用。Finalize()方法的Object执行代码实际上什么也没有做,因而被垃圾收集器忽略。如果对象拥有对未托管资源的引用,则在该对象被删除时,就需要删除这些引用,此时一般要重写Finalize()。垃圾收集器不能直接重写该方法,因为它只负责托管的资源,只能依赖用户提供的Finalize()

       GetType()方法:这个方法返回从System.Type派生的类的一个实例。这个对象可以提供对象所属类的更多信息,包括基本类型、方法、属性等。System.Type还提供了.NET反射技术的入口。这个主题详见第11章。

       MemberwiseClone()方法:这是System.Object中惟一没有在本书的其他地方详细论述的方法。不需要讨论这个方法,因为它在概念上相当简单,只是复制对象,返回一个对副本的引用(对于值类型,就是一个装箱的引用)。注意,得到的副本是一个简单复制,即它复制了类中的所有值类型。如果类包含内嵌的引用,就只复制引用,而不复制引用的对象。这个方法是受保护的,所以不能用于复制外部的对象。该方法不是虚拟的,所以不能重写它的实现代码。

3.6.2  ToString()方法

第2章已经提到了ToString()方法,它是快速获取对象的字符串表示的最便捷的方式。

例如:

int i = 50;

string str = i.ToString();  // returns "50"

下面是另一个例子:

enum Colors {Red, Orange, Yellow};

// later on in code...

Colors favoriteColor = Colors.Orange;

string str = favoriteColor.ToString();                 // returns "Orange"

Object.ToString()声明为虚类型,在这些例子中,该方法的实现代码都是为C#预定义数据类型重写过的代码,以返回这些类型的正确字符串表示。Colors枚举是一个预定义的数据类型,它实际上实现为一个派生于System.Enum的结构,而System.Enum有一个相当聪明的ToString()重写方法,来处理用户定义的所有枚举。

如果不在自己定义的类中重写ToString(),该类将只继承System.Object执行方式—— 显示类的名称。如果希望ToString()返回一个字符串,其中包含类中对象的值信息,就需要重写它。下面用一个例子Money来说明这一点。在该例子中,定义一个非常简单的类Money,表示美元数。Money是decimal类的包装器,提供了一个ToString()方法。注意,这个方法必须声明为override,因为它将替代(重写)Object提供的ToString()方法。第4章将详细讨论重写。该例子的完整代码如下所示(注意它还说明了如何使用属性封装字段):

using System;

namespace Wrox.ProCSharp.OOCSharp

{

   class MainEntryPoint

   {

      static void Main(string[] args)

      {

         Money cash1 = new Money();

         cash1.Amount = 40M;

         Console.WriteLine("cash1.ToString() returns: " + cash1.ToString());

         Console.ReadLine();

      }

   }

   class Money

   {

      private decimal amount;

      public decimal Amount

      {

         get

         {

            return amount;

         }

         set

         {

            amount = value;

         }

      }

      public override string ToString()

      {

         return "$" + Amount.ToString();

      }

   }

}

这个例子仅说明了C#的语法特性。C#已经有表示货币的预定义类型decimal。所以在现实生活中,不必编写这样的类来重复该功能,除非要给它添加其他方法。在许多情况下,由于格式化要求,也可以使用String.Format()方法(详见第8章)格式化货币字符串,而不是ToString()。

在Main()方法中,先实例化一个Money对象,再实例化BetterMonny对象。在这两个实例化过程中都调用了ToString()。对于Money对象,选择了该方法的Object版本,显示类的信息。对于BetterMonny对象,选择了我们自己的重写方法。运行这段代码,会得到如下结果:

StringRepresentations

cash1.ToString() returns: $40

3.7  小结

本章介绍了C#中声明和处理对象的语法,论述了如何声明静态和实例字段、属性、方法和构造函数。还讨论了C#中新增的、其他语言的OOP模型中没有的新特性:静态构造函数提供了初始化静态字段的方式,利用结构可以定义高性能的类型,遵循更严格的特性集,不需要使用托管的堆。我们还阐述了C#中的所有类型最终都派生于类System.Object,这说明所有的类型都拥有一组基本的方法,包括ToString()。

第4章将介绍C#中的实现(implementation)继承和接口继承。

4继承

第3章介绍了如何使用C#中的各个类,其重点是如何定义方法、构造函数、属性和单个类(或单个结构)中的其他成员。我们指出,所有的类最终都派生于System.Object类,但并没有说明如何创建继承类的层次结构。继承是本章的主题。我们将简要讨论C#对继承的支持,然后详细论述如何在C#中编码实现(implementation)继承和接口继承。注意,本章假定您已经熟悉了继承的基本概念,包括虚函数和重写。我们将重点阐述用于提供继承的语法和与继承相关的主题,例如虚函数,C#继承模型的其他方面是C#所特有的,其他面向对象的语言都不具备。

4.1  继承的类型

首先介绍C#在继承方面支持和不支持的功能。

4.1.1  实现继承和接口继承

面向对象编程的开发人员知道,有两种截然不同的继承类型:实现继承和接口继承。

       实现继承:表示一个类型派生于一个基类型,拥有该基类型的所有成员字段和函数。在实现继承中,派生类型的每个函数采用基类型的实现代码,除非在派生类型的定义中指定重写该函数的实现代码。在需要给现有的类型添加功能,或许多相关的类型共享一组重要的公共功能时,这种类型的继承是非常有效的。例如第23章讨论的Windows Forms类。第23章也讨论了基类System.Windows.Forms.Control,该类提供了常用Windows控件的非常复杂的实现代码,第23章还讨论了许多其他的类,例如System. Windows.Forms.TextBoxSystem.Windows.Forms.ListBox,这两个类派生于Control,并重写了函数,或提供了新的函数,以实现特定类型的控件。

       接口继承:表示一个类型只继承了函数的签名,没有继承任何实现代码。在需要指定该类型具有某些可用的特性时,最好使用这种类型的继承。例如,某些类型可以指定从接口System.IDisposable(详见第7)中派生,从而提供一种清理资源的方法Dispose()。由于某种类型清理资源的方式可能与另一种类型的完全不同,所以定义通用的实现代码是没有意义的,此时就适合使用接口继承。接口继承常常被看做提供了一种契约:让类型派生于接口,来保证为客户提供某个功能。

在传统上,像C++这样的语言在实现继承方面的功能非常强大,实际上,实现继承是C++编程模型的核心。另一方面,VB6不支持类的任何实现继承,但因其底层的COM基础体系,所以它支持接口继承。

在C#中,既有实现继承,也有接口继承。它们没有强弱之分,因为这两种继承都完全内置于语言中,因此很容易为不同的情形选择最好的体系结构。

4.1.2  多重继承

一些语言如C++支持所谓的“多重继承”,即一个类派生于多个类。使用多重继承的优点是有争议的:一方面,毫无疑问,可以使用多重继承编写非常复杂、但很紧凑的代码,如C++ ATL库。另一方面,使用多重实现继承的代码常常很难理解和调试(这也可以从C++ ATL库中看出)。如前所述,使健壮代码的编写容易一些,是开发C#的重要设计目标。因此,C#不支持多重实现继承。而C#又允许类型派生于多个接口。这说明,C#类可以派生于另一个类和任意多个接口。因为System.Object是一个公共的基类,所以每个C#类(除了Object类之外)都有一个基类,还可以有任意多个基接口。

4.1.3  结构和类

第3章区分了结构(值类型)和类(引用类型)。使用结构的一个限制是结构不支持继承,但每个结构都自动派生于System.ValueType。实际上还应更仔细一些:不能建立结构的类型层次,但结构可以实现接口。换言之,结构并不支持实现继承,但支持接口继承。事实上,定义结构和类可以总结为:

       结构总是派生于System.ValueType,它们还可以派生于任意多个接口。

       类总是派生于用户选择的另一个类,它们还可以派生于任意多个接口。

4.2  实现继承

如果要声明一个类派生于另一个类,可以使用下面的语法:

class MyDerivedClass : MyBaseClass

{

   // functions and data members here

}

注意:

这个语法非常类似于C++Java中的语法,但是,C++程序员习惯于使用公共和私有继承的概念,要注意C#不支持私有继承,因此基类名上没有publicprivate限定符。支持私有继承会大大增加语言的复杂性,实际上私有继承在C++中也很少使用。

如果类(或结构)也派生于接口,则用逗号分隔开基类和接口:

public class MyDerivedClass : MyBaseClass, IInterface1, IInterface2

{

//etc.

对于结构,语法如下:

public struct MyDerivedStruct : IInterface1, IInterface2

{

//etc.

如果在类定义中没有指定基类,C#编译器就假定System.Object是基类。因此下面的两段代码生成相同的结果:

class MyClass : Object   //derives from System.Object

{

   //etc.

}

class MyClass   //derives from System.Object

{

   //etc.

}

第二种形式比较常用,因为它较简单。

C#支持object关键字,它用作System.Object类的假名,所以也可以编写下面的代码:

class MyClass : object   //derives from System.Object

{

   //etc.

}

如果要引用Object类,可以使用object关键字,智能编辑器(如VS2005)会识别它,因此便于编辑代码。

4.2.1  虚方法

把一个基类函数声明为virtual,该函数就可以在任何派生类中重写了:

class MyBaseClass

{

   public virtual string VirtualMethod()

   {

      return "This method is virtual and defined in MyBaseClass";

   }

}

也可以把属性声明为virtual。对于虚属性或重写属性,语法与非虚属性是相同的,但要在定义中加上关键字virtual,其语法如下所示:

public virtual string ForeName

{

   get { return foreName; }

   set { foreName = value; }

}

private string foreName;

为了简单起见,下面的讨论将主要集中于方法,但其规则也适用于属性。

C#中虚函数的概念与标准OOP概念相同:可以在派生类中重写虚函数。在调用方法时,会调用对象类型的合适方法。在C#中,函数在默认情况下不是虚拟的,但(除了构造函数以外)可以显式地声明为virtual。这遵循C++的方式,即从性能的角度来看,除非显式指定,否则函数就不是虚拟的。而在Java中,所有的函数都是虚拟的。但C#的语法与C++的语法不同,因为C#要求在派生类的函数重写另一个函数时,要使用override关键字显式声明:

class MyDerivedClass : MyBaseClass

{

   public override string VirtualMethod()

   {

      return "This method is an override defined in MyDerivedClass";

   }

}

方法重写的语法避免了C++中很容易发生的潜在运行错误:当派生类的方法签名无意中与基类版本略有差别时,派生类方法就不能重写基类方法了。在C#中,这会出现一个编译错误,因为编译器会认为函数已标记为override,但没有重写它的基类方法。

成员字段和静态函数都不能声明为virtual,因为这个概念只对类中的实例函数成员有意义。

4.2.2  隐藏方法

如果签名相同的方法在基类和派生类中都进行了声明,但该方法没有声明为virtual 和 override,派生类方法就会隐藏基类方法。

在大多数情况下,是要重写方法,而不是隐藏方法,因为隐藏方法会存在为给定类的实例调用错误方法的危险。但是,如下面的例子所示,C#语法可以确保开发人员在编译时收到这个潜在错误的警告,使隐藏方法更加安全。这也是类库开发人员得到的版本方面的好处。

假定有人编写了类HisBaseClass:

class HisBaseClass

{

   // various members

}

在将来的某一刻,要编写一个派生类,给HisBaseClass添加某个功能,特别是要添加一个目前基类中没有的方法MyGroovyMethod():

class MyDerivedClass: HisBaseClass

{

   public int MyGroovyMethod()

   {

      // some groovy implementation

      return 0;

   }

}

一年后,基类的编写者决定扩展基类的功能。为了保持一致,他也添加了一个名为MyGroovyMethod()的方法,该方法的名称和签名与前面添加的方法相同,但并不完成相同的工作。在使用基类的新方法编译代码时,程序在应该调用哪个方法上就会有潜在的冲突。这在C#中完全合法,但因为我们的MyGroovyMethod()与基类的MyGroovyMethod()不相关,运行这段代码的结果就可能不是我们希望的结果。C#已经为此设计了一种方式,可以很好地处理这种情况。

首先,系统会发出警告。在C#中,应使用new关键字声明我们要隐藏一个方法,如下所示:

class MyDerivedClass : HisBaseClass

{

   public new int MyGroovyMethod()

   {

      // some groovy implementation

      return 0;

   }

}

但是,我们的MyGroovyMethod()没有声明为new,所以编译器会认为它隐藏了基类的方法,但没有显式声明,因此发出一个警告(这也适用于把MyGroovyMethod()声明为 virtual)。如果愿意,可以给我们的方法重命名。这么做,是最好的情形,因为这会避免许多冲突。但是,如果觉得重命名方法是不可能的(例如,已经为其他公司把软件发布为一个库,所以无法修改方法的名称),则所有的已有客户机代码仍能正确运行,选择我们的MyGroovyMethod()。这是因为访问这个方法的已有代码必须通过对MyDerivedClass(或进一步派生的类)的引用进行选择。

已有的代码不能通过对HisBaseClass的引用访问这个方法,因为在对HisBaseClass的早期版本进行编译时,会产生一个编译错误。这个问题只会发生在将来编写的客户机代码上。C#会发出一个警告,告诉用户在将来的代码中可能会出问题——用户应注意这个警告,不要试图在将来的代码中通过对HisBaseClass的引用调用MyGroovyMethod()方法,但所有已有的代码仍会正常工作。这是比较微妙的,但很好地说明了C#如何处理类的不同版本。

4.2.3  调用函数的基类版本

C#有一种特殊的语法用于从派生类中调用方法的基类版本:base.<MethodName>()。例如,假定派生类中的一个方法要返回基类的方法返回的值的90%,就可以使用下面的语法:

class CustomerAccount

{

   public virtual decimal CalculatePrice()

   {

      // implementation

      return 0.0M;

   }

}  

class GoldAccount : CustomerAccount

{

   public override decimal CalculatePrice()

   {

      return base.CalculatePrice() * 0.9M;

   }

}

这个语法类似于Java,但Java使用关键字super而不是base。C++没有类似的关键字,但需要显式指定类名(CustomerAccount::CalculatePrice())。C++中对应于base的内容都比较模糊,因此C++允许多重继承。

注意,可以使用base.<MethodName>()语法调用基类中的任何方法,不必在同一个方法的重载中调用它。

4.2.4  抽象类和抽象函数

C#允许把类和函数声明为abstract,抽象类不能实例化,而抽象函数没有执行代码,必须在非抽象的派生类中重写。显然,抽象函数也是虚拟的(但也不需要提供virtual关键字,实际上,如果提供了该关键字,就会产生一个语法错误)。如果类包含抽象函数,该类将也是抽象的,也必须声明为抽象的:

abstract class Building

{

   public abstract decimal CalculateHeatingCost();      // abstract method

}

C++开发人员要注意C#中的一些语法区别。C#不支持采用=0语法来声明抽象函数。在C#中,这个语法有误导作用,因为可以在类声明的成员字段上使用=<value>,提供初始值:

abstract class Building

{

   private bool damaged = false;   // field

   public abstract decimal CalculateHeatingCost();   // abstract method

}

注意:

C++开发人员还要注意术语上的细微差别:在C++中,抽象函数常常描述为纯虚函数,而在C#中,仅使用抽象这个术语。

4.2.5  密封类和密封方法

C#允许把类和方法声明为sealed。对于类来说,这表示不能继承该类;对于方法来说,这表示不能重写该方法。

sealed class FinalClass

{

   // etc

}

class DerivedClass : FinalClass       // wrong. Will give compilation error

{

   // etc

}

注意:

Java开发人员可以把C#中的sealed当作Java中的final

在把类或方法标记为sealed时,最可能的情形是:如果要对库、类或自己编写的其他类进行操作,则重写某些功能会导致错误。也可以因商业原因把类或方法标记为sealed,以防第三方以违反注册协议的方式扩展该类。但一般情况下,在把类或方法标记为sealed时要小心,因为这么做会严重限制它的使用。即使不希望它能继承一个类或重写类的某个成员,仍有可能在将来的某个时刻,有人会遇到我们没有预料到的情形。.NET基类库大量使用了密封类,使希望从这些类中派生出自己的类的第三方开发人员无法访问这些类。例如string就是一个密封类。

把方法声明为sealed也可以实现类似的目的,但很少这么做。

class MyClass

{

   public sealed override void FinalMethod()

   {

      // etc.

   }

}

class DerivedClass : MyClass

{

   public override void FinalMethod()      // wrong. Will give compilation error

   {

   }

}

在方法上使用sealed关键字是没有意义的,除非该方法本身是某个基类上另一个方法的重写形式。如果定义一个新方法,但不想让别人重写它,首先就不要把它声明为virtual。但如果要重写某个基类方法,sealed关键字就提供了一种方式,可以确保为方法提供的重写代码是最终的代码,其他人不能再重写它。

4.2.6  派生类的构造函数

第3章介绍了单个类的构造函数是如何工作的。这样,就产生了一个有趣的问题,在开始为层次结构中的类(这个类继承了其他类,也可能有定制的构造函数)定义自己的构造函数时,会发生什么情况?

假定没有为类定义任何显式的构造函数,这样编译器就会为所有的类提供默认的构造函数,在后台会进行许多操作,编译器可以很好地解决层次结构中的所有问题,每个类中的每个字段都会初始化为默认值。但在添加了一个我们自己的构造函数后,就要通过派生类的层次结构高效地控制构造过程,因此必须确保构造过程顺利进行,不要出现不能按照层次结构进行构造的问题。

为什么派生类会有某些特殊的问题?原因是在创建派生类的实例时,实际上会有多个构造函数起作用。要实例化的类的构造函数本身不能初始化类,还必须调用基类中的构造函数。这就是为什么要通过层次结构进行构造的原因。

为了说明为什么必须调用基类的构造函数,下面是手机公司MortimerPhones开发的一个例子。这个例子包含一个抽象类GenericCustomer,它表示顾客。还有一个(非抽象)类Nevermore60-
Customer,它表示采用特定付费方式(称为Nevermore60付费方式)的顾客。所有的顾客都有一个名字,由一个私有字段表示。在Nevermore60付费方式中,顾客前几分钟的电话费比较高,需要一个字段highCostMinutesUsed,它详细说明了每个顾客该如何支付这些较高的电话费。抽象类GenericCustomer的定义如下所示:

abstract class GenericCustomer

{

   private string name;

   // lots of other methods etc.

}

class Nevermore60Customer : GenericCustomer

{

   private uint highCostMinutesUsed;

   // other methods etc.

}

不要担心在这些类中执行的其他方法,因为这里仅考虑构造过程。如果下载了本章的示例代码,就会发现类的定义仅包含构造函数。

下面看看使用new运算符实例化Nevermore60Customer时,会发生什么情况:

   GenericCustomer customer = new Nevermore60Customer();

显然,成员字段name和highCostMinutesUsed都必须在实例化customer时进行初始化。如果没有提供自己的构造函数,而是仅依赖默认的构造函数,name就会初始化为null引用,highCostMinutesUsed初始化为0。下面详细讨论其过程。

highCostMinutesUsed字段没有问题:编译器提供的默认Nevermore60Customer构造函数会把它初始化为0。

那么name呢?看看类定义,显然,Nevermore60Customer构造函数不能初始化这个值。字段name声明为private,这意味着派生的类不能访问它。默认的Nevermore60Customer构造函数甚至不知道存在这个字段。惟一知道这个字段的是GenericCustomer的其他成员,即如果对name进行初始化,就必须在GenericCustomer的某个构造函数中进行。无论类层次结构有多大,这种情况都会一直延续到最终的基类System.Object上。

理解了上面的问题后,就可以明白实例化派生类时会发生什么样的情况了。假定默认的构造函数在整个层次结构中使用:编译器首先找到它试图实例化的类的构造函数,在本例中是Nevermore60Customer,这个默认Nevermore60Customer构造函数首先要做的是为其直接基类GenericCustomer运行默认构造函数,然后GenericCustomer构造函数为其直接基类System.Object运行默认构造函数,System. Object没有任何基类,所以它的构造函数就执行,并把控制返回给GenericCustomer构造函数。现在执行GenericCustomer构造函数,把name初始化为null,再把控制权返回给Nevermore60Customer构造函数,接着执行这个构造函数,把highCostMinutesUsed初始化为0,并退出。此时,Nevermore60Customer实例就已经成功地构造和初始化了。

构造函数的调用顺序是先调用System.Object,再按照层次结构由上向下进行,直到到达编译器要实例化的类为止。还要注意在这个过程中,每个构造函数都初始化它自己的类中的字段。这是它的一般工作方式,在开始添加自己的构造函数时,也应尽可能遵循这个规则。

注意构造函数的执行顺序。基类的构造函数总是最先调用。也就是说,派生类的构造函数可以在执行过程中调用基类方法、属性和其他成员,因为基类已经构造出来的,其字段也初始化了。如果派生类不喜欢初始化基类的方式,但要访问数据,就可以改变数据的初始值,但是,好的编程方式应尽可能避免这种情况,让基类构造函数来处理其字段。

理解了构造过程后,就可以开始添加自己的构造函数了。

1. 在层次结构中添加无参数的构造函数

首先讨论最简单的情况,在层次结构中用一个无参数的构造函数来替换默认的构造函数后,看看会发生什么情况。假定要把每个人的名字初始化为<no name>,而不是null引用,修改GenericCustomer中的代码,如下所示:

   public abstract class GenericCustomer

   {

      private string name;

      public GenericCustomer()

         : base()  // we could omit this line without affecting the compiled code

      {

         name = "<no name>";

      }

添加这段代码后,代码运行正常。Nevermore60Customer仍有自己的默认构造函数,所以上面描述的事件顺序仍不变,但编译器会使用定制的GenericCustomer构造函数,而不是生成默认的构造函数,所以name字段按照需要总是初始化为<no name>。

注意,在定制的构造函数中,在执行GenericCustomer构造函数前,添加了一个对基类构造函数的调用,使用的语法与前面解释如何让构造函数的不同重载版本互相调用时使用的语法相同。惟一的区别是,这次使用的关键字是base,而不是this,表示这是基类的构造函数,而不是要调用的类的构造函数。在base关键字后面的圆括号中没有参数,这是非常重要的,因为没有给基类构造函数传送参数,所以编译器会调用无参数的构造函数。其结果是编译器会插入调用System.Object构造函数的代码,这正好与默认情况相同。

实际上,可以把这行代码删除,只加上为本章中大多数构造函数编写的代码:

      public GenericCustomer()

      {

         name = "<no name>";

      }

如果编译器没有在起始花括号的前面找到对另一个构造函数的任何引用,它就会假定我们要调用基类构造函数——这符合默认构造函数的工作方式。

base 和 this关键字是调用另一个构造函数时允许使用的惟一关键字,其他关键字都会产生编译错误。还要注意只能指定一个其他的构造函数。

到目前为止,这段代码运行正常。但是,要通过构造函数的层次结构把级数弄乱的最好方法是把构造函数声明为私有:

      private GenericCustomer()

      {

         name = "<no name>";

      }

如果试图这样做,就会产生一个有趣的编译错误,如果不理解构造是如何按照层次结构由上而下的顺序工作的,这个错误会让人摸不着头脑。

'Wrox.ProCSharp.GenericCustomer()' is inaccessible due to its protection level

有趣的是,该错误没有发生在GenericCustomer类中,而是发生在Nevermore60Customer派生类中。编译器试图为Nevermore60Customer生成默认的构造函数,但又做不到,因为默认的构造函数应调用无参数的GenericCustomer构造函数。把该构造函数声明为private,它就不可能访问派生类了。如果为带有参数的GenericCustomer提供一个构造函数,但没有提供无参数的构造函数,也会发生类似的错误。在本例中,编译器不能为GenericCustomer生成默认构造函数,所以当编译器试图为派生类生成默认构造函数时,会再次发现它不能做到这一点,因为没有无参数的基类构造函数可调用。这个问题的解决方法是为派生类添加自己的构造函数—— 实际上不需要在这些构造函数中做任何工作,这样,编译器就不会为这些派生类生成默认构造函数了。

前面介绍了所有的理论知识,下面用一个例子来说明如何给类的层次结构添加构造函数。下一节为MortimerPhones样例添加带参数的构造函数。

2. 在层次结构中添加带参数的构造函数

首先是带一个参数的MortimerPhones构造函数,它仅在顾客提供其姓名时才实例化顾客:

   abstract class GenericCustomer

   {

      private string name;

      public GenericCustomer(string name)

      {

         this.name = name;

      }

到目前为止,代码运行一切正常,但刚才说过,在编译器试图为派生类创建默认构造函数时,会产生一个编译错误,因为编译器为Nevermore60Customer生成的默认构造函数会试图调用无参数的GenericCustomer构造函数,但GenericCustomer没有这样的构造函数。因此,需要为派生类提供一个构造函数,来避免这个错误:

class Nevermore60Customer : GenericCustomer

{

   private uint highCostMinutesUsed;

   public Nevermore60Customer(string name)

      :   base(name)

   {

   }

现在,Nevermore60Customer对象的实例只能在提供了包含顾客姓名的字符串后创建,这正是我们需要的。有趣的是Nevermore60Customer构造函数对这个字符串所做的处理。它本身不能初始化name字段,因为它不能访问基类中的私有字段,但可以把顾客姓名传送给基类,以便GenericCustomer构造函数处理。具体方法是,把先执行的基类构造函数指定为把顾客姓名当做参数的构造函数。除此之外,它不需要执行任何操作。

下面讨论如果要处理不同的重载构造函数和一个类的层次结构,会发生什么情况。假定Nevermore60Customers通过朋友联系到MortimerPhones,即MortimerPhones公司中有一个人是朋友,因此可以获得折扣。这表示在构造一个Nevermore60Customer时,还需要传递联系人的姓名。在现实生活中,构造函数必须利用该姓名去完成更复杂的工作,例如处理折扣等,但这里只是把联系人的姓名存储到另一个字段中。

此时,Nevermore60Customer定义如下所示:

   class Nevermore60Customer : GenericCustomer

   {

      public Nevermore60Customer(string name, string referrerName)

         : base(name)

      {

         this.referrerName = referrerName;

      }

     

      private string referrerName;

      private uint highCostMinutesUsed;

该构造函数将姓名作为参数,把它传递给GenericCustomer构造函数进行处理。referrerName是一个变量,我们需要声明它,这样构造函数才能在其主体中处理这个参数。

但是,并不是所有的Nevermore60Customers都有联系人,所以还需要有一个不需此参数的构造函数(或为它提供默认值的构造函数)。实际上,我们指定如果没有联系人,referrerName字段就设置为<None>。下面是这个带一个参数的构造函数:

      public Nevermore60Customer(string name)

         : this(name, "<None>")

      {

      }

这样就正确建立了所有的构造函数。执行下面的代码时,检查事件链是很有益的:

   GenericCustomer customer = new Nevermore60Customer("Arabel Jones");

编译器认为它需要带一个字符串参数的构造函数,所以它确认的构造函数就是刚才定义的那个构造函数,如下所示。

      public Nevermore60Customer(string Name)

         : this(Name, "<None>")

在实例化customer时,就会调用这个构造函数。之后立即把控制权传送给对应的Nevermore60Customer构造函数,该构造函数带2个参数,分别是Arabel Jones和<None>。在这个构造函数中,把控制权依次传送给GenericCustomer构造函数,该构造函数带有1个参数,即字符串Arabel Jones。然后这个构造函数把控制权传送给System.Object默认构造函数。现在执行这些构造函数,首先执行System.Object构造函数,接着执行Nevermore60Customer构造函数,初始化name字段。然后带有两个参数的Nevermore60Customer构造函数得到控制权,把联系人的姓名初始化为<None>。最后,执行Nevermore60Customer构造函数,该构造函数带有1个参数—— 这个构造函数什么也不做。

这个过程非常简洁,设计也很合理。每个构造函数都处理变量的初始化。在这个过程中,正确地实例化了类,以备使用。如果在为类编写自己的构造函数时遵循这个规则,即便是最复杂的类,也可以顺利地初始化,不会出现任何问题。

4.3  修饰符

前面已经遇到许多所谓的修饰符,即应用于类型或成员的关键字。修饰符可以指定方法的可见性,例如public或private,还可以指定一项的本质,例如方法是virtual或abstract。C#有许多访问修饰符,下面讨论完整的修饰符列表。

4.3.1  可见性修饰符

表4-1中的修饰符确定了是否允许其他代码访问某一项。 

  4-1

  

  

   

public

所有的类型或成员

任何代码均可以访问该方法

protected

类型和内嵌类型的所有成员

只有派生的类型能访问该方法

internal

类型和内嵌类型的所有成员

只能在包含它的程序集中访问该方法

private

所有的类型或成员

只能在它所属的类型中访问该方法

protected internal

类型和内嵌类型的所有成员

只能在包含它的程序集和派生类型的代码中访问该方法

注意,类型定义可以是公共或私有的,这取决于是否希望在包含类型的程序集外部访问它:

public class MyClass

{

//etc.

不能把类型定义为protected、internal和protected internal,因为这些修饰符对于包含在命名空间中的类型来说是没有意义的。因此这些修饰符只能应用于成员。但是,可以用这些修饰符定义嵌套的类型(即包含在其他类型中的类型),因为在这种情况下,类型也具有成员的状态。下面的代码是合法的:

public class OuterClass

{

   protected class InnerClass

   {

         //etc.

    }

    //etc.

}

如果有嵌套的类型,内部的类型总是可以访问外部类型的所有成员,所以在上面的代码中,InnerClass中的代码可以访问OuterClass的所有成员,甚至可以访问OuterClass的私有成员。

4.3.2  其他修饰符

表4-2中的修饰符可以应用于类型的成员,而且有不同的用途。在应用于类型时,其中的几个修饰符也是有意义的。

  4-2

  

  

   

new

函数成员

成员用相同的签名隐藏继承的成员

static

所有的成员

成员不在类的具体实例上执行

virtual

仅类和函数成员

成员可以由派生类重写

abstract

仅函数成员

虚拟成员定义了成员的签名,但没有提供实现代码

(续表)  

  

  

   

override

仅函数成员

成员重写了继承的虚拟或抽象成员

sealed

成员重写了继承的虚拟成员,但继承该类的任何类都不能重写该成员。该修饰符必须与override一起使用

extern

仅静态[DllImport]方法

成员在外部用另一种语言实现

在这些修饰符中,internal 和 protected internal是C#和.NET Framework新增的。internal与public类似,但访问仅限于同一个程序集中的其他代码,换言之,在同一个程序中同时编译的代码。使用internal可以确保编写的其他类都能访问某一成员,但同时其他公司编写的其他代码不能访问它们。protected internal合并了protected和internal,但这是一种OR合并,而不是AND合并。protected internal成员在同一个程序集的任何代码中都可见,在派生类中也可见,甚至在其他程序集中也可见。

4.4  接口

如前所述,如果一个类派生于一个接口,它就会执行某些函数。并不是所有的面向对象语言都支持接口,所以本节将详细介绍C#接口的实现。

注意:

熟悉COM的开发人员应注意,尽管在概念上C#接口类似于COM接口,但它们是不同的,底层的结构不同,例如,C#接口并不派生于IUnknownC#接口根据.NET函数提供了一个契约。与COM接口不同,C#接口不代表任何类型的二进制标准。

下面列出Microsoft预定义的一个接口System.IDisposable的完整定义。IDisposable包含一个方法Dispose(),该方法由类执行,用于清理代码:

public interface IDisposable

{

   void Dispose();

}

上面的代码说明,声明接口在语法上与声明抽象类完全相同,但不允许提供接口中任何成员的执行方式。一般情况下,接口中只能包含方法、属性、索引器和事件的声明。

不能实例化接口,它只能包含其成员的签名。接口不能有构造函数(如何构建不能实例化的对象?)或字段(因为这隐含了某些内部的执行方式)。接口定义也不允许包含运算符重载,但这不是因为声明它们在原则上有什么问题,而是因为接口通常是公共契约,包含运算符重载会引起一些与其他.NET语言不兼容的问题,例如与VB.NET的不兼容,因为VB.NET不支持运算符重载。

在接口定义中还不允许声明成员上的修饰符。接口成员总是公共的,不能声明为虚拟或静态。如果需要,就应由执行的类来声明,因此最好通过执行的类来声明访问修饰符,就像上面的代码那样。

例如IDisposable。如果类希望声明为公共类型,以便执行方法Dispose(),该类就必须执行IDisposable。在C#中,这表示该类派生于IDisposable。

class SomeClass : IDisposable

{

   // this class MUST contain an implementation of the

   // IDisposable.Dispose() method, otherwise

   // you get a compilation error

   public void Dispose()

   {

      // implementation of Dispose() method

   }

   // rest of class

}

在这个例子中,如果SomeClass派生于IDisposable,但不包含与IDisposable中签名相同的Dispose()实现代码,就会得到一个编译错误,因为该类破坏了实现IDisposable的契约。当然,编译器允许类有一个不派生于IDisposable的Dispose()方法。问题是其他代码无法识别出SomeClass支持IDisposable特性。

注意:

IDisposable是一个相当简单的接口,它只定义了一个方法。大多数接口都包含许多成员。

接口的另一个例子是C#中的foreach循环。实际上,foreach循环的内部工作方式是查询对象,看看它是否实现了System.Collections.IEnumerable接口。如果是,C#编译器就插入IL代码,使用这个接口上的方法迭代集合中的成员,否则,foreach就会引发一个异常。第9章将详细介绍IEnumerable接口。但应注意,IEnumerable和IDisposable在某种程度上都是有点特殊的接口,因为它们都可以由C#编译器识别,在C#编译器生成的代码中会考虑它们。显然,自己定义的接口就没有这个特权。

4.4.1  定义和实现接口

下面开发一个遵循接口继承规范的小例子来说明如何定义和使用接口。这个例子建立在银行账户的基础上。假定编写代码,最终允许在银行账户之间进行计算机转账业务。许多公司可以实现银行账户,但它们都是彼此赞同表示银行账户的所有类都实现接口IBankAccount。该接口包含一个用于存取款的方法和一个返回余额的属性。这个接口还允许外部代码识别由不同银行账户执行的各种银行账户类。我们的目的是允许银行账户彼此通信,以便在账户之间进行转账业务,但还没有介绍这个功能。

为了使例子简单一些,我们把例子的所有代码都放在同一个源文件中,但实际上不同的银行账户类会编译到不同的程序集中,而这些程序集位于不同银行的不同机器上。第29章在讨论远程通信时,将介绍位于不同机器上的.NET程序集如何通信。但那些内容对于这里的例子来说过于复杂了。为了保留一定的真实性,我们为不同的公司定义不同的命名空间。

首先,需要定义IBank接口:

namespace Wrox.ProCSharp

{

   public interface IBankAccount

   {

      void PayIn(decimal amount);

      bool Withdraw(decimal amount);

      decimal Balance

      {

         get;

      }

   }

}

注意,接口的名称为IBankAccount。接口名称传统上以字母I开头,以便知道这是一个接口。

注意:

如第2章所述,在大多数情况下,.NET用法规则不鼓励采用所谓的Hungarian表示法,在名称的前面加一个字母,表示对象的类型,接口是Hungarian表示法推荐采用的几种名称之一。

现在可以编写表示银行账户的类了。这些类不必彼此相关,它们可以是完全不同的类。但它们都表示银行账户,因为它们都实现了IBankAccount接口。

下面是第一个类,一个由Royal Bank of Venus运行的存款账户:

namespace Wrox.ProCSharp.VenusBank

{

   public class SaverAccount : IBankAccount

   {

      private decimal balance;

      public void PayIn(decimal amount)

      {

         balance += amount;

      }

      public bool Withdraw(decimal amount)

      {

         if (balance >= amount)

         {

            balance -= amount;

            return true;

         }

         Console.WriteLine("Withdrawal attempt failed.");

         return false;

      }

      public decimal Balance

      {

         get

         {

            return balance;

         }

      }

      public override string ToString()

      {

         return String.Format("Venus Bank Saver: Balance = {0,6:C}", balance);

      }

   }

}

这个类的实现代码的作用一目了然。其中包含一个私有字段balance,当存款或取款时就调整这个字段。如果因为账户中的金额不足而取款失败,就会显示一个错误消息。还要注意,因为我们要使代码尽可能简单,所以不实现额外的属性,例如账户持有人的姓名。在现实生活中,这是最基本的信息,但对于本例来说,这是不必要的。

在这段代码中,惟一有趣的是类的声明:

public class SaverAccount : IBankAccount

SaverAccount派生于一个接口IbankAccount,我们没有明确指出任何其他基类(当然这表示SaverAccount直接派生于System.Object)。另外,从接口中派生完全独立于从类中派生。

SaverAccount派生于IBankAccount,表示它获得了IBankAccount的所有成员,但接口并不实际实现其方法,所以SaverAccount必须提供这些方法的所有实现代码。如果没有提供实现代码,编译器就会产生错误。接口仅表示其成员的存在性,类负责确定这些成员是虚拟还是抽象的(但只有在类本身是抽象的,这些成员才能是抽象的)。在本例中,接口方法不必是虚拟的。

为了说明不同的类如何实现相同的接口,下面假定Planetary Bank of Jupiter还实现一个类Gold Account来表示其银行账户:

namespace Wrox.ProCSharp.JupiterBank

{

   public class GoldAccount : IBankAccount

   {

      // etc

   }

}

这里没有列出GoldAccount类的细节,因为在本例中它基本上与SaverAccount的实现代码相同。GoldAccount与VenusAccount没有关系,它们只是碰巧实现相同的接口而已。

有了自己的类后,就可以测试它们了。首先需要一些using语句:

using System;

using Wrox.ProCSharp;

using Wrox.ProCSharp.VenusBank;

using Wrox.ProCSharp.JupiterBank;

然后需要一个Main()方法:

namespace Wrox.ProCSharp

{

   class MainEntryPoint

   {

      static void Main()

      {

         IBankAccount venusAccount = new SaverAccount();

         IBankAccount jupiterAccount = new GoldAccount();

         venusAccount.PayIn(200);

         venusAccount.Withdraw(100);

         Console.WriteLine(venusAccount.ToString());

         jupiterAccount.PayIn(500);

         jupiterAccount.Withdraw(600);

         jupiterAccount.Withdraw(100);

         Console.WriteLine(jupiterAccount.ToString());

      }

   }

}

这段代码(如果下载本例子,它在BankAccounts.cs文件中)的执行结果如下:

C:>BankAccounts

Venus Bank Saver: Balance = £100.00

Withdrawal attempt failed.

Jupiter Bank Saver: Balance = £400.00

在这段代码中,一个要点是把引用变量声明为IBankAccount引用的方式。这表示它们可以指向实现这个接口的任何类的实例。但我们只能通过这些引用调用方法(这些方法是接口的一部分)—— 如果要调用由不在接口中的类执行的任何方法,就需要把引用强制转换为合适的类型。在这段代码中,我们调用了ToString()(不由IBankAccount实现),但没有进行任何显式转换,这只是因为ToString()是一个System.Object方法,C#编译器知道任何类都支持这个方法(换言之,从接口到System.Object的数据类型转换是隐式的)。第5章将介绍强制转换的语法。

接口引用完全可以看做是类引用—— 但接口引用的强大之处在于,它可以引用任何实现该接口的类。例如,我们可以构造接口数组,其中的每个元素都是不同的类:

IBankAccount[] accounts = new IBankAccount[2];

accounts[0] = new SaverAccount();

accounts[1] = new GoldAccount();

Note, however, that we'd get a compiler error if we tried something like this

accounts[1] = new SomeOtherClass();     // SomeOtherClass does NOT implement

                                                                // IBankAccount: WRONG!!

这会导致一个如下所示的编译错误:

Cannot implicitly convert type 'Wrox.ProCSharp.SomeOtherClass' to

 'Wrox.ProCSharp.IBankAccount'

4.4.2  派生的接口

接口可以彼此继承,其方式与类的继承相同。下面通过定义一个新接口ITransferBank Account来说明这个概念,该接口的功能与IBankAccount相同,只是又定义了一个方法,把资金直接转到另一个账户上。

namespace Wrox.ProCSharp

{

   public interface ITransferBankAccount : IBankAccount

   {

      bool TransferTo(IBankAccount destination, decimal amount);

   }

}

因为ITransferBankAccount派生于IBankAccount,所以拥有IBankAccount的所有成员和它自己的成员。这表示执行(派生于)ITransferBankAccount的任何类都必须执行IBankAccount的所有方法和在ITransferBankAccount中定义的新方法TransferTo()。没有执行所有这些方法就会产生一个编译错误。

注意,TransferTo()方法为目标账户使用了IBankAccount接口引用。这说明了接口的用途:在执行并调用这个方法时,不必知道转帐的对象类型,只需知道该对象执行IBankAccount即可。

下面演示ITransferBankAccount:假定Planetary Bank of Jupiter还提供了一个当前账户。CurrentAccount类的大多数执行代码与SaverAccount 和 GoldAccount的执行代码相同(这仅是为了使例子更简单,一般是不会这样的),所以在下面的代码中,我们仅突出显示了不同的地方:

public class CurrentAccount : ITransferBankAccount

{

   private decimal balance;

   public void PayIn(decimal amount)

   {

      balance += amount;

   }

   public bool Withdraw(decimal amount)

   {

      if (balance >= amount)

      {

         balance -= amount;

         return true;

      }

      Console.WriteLine("Withdrawal attempt failed.");

      return false;

   }

   public decimal Balance

   {

      get

      {

         return balance;

      }

   }

   public bool TransferTo(IBankAccount destination, decimal amount)

   {

      bool result;

      if ((result = Withdraw(amount)) == true)

         destination.PayIn(amount);

      return result;

   }

   public override string ToString()

   {

      return String.Format("Jupiter Bank Current Account: Balance = {0,6:C}",

                                                            balance);

   }  

}

可以用下面的代码验证该类:

static void Main()

{

   IBankAccount venusAccount = new SaverAccount();

   ITransferBankAccount jupiterAccount = new CurrentAccount();

   venusAccount.PayIn(200);

   jupiterAccount.PayIn(500);

   jupiterAccount.TransferTo(venusAccount, 100);

   Console.WriteLine(venusAccount.ToString());

   Console.WriteLine(jupiterAccount.ToString());

}

 这段代码(CurrentAccount.cs)的结果如下所示,其中显示转账后正确的资金数:

C:>CurrentAccount

Venus Bank Saver: Balance = £300.00

Jupiter Bank Current Account: Balance = £400.00

4.5  小结

本章介绍了如何在C#中进行继承。C#支持多接口继承和单一实现继承,还提供了许多有效的语法结构,以使代码更健壮,例如override关键字,它表示函数应在何时重写基类函数,new关键字表示函数在何时隐藏基类函数,构造函数初始化器的硬性规则可以确保构造函数以健壮的方式进行交互操作。

第5章将讨论C#对运算符、运算符重载和类型强制转换的支持。

5运算符和类型强制转换

前几章介绍了使用C#编写程序所需要的大部分知识。本章将首先讨论基本语言元素,接着论述C#语言的扩展功能。本章的主要内容如下:

       C#中的可用运算符

       处理引用类型和值类型时相等的含义

       基本数据类型之间的数据转换

       使用装箱技术把值类型转换为引用类型

       通过强制转换技术在引用类型之间转换

       重载标准的运算符,以支持对定制类型的操作

       给定制类型添加强制转换运算符,以支持无缝的数据类型转换

5.1  运算符

C和C++开发人员应很熟悉大多数C#运算符,这里为新程序员和VB开发人员介绍最重要的运算符,并介绍C#中的一些新变化。

C#支持表5-1所示的运算符,其中有4个运算符(sizeof、*、–>、&)只能用于不安全的代码(这些代码绕过了C#类型安全性的检查),这些不安全的代码见第7章的讨论。

  5-1

   

  

算术运算符

+ * / %

逻辑运算符

&  |  ^  ~  &&  ||  !

字符串连接运算符

+

增量和减量运算符

++  – –

移位运算符

<<  >>

比较运算符

==  !=  < >  <=  >=

赋值运算符

=  += =  *=  /=  %=  &=  |=  ^=  <<=  >>=

成员访问运算符(用于对象和结构)

.

索引运算符(用于数组和索引器)

[]

数据类型转换运算符

()

条件运算符 (三元运算符)

?:

委托连接和删除运算符(见第6)

+

对象创建运算符

new

类型信息运算符

sizeof (只用于不安全的代码) is typeof as

   

  

溢出异常控制运算符

checked unchecked

间接寻址运算符

*  –>  & (只用于不安全代码) []

命名空间别名限定符(见第2)

::

空接合运算符

??

使用C#运算符的一个最大缺点是,与C风格的语言一样,赋值(=)和比较(==)运算使用不同的运算符。例如,下述语句表示“x等于3”:

x = 3;

如果要比较x和另一个值,就需要使用两个等号(==):

if (x == 3)

C#非常严格的类型安全规则防止出现常见的C#错误,也就是在逻辑语句中使用赋值运算符代替比较运算符。在C#中,下述语句会产生一个编译错误:

if (x = 3)

习惯使用宏字符&来连接字符串的VB程序员必须改变这个习惯。在C#中,使用加号+连接字符串,而&表示两个不同整数值的按位AND运算。| 则在两个整数之间执行按位OR运算。VB程序员可能还没有使用过%(取模)运算符,它返回除运算的余数,例如,如果x等于7,则x% 5会返回2。

在C#中很少会用到指针,因此也很少会用到间接寻址运算符(–>)。使用它们的惟一场合是在不安全的代码块中,因为只有在此C#才允许使用指针。指针和不安全的代码见第7章。

5.1.1  运算符的简化操作

表5-2列出了C#中的全部简化赋值运算符。

  5-2

运算符的简化操作

  

x++, ++x

x = x + 1

x– –,– –x

x = x – 1

x+= y

x = x + y

x= y

x = x – y

x *= y

x = x * y

x /= y

x = x / y

x %= y

x = x % y

x >>= y

x = x >> y

x <<= y

x = x << y

运算符的简化操作

  

x &= y

x = x & y

x |= y

x = x | y

x ^= y

x = x ^ y

为什么用两个例子来说明++增量和– –减量运算符?把运算符放在表达式的前面称为前置,把运算符放在表达式的后面称为后置。它们的执行方式有所不同。

增量或减量运算符可以作用于整个表达式,也可以作用于表达式的内部。当x++和++x单独占一行时,它们的作用是相同的,对应于语句x = x + 1。但当它们用于表达式内部时,把运算符放在前面(++x)会在计算表达式之前递增x,换言之,递增了x后,在表达式中使用新值进行计算。而把运算符放在后面(x++)会在计算表达式之后递增x—— 使用原来的值计算表达式。下面的例子使用++增量运算符说明了它们的区别:

int x = 5;

if (++x == 6)

{

   Console.WriteLine("This will execute");

}

if (x++ == 7)

{

   Console.WriteLine("This won't");

}

第一个if条件得到true,因为在计算表达式之前,x从5递增为6。第二个if语句中的条件为false,因为在计算完整个表达式(x=6)后,x才递增为7。

前置运算符––x和后置运算符x––与此类似,但它们是递减,而不是递增。

其他简化运算符,如+= 和–=需要两个操作数,用于执行算术、逻辑和按位运算,改变第一个操作数的值。例如,下面两行代码是等价的:

x += 5;

x = x + 5;

5.1.2  三元运算符

三元运算符(?:)是if...else结构的简化形式。其名称的出处是它带有三个操作数。它可以计算一个条件,如果条件为真,就返回一个值;如果条件为假,则返回另一个值。其语法如下:

condition ? true_value : false_value

其中condition是要计算的Boolean型表达式,true_value是condition为true时返回的值,false_value是condition为false时返回的值。

恰当地使用三元运算符,可以使程序非常简洁。它特别适合于给被调用的函数提供两个参数中的一个。使用它可以把Boolean值转换为字符串值true或false。它也很适合于显示正确的单数形式或复数形式,例如:

int x = 1;

string s = x.ToString() + " ";

s += (x == 1 ? "man" : "men");

Console.WriteLine(s);

如果x等于1,这段代码就显示 man,如果x等于其他数,就显示其正确的复数形式。但要注意,如果结果需要用在不同的语言中,就必须编写更复杂的例程,以考虑到不同语言的不同语法。

5.1.3  checkedunchecked运算符

考虑下面的代码:

byte b = 255;

b++;

Console.WriteLine(b.ToString());

byte数据类型只能包含0~255的数,所以b值的增量会导致溢出。CLR如何处理这个溢出取决于许多方面,包括编译器选项,所以无论溢出有什么样的风险,都需要用某种方式确保得到我们希望的结果。

为此,C#提供了checked和 unchecked运算符。如果把一个代码块标记为checked,CLR就会执行溢出检查,如果发生溢出,就抛出异常。如果改变代码,使之包含checked运算符:

byte b = 255;

checked

{

   b++;

}

Console.WriteLine(b.ToString());

运行这段代码,就会得到一个错误信息:

Unhandled Exception: System.OverflowException: Arithmetic operation resulted in an overflow.

   at Wrox.ProCSharp.Basics.OverflowTest.Main(String[] args)

注意:

/checked编译器选项进行编译,就可以检查程序中所有未标记代码中的溢出。

如果要禁止溢出检查,可以把代码标记为unchecked:

byte b = 255;

unchecked

{

   b++;

}

Console.WriteLine(b.ToString());

在本例中,不会抛出异常,但会丢失数据——因为byte数据类型不能包含256,溢出的位会被丢掉,所以b变量得到的值是0。

注意,unchecked是默认值。只有在需要把几个未检查的代码行放在一个明确标记为checked的大代码块中,才需要显式使用unchecked关键字。

5.1.4  is运算符

is运算符可以检查对象是否与特定的类型兼容。例如,要检查变量是否与object类型兼容:

注意:

“兼容”表示对象是该类型,或者派生于该类型。

int i = 10;

if (i is object)

{

   Console.WriteLine("i is an object");

}

int和从object继承而来的其他C#数据类型一样,表达式i is object将得到true,并显示信息。

5.1.5  as运算符

as运算符用于执行引用类型的显式类型转换。如果要转换的类型与指定的类型兼容,转换就会成功进行;如果类型不兼容,as运算符就会返回值null。如下面的代码所示,如果object引用不指向string实例,把object引用转换为string就会返回null:

object o1 = "Some String";

object o2 = 5;

string s1 = o1 as string;      //s1 = "Some String"

string s2 = o2 as string;     //s1 = null

as运算符允许在一步中进行安全的类型转换,不需要先使用is运算符测试类型,再执行   转换。

5.1.6  sizeof运算符

使用sizeof运算符可以确定堆栈中值类型需要的长度(单位是字节):

unsafe

{

   Console.WriteLine(sizeof(int));

}

其结果是显示数字4,因为int有4个字节。

注意,只能在不安全的代码中使用sizeof运算符。第7章将详细论述不安全的代码。

5.1.7  typeof运算符

typeof运算符返回一个表示特定类型的System.Type对象。例如,typeof(string)返回表示System.String类型的Type对象。在使用反射动态查找对象的信息时,这个运算符是很有效的。第11章将介绍反射。

5.1.8  可空类型和运算符

如果在程序中使用可空类型,就必须考虑null值在与各种运算符一起使用时的影响。通常可空类型与一元或二元运算符一起使用时,如果其中一个操作数或两个操作数都是null,其结果就是null。例如:

int? a = null;

int? b = a + 4;      // b = null

int? c = a * 5;      // c = null

但是在比较可空类型时,只要有一个操作数是null,比较的结果就是false。即不能因为一个条件是false,就认为该条件的对立面是true,这在使用非可空类型的程序中很常见。例如:

int? a = null;

int? b = -5;

if (a >= b)

   System.Console.WriteLine("a >= b");

else

   System.Console.WriteLine("a < b");

注意:

Null值的可能性表示,不能随意比较表达式中的可空类型和非可空类型,详见本章后面的内容。

5.1.9  空接合运算符

空接合运算符(??)为处理可空类型和引用类型时表示Null值的可能性提供了一种快捷方式。这个运算符放在两个操作数之间,第一个操作数必须是一个可空类型或引用类型,第二个操作数必须与第一个操作数的类型相同,或者可以隐含地转换为第一个操作数的类型。空接合运算符的计算如下:如果第一个操作数不是null,则整个表达式就等于第一个操作数的值。但如果第一个操作数是null,则整个表达式就等于第二个操作数的值。例如:

int? a = null;

int b;

b = a ?? 10;     // b has the value 10

a = 3;

b = a ?? 10;     // b has the value 3

如果第二个操作数不能隐含地转换为第一个操作数的类型,就生成一个编译错误。

5.1.10  运算符的优先级

表5-3显示了C#运算符的优先级。表顶部的运算符有最高的优先级(即在包含多个运算符的表达式中,最先计算该运算符)

  5-3

  

初级运算符

().[]x++x––  new  typeof  sizeof  checked  unchecked

一元运算符

+   !  ~  ++x  ––x和数据类型转换

/除运算符

*  /  %

/减运算符

+  –

移位运算符

<<  >>

关系运算符

<  >  <=  >=  is as

比较运算符

= =  !=

按位AND运算符

&

按位XOR运算符

|

按位OR运算符

^

布尔 AND运算符

&&

布尔OR运算符

||

三元运算符

?:

赋值运算符

=  += =  *=  /=  %=  &=  |=  ^=  <<=  >>=  >>>=

注意:

在复杂的表达式中,应避免利用运算符优先级来生成正确的结果。使用括号指定运算符的执行顺序,可以使代码更整洁,避免出现潜在的冲突。

5.2  类型的安全性

第1章提到中间语言(IL)可以对其代码强制加上强类型安全性。强类型支持.NET提供的许多服务,包括安全性和语言的交互性。因为C#这种语言会编译为IL,所以C#也是强类型的。这说明数据类型并不总是可互换的。本节将介绍基本类型之间的转换。

 注意:

C#还支持在不同引用类型之间的转换,允许指定自己创建的数据类型如何与其他类型进行相互转换。这些论题将在本章后面讨论。

泛型是C# 2.0中的一个新特性,它可以避免对一些常见的情形进行类型转换,泛型详见第10 章。

5.2.1  类型转换

我们常常需要把数据从一种类型转换为另一种类型。考虑下面的代码:

byte value1 = 10;

byte value2 = 23;

byte total;

total = value1 + value2;

Console.WriteLine(total);

在编译这些代码时,会产生一个错误:

Cannot implicitly convert type 'int' to 'byte' (不能把int类型隐式地转换为byte类型)

问题是,我们把两个byte加在一起时,应返回int型结果,而不是另一个byte。这是因为byte包含的数据只能为8位,所以把两个byte加在一起很容易得到不能存储在一个byte中的值。如果要把结果存储在一个byte变量中,就必须把它转换回byte。C#有两种转换方式,隐式转换方式和显式转换方式。

1. 隐式转换方式

只要能保证值不会发生任何变化,类型转换就可以自动进行。这就是前面代码失败的原因:试图从int转换为byte,而潜在地丢失了3个字节的数据。编译器不会告诉我们该怎么做,除非我们明确告诉它这就是我们希望的!如果在long型变量中存储结果,而不是byte型变量中,就不会有问题了:

byte value1 = 10;

byte value2 = 23;

long total;               // this will compile fine

total = value1 + value2;

Console.WriteLine(total);

这是因为long型变量包含的数据字节比int型多,所以数据没有丢失的危险。在这些情况下,编译器会很顺利地进行转换,我们也不需要显式提出要求。

表5-4介绍了C#支持的隐式类型转换。 

  5-4

  

sbyte

shortintlongfloatdoubledecimal

byte

shortushortintuintlongulongfloatdoubledecimal

short

intlongfloatdoubledecimal

ushort

intuintlongulongfloatdoubledecimal

int

longfloatdoubledecimal

uint

longulongfloatdoubledecimal

longulong

floatdoubledecimal

float

double

char

ushortintuintlongulongfloatdoubledecimal

注意,只能从较小的整数类型隐式地转换为较大的整数类型,不能从较大的整数类型隐式地转换为较小的整数类型。也可以在整数和浮点数之间转换,其规则略有不同,可以在相同大小的类型之间转换,例如int/uint转换为 float,long/ulong转换为double,也可以从long/ulong转换回float。这样做可能会丢失4个字节的数据,但这仅表示得到的float值比使用double得到的值精度低,编译器认为这是一种可以接受的错误,而其值的大小是不会受到影响的。无符号的变量可以转换为有符号的变量,只要无符号的变量值的大小在有符号的变量的范围之内即可。

在隐式转换值类型时,可空类型需要额外考虑:

       可空类型隐式转换为其他可空类型,应遵循上表中非可空类型的转换规则。即int? 隐式转换为long?float?double?decimal?

       非可空类型隐式转换为可空类型也遵循上表中的转换规则,即int隐式转换为long?float?double?decimal?

       可空类型不能隐式转换为非可空类型,此时必须进行显式转换,如下一节所述。这是因为可空类型的值可以是null,但非可空类型不能表示这个值。

2. 显式转换方式

有许多场合不能隐式地转换类型,否则编译器会报告错误。下面是不能进行隐式转换的一些场合:

       int转换为short——会丢失数据

       int转换为uint——会丢失数据

       uint转换为int——会丢失数据

       float转换为int——会丢失小数点后面的所有数据

       任何数字类型转换为char ——会丢失数据

       decimal转换为任何数字类型——因为decimal 类型的内部结构不同于整数和浮点数

       int? 转换为int——可空类型的值可以是null

但是,可以使用cast显式执行这些转换。在把一种类型转换为另一种类型时,要迫使编译器进行转换。类型转换的一般语法如下:

long val = 30000;

int i = (int)val;   // A valid cast. The maximum int is 2147483647

这表示,把转换的目标类型名放在要转换的值之前的圆括号中。对于熟悉C的程序员来说,这是数据类型转换的典型语法。对于熟悉C++数据类型转换关键字(如static_cast)的程序员来说,这些关键字在C#中不存在,必须使用C风格的旧语法。

这种类型转换是一种比较危险的操作,即使在从long转换为int这样简单的转换过程中,如果原来long的值比int的最大值还大,就会出问题:

long val = 3000000000;

int i = (int)val;         // An invalid cast. The maximum int is 2147483647

在本例中,不会报告错误,也得不到期望的结果。如果运行上面的代码,结果存储在i中,则其值为:

1294967296

最好假定显式数据转换不会给出希望的结果。如前所述,C#提供了一个checked运算符,使用它可以测试操作是否会产生算术溢出。使用这个运算符可以检查数据类型转换是否安全,如果不安全,就会让运行库抛出一个溢出异常:

long val = 3000000000;

int i = checked ((int)val);

记住,所有的显式数据类型转换都可能不安全,在应用程序中应包含这样的代码,处理可能失败的数据类型转换。第12章将使用try和 catch语句引入结构化异常处理。

使用数据类型转换可以把大多数数据从一种基本类型转换为另一种基本类型。例如:给price加上0.5,再把结果转换为int

double price = 25.30;

int approximatePrice = (int)(price + 0.5);

这么做的代价是把价格四舍五入为最接近的美元数。但在这个转换过程中,小数点后面的所有数据都会丢失。因此,如果要使用这个修改过的价格进行更多的计算,最好不要使用这种转换;如果要输出全部计算完或部分计算完的近似值,不希望用小数点后面的数据去麻烦用户,这种转换是很好的。

下面的例子说明了把一个无符号的整数转换为char型时,会发生的情况:

ushort c = 43;

char symbol = (char)c;

Console.WriteLine(symbol);

结果是ASCII数为43的字符,即+号。可以尝试数字类型之间的任何转换(包括char),这种转换是成功的,例如把decimal转换为char,或把char转换为decimal。

值类型之间的转换并不仅限于孤立的变量。还可以把类型为double的数组元素转换为类型为int的结构成员变量:

struct ItemDetails

{

   public string Description;

   public int ApproxPrice;

}

//...

double[] Prices = { 25.30, 26.20, 27.40, 30.00 };

ItemDetails id;

id.Description = "Whatever";

id.ApproxPrice = (int)(Prices[0] + 0.5);

要把一个可空类型转换为非可空类型,或转换为另一个可空类型,但可能会丢失数据,就必须使用显式转换。重要的是,在底层基本类型相同的元素之间进行转换时,就一定要使用显式转换,例如int?转换为int,或float?转换为float。这是因为可空类型的值可以是null,非可空类型不能表示这个值。只要可以在两个非可空类型之间进行显式转换,对应可空类型之间的显式转换就可以进行。但如果从可空类型转换为非可空类型,且变量的值是null,就会抛出InvalidOperationException。例如:

int? a = null;

int  b = (int)a;     // Will throw exception

使用显式的数据类型转换方式,并小心使用这种技术,就可以把简单值类型的任何实例转换为另一种类型。但在进行显式的类型转换时有一些限制,例如值类型,只能在数字、char类型和enum类型之间转换。不能直接把Boolean数据类型转换为其他类型,也不能把其他类型转换为Boolean数据类型。

如果需要在数字和字符串之间转换,.NET类库提供了一些方法。Object类有一个ToString()方法,该方法在所有的.NET预定义类型中都进行了重写,返回对象的字符串表示:

int i = 10;

string s = i.ToString();

同样,如果需要分析一个字符串,获得一个数字或Boolean值,就可以使用所有预定义值类型都支持的Parse方法:

string s = "100";

int i = int.Parse(s);

Console.WriteLine(i + 50);   // Add 50 to prove it is really an int

注意,如果不能转换字符串(例如要把字符串Hello转换为一个整数),Parse方法就会注册一个错误,抛出一个异常。第12章将介绍异常。

5.2.2  装箱和拆箱

第2章介绍了所有类型,包括简单的预定义类型,例如int和char,以及复杂类型,例如从Object类型中派生的类和结构。下面可以像处理对象那样处理字面值:

string s = 10.ToString();

但是,C#数据类型可以分为在堆栈上分配内存的值类型和在堆上分配内存的引用类型。如果int不过是堆栈上一个4字节的值,该如何在它上面调用方法?

C#的实现方式是通过一个有点魔术性的方式,即装箱(boxing)。装箱和拆箱(unboxing)可以把值类型转换为引用类型,或把引用类型转换为值类型。这已经在数据类型转换一节中介绍过了,即把值转换为object类型。装箱用于描述把一个值类型转换为引用类型。运行库会为堆上的对象创建一个临时的引用类型“box”。

该转换是隐式进行的,如上面的例子所述。还可以手工进行转换:

int i = 20;

object o = i;

拆箱用于描述相反的过程,即以前装箱的值类型转换回引用类型。这里使用术语“cast”,是因为这种数据类型转换是显式进行的。其语法类似于前面的显式类型转换:

int i = 20;

object o = i;     // Box the int

int j = (int)o;    // Unbox it back into an int

只能把以前装箱的变量再转换为值类型。当o不是装箱后的int型时,如果执行上面的代码,就会在运行期间抛出一个异常。

这里有一个警告。在拆箱时,必须非常小心,确保得到的值变量有足够的空间存储拆箱的值中的所有字节。例如,C#的int只有32位,所以把long值(64位) 拆箱为int时,会产生一个InvalidCastException异常:

long a = 333333423;

object b = (object)a;

int c = (int)b;

5.3  对象的相等比较

在讨论了运算符,并简要介绍了等于运算符后,就应考虑在处理类和结构的实例时相等意味着什么。理解对象相等比较的机制对编写逻辑表达式非常重要,另外,对实现运算符重载和数据类型转换也非常重要,本章的后面将讨论运算符重载。

对象相等比较的机制对于引用类型(类的实例)的比较和值类型(基本数据类型,结构或枚举的实例)的比较来说是不同的。下面分别介绍引用类型和值类型的相等比较。

5.3.1  引用类型的相等比较

System.Object的一个初看上去令人惊讶的方面是它定义了3个不同的方法,来比较对象的相等性:ReferenceEquals()和Equals()的两个版本。再加上比较运算符==,实际上有4种进行相等比较的方式。这些方法有一些微妙的区别,下面就介绍这些方法。

1. ReferenceEquals()方法

ReferenceEquals()是一个静态方法,测试两个引用是否指向类的同一个实例,即两个引用是否包含内存中的相同地址。作为静态方法,它不能重写,所以只能使用System.Object的实现代码。如果提供的两个引用指向同一个对象实例,ReferenceEquals()总是返回true,否则就返回false。但是它认为null等于null:

SomeClass x, y;

x = new SomeClass();

y = new SomeClass();

bool B1 = ReferenceEquals(null, null);                //return true

bool B2 = ReferenceEquals(null, x);                //return false

bool B3 = ReferenceEquals(x, y);                  //return false because x and y

                                            //point to different objects

2. 虚拟的Equals()方法

Equals()虚拟版本的System.Object实现代码也比较引用。但因为这个方法是虚拟的,所以可以在自己的类中重写它,按值来比较对象。特别是如果希望类的实例用作字典中的键,就需要重写这个方法,以比较值。否则,根据重写Object.GetHashCode()的方式,包含对象的字典类要么不工作,要么工作的效率非常低。在重写Equals()方法时要注意,重写的代码不会抛出异常。这是因为如果抛出异常,字典类就会出问题,一些在内部调用这个方法的.NET基类也可能出问题。

3. 静态的Equals()方法

Equals()的静态版本与其虚拟实例版本的作用相同,其区别是静态版本带有两个参数,并对它们进行相等比较。这个方法可以处理两个对象中有一个是null的情况,因此,如果一个对象可能是null,这个方法就可以抛出异常,提供了额外的保护。静态重载版本首先要检查它传送的引用是否为null。如果它们都是null,就返回true(因为null与null相等)。如果只有一个引用是null,就返回false。如果两个引用都指向某个对象,它就调用Equals()的虚拟实例版本。这表示在重写Equals()的实例版本时,其效果相当于也重写了静态版本。

4. 比较运算符==

最好将比较运算符看作是严格值比较和严格引用比较之间的中间选项。在大多数情况下,下面的代码:

bool b = (x == y);          //x, y object references

表示比较引用。但是,如果把一些类看作值,其含义就会比较直观。在这些情况下,最好重写比较运算符,以执行值的比较。后面将讨论运算符的重载,但显然它的一个例子是System.String类,Microsoft重写了这个运算符,比较字符串的内容,而不是它们的引用。

5.3.2  值类型的相等比较

在进行值类型的相等比较时,采用与引用类型相同的规则:ReferenceEquals()用于比较引用,Equals()用于比较值,比较运算符可以看作是一个中间项。但最大的区别是值类型需要装箱,才能把它们转换为引用,才能对它们执行方法。另外,Microsoft已经在System.ValueType类中重载了实例方法Equals(),以便对值类型进行合适的相等测试。如果调用sA.Equals(sB),其中sA和sB是某个结构的实例,则根据sA和sB是否在其所有的字段中包含相同的值,而返回true或false。另一方面,在默认情况下,不能对自己的结构重载==运算符。在表达式中使用(sA==sB)会导致一个编译错误,除非在代码中为结构提供了==的重载版本。

另外,ReferenceEquals()在应用于值类型时,总是返回false,因为为了调用这个方法,值类型需要装箱到对象中。即使使用下面的代码:

bool b = ReferenceEquals(v, v);          //v is a variable of some value type

也会返回false,因为在转换每个参数时,v都会被单独装箱,这意味着会得到不同的引用。调用ReferenceEquals()来比较值类型实际上没有什么意义。

尽管System.ValueType提供的Equals()默认重载肯定足以应付绝大多数自定义的结构,但仍可以为自己的结构重写它,以提高性能。另外,如果值类型包含作为字段的引用类型,就需要重写Equals(),以便为这些字段提供合适的语义,因为Equals()的默认重写版本仅比较它们的地址。

5.4  运算符重载

本节将介绍为类或结构定义的另一种类型的成员:运算符重载。

C++开发人员应很熟悉运算符重载。但是,因为这个概念对Java和VB开发人员来说是全新的,所以这里要解释一下。C++开发人员可以直接跳到主要示例上。

运算符重载的关键是在类实例上不能总是只调用方法或属性,有时还需要做一些其他的工作,例如对数值进行相加、相乘或逻辑操作,如比较对象等。假定要定义一个类,表示一个数学矩阵,在数学中,矩阵可以相加和相乘,就像数字一样。所以可以编写下面的代码:

Matrix a, b, c;

// assume a, b and c have been initialized

Matrix d = c * (a + b);

通过重载运算符,就可以告诉编译器,+和*对Matrix进行什么操作,以编写上面的代码。如果用不支持运算符重载的语言编写代码,就必须定义一个方法,以执行这些操作,结果肯定不太直观,如下所示。

Matrix d = c.Multiply(a.Add(b));

学习到现在,像+*这样的运算符只能用于预定义的数据类型,原因很简单:编译器认为所有常见的运算符都是用于这些数据类型的,例如,它知道如何把两个long加起来,或者如何从一个double中减去另一个double,并生成合适的中间语言代码。但在定义自己的类或结构时,必须告诉编译器:什么方法可以调用,每个实例存储了什么字段等所有的信息。同样,如果要在自己的类上使用运算符,就必须告诉编译器相关的运算符在这个类中的含义。此时就要定义运算符重载。

要强调的另一个问题是重载不仅仅限于算术运算符。还需要考虑比较运算符 ==、<、>、!=、>=和<=。例如,语句if(a==b)。对于类,这个语句在默认状态下会比较引用a和b,检测这两个引用是否指向内存中的同一个地址,而不是检测两个实例是否包含相同的数据。对于string类,这种操作就会重写,比较字符串实际上就是比较每个字符串的内容。可以对自己的类进行这样的操作。对于结构,==运算符在默认状态下不做任何工作。试图比较两个结构,看看它们是否相等,就会产生一个编译错误,除非显式重载了==,告诉编译器如何进行比较。

在许多情况下,重载运算符允许生成可读性更高、更直观的代码,包括:

       在数学领域中,几乎包括所有的数学对象:坐标、矢量、矩阵、张量和函数等。如果编写一个程序执行某些数学或物理建模,肯定会用类表示这些对象。

       图形程序在计算屏幕上的位置时,也使用数学或相关的坐标对象。

       表示大量金钱的类(例如,在财务程序中)

       字处理或文本分析程序也有表示语句、子句等的类,可以使用运算符把语句连接在一起(这是字符串连接的一种比较复杂的版本)

另外,有许多类与运算符重载并不相关。不恰当地使用运算符重载,会使使用类型的代码很难理解。例如,把两个DateTime对象相乘,在概念上没有任何意义。

5.4.1  运算符的工作方式

为了理解运算符是如何重载的,考虑一下在编译器遇到运算符时会发生什么样的情况是很有用的——我们用相加运算符+作为例子来讲解。假定编译器遇到下面的代码:

int a = 3;

uint b = 2;

double d = 4.0;

long l = a + b;

double x = d + a;

会发生什么情况:

long l = a + b;

编译器知道它需要把两个整数加起来,并把结果赋予long。调用一个方法把数字加在一起时,表达式a+b是一种非常直观、方便的语法。该方法带有两个参数a和b,并返回它们的和。所以它完成的任务与任何方法调用是一样的—— 它会根据参数类型查找最匹配的+运算符重载,这里是带两个整数参数的+运算符重载。与一般的重载方法一样,预定义的返回类型不会因为调用的方法版本而影响编译器的选择。在本例中调用的重载方法带两个int类型参数,返回一个int,这个返回值随后会转换为long。

下一行代码让编译器使用+运算符的另一个重载版本:

double x = d + a;

在这个例子中,参数是一个double类型的数据和一个int类型的数据,但+运算符没有带这种复合参数的重载形式,所以编译器认为,最匹配的+运算符重载是把两个double作为其参数的版本,并隐式地把int转换为double。把两个double加在一起与把两个整数加在一起完全不同,浮点数存储为一个尾数和一个指数。把它们加在一起要按位移动一个double的尾数,让两个指数有相同的值,然后把尾数加起来,移动所得尾数的位,调整其指数,保证答案有尽可能高的精度。

现在,看看如果编译器遇到下面的代码,会发生什么:

Vector vect1, vect2, vect3;

// initialise vect1 and vect2

vect3 = vect1 + vect2;

vect1 = vect1*2;

其中,Vector是结构,稍后再定义它。编译器知道它需要把两个Vector加起来,即vect1 和 vect2。它会查找+运算符的重载,把两个Vector实例作为参数。

如果编译器找到这样的重载版本,就调用它的实现代码。如果找不到,就要看看有没有可以用作最佳匹配的其他+运算符重载,例如某个运算符重载的参数是其他数据类型,但可以隐式地转换为Vector实例。如果编译器找不到合适的运算符重载,就会产生一个编译错误,就像找不到其他方法调用的合适重载一样。

5.4.2  运算符重载的示例:Vector结构

本节将开发一个结构Vector,来演示运算符重载,这个Vector结构表示一个三维矢量。如果数学不是你的强项,不必担心,我们会使这个例子尽可能简单。三维矢量只是三个(double)数字的一个集合,说明物体和原点之间的距离,表示数字的变量是x、y和z,x表示物体与原点在x方向上的距离,y表示它与原点在y方向上的距离,z表示高度。把这3个数字组合起来,就得到总距离。例如,如果x=3.0, y=3.0, z=1.0,一般可以写作(3.0, 3.0, 1.0),表示物体与原点在x方向上的距离是3,与原点在y方向上的距离是3,高度为1。

矢量可以与矢量或数字相加或相乘。在这里我们使用术语“标量”(scalar),它是数字的数学用语—— 在C#中,就是一个double。相加的作用是很明显的。如果先移动(3.0, 3.0, 1.0),再移动(2.0, –4.0, –4.0),总移动量就是把这两个矢量加起来。矢量的相加是指把每个元素分别相加,因此得到(5.0, –1.0,–3.0)。此时,数学表达式总是写成c=a+b,其中a和b是矢量,c是结果矢量。这与使用Vector结构的方式是一样的。

注意:

这个例子是作为一个结构来开发的,而不是类,但这并不重要。运算符重载用于结构和类时,其工作方式是一样的。

下面是Vector的定义—— 包含成员字段、构造函数和一个ToString()重写方法,以便查看Vector的内容,最后是运算符重载:

namespace Wrox.ProCSharp.OOCSharp

{

   struct Vector

   {

      public double x, y, z;

      public Vector(double x, double y, double z)

      {

         this.x = x;

         this.y = y;

         this.z = z;

      }

      public Vector(Vector rhs)

      {

         x = rhs.x;

         y = rhs.y;

         z = rhs.z;

      }

      public override string ToString()

      {

         return "( " + x + " , " + y + " , " + z + " )";

      }

这里提供了两个构造函数,通过传递每个元素的值,或者提供另一个复制其值的Vector,来指定矢量的初始值。第二个构造函数带一个Vector参数,通常称为复制构造函数,因为它们允许通过复制另一个实例来初始化一个类或结构实例。注意,为了简单起见,把字段设置为public。也可以把它们设置为private,编写相应的属性来访问它们,这样做不会改变这个程序的功能,只是代码会复杂一些。

下面是Vector结构的有趣部分—— 为+运算符提供支持的运算符重载:

      public static Vector operator + (Vector lhs, Vector rhs)

      {

         Vector result = new Vector(lhs);

         result.x += rhs.x;

         result.y += rhs.y;

         result.z += rhs.z;

         return result;

      }

   }

}

运算符重载的声明方式与方法的声明方式相同,但operator关键字告诉编译器,它实际上是一个运算符重载,后面是相关运算符的符号,在本例中就是+。返回类型是在使用这个运算符时获得的类型。在本例中,把两个矢量加起来会得到另一个矢量,所以返回类型就是Vector。对于这个+运算符重载,返回类型与包含类一样,但这种情况并不是必需的。两个参数就是要操作的对象。对于二元运算符(带两个参数),如+和-运算符,第一个参数是放在运算符左边的值,第二个参数是放在运算符右边的值。

C#要求所有的运算符重载都声明为public和static,这表示它们与它们的类或结构相关联,而不是与实例相关联,所以运算符重载的代码体不能访问非静态类成员,也不能访问this标识符;这是可以的,因为参数提供了运算符执行任务所需要知道的所有数据。

前面介绍了声明运算符+的语法,下面看看运算符内部的情况:

      {

         Vector result = new Vector(lhs);

         result.x += rhs.x;

         result.y += rhs.y;

         result.z += rhs.z;

         return result;

      }

这部分代码与声明方法的代码是完全相同的,显然,它返回一个矢量,其中包含前面定义的lhs和rhs的和,即把x、y和z分别相加。

下面需要编写一些简单的代码,测试Vector结构:

      static void Main()

      {

         Vector vect1, vect2, vect3;

         vect1 = new Vector(3.0, 3.0, 1.0);

         vect2 = new Vector(2.0,­­­4.0,4.0);

         vect3 = vect1 + vect2;

         Console.WriteLine("vect1 = " + vect1.ToString());

         Console.WriteLine("vect2 = " + vect2.ToString());

         Console.WriteLine("vect3 = " + vect3.ToString());

      }

把这些代码保存为Vectors.cs,编译并运行它,结果如下:

Vectors

vect1 = ( 3 , 3 , 1 )

vect2 = ( 2 ,4 ,4 )

vect3 = ( 5 ,1 ,3 )

1. 添加更多的重载

矢量除了可以相加之外,还可以相乘、相减,比较它们的值。本节通过添加几个运算符重载,扩展了这个例子。这并不是一个功能全面的真实的Vector类型,但足以说明运算符重载的其他方面了。首先要重载乘法运算符,以支持标量和矢量的相乘以及矢量和矢量的相乘。

矢量乘以标量只是矢量的元素分别与标量相乘,例如,2 * (1.0, 2.5, 2.0)就等于(2.0, 5.0, 4.0)。相关的运算符重载如下所示。

      public static Vector operator * (double lhs, Vector rhs)

      {

         return new Vector(lhs * rhs.x, lhs * rhs.y, lhs * rhs.z);

      }

但这还不够,如果a和b声明为Vector 类型,就可以编写下面的代码:

b = 2 * a;

编译器会隐式地把整数2转换为double类型,以匹配运算符重载的签名。但不能编译下面的代码:

b = a * 2;

编译器处理运算符重载的方式和处理方法重载的方式是一样的。它会查看给定运算符的所有可用重载,找到与之最匹配的那个运算符重载。上面的语句要求第一个参数是一个Vector,第二个参数是一个整数,或者可以隐式转换为整数的其他数据类型。我们没有提供这样一个重载。有一个运算符重载,其参数是一个double和一个Vector,但编译器不能改变参数的顺序,所以这是不行的。还需要显式定义一个运算符重载,其参数是一个Vector和一个double,有两种方式可以定义这样一个运算符重载,第一种方式和处理所有运算符的方式一样,显式中断矢量相乘操作:

      public static Vector operator * (Vector lhs, double rhs)

      {

         return new Vector(rhs * lhs.x, rhs * lhs.y, rhs *lhs.z);

      }

假定已经编写了执行相乘操作的代码,最好重复使用该代码:

      public static Vector operator * (Vector lhs, double rhs)

      {

         return rhs * lhs;

      }

这段代码会告诉编译器,如果有Vector和double的相乘操作,编译器就使参数的顺序反序,调用另一个运算符重载。在某种程度上,喜欢哪一个是由用户自己决定的。在本章的示例代码中,我们使用第二个版本,它看起来比较简洁。利用这个版本可以编写出维护性更好的代码,因为不需要复制代码,就可在两个独立的重载中执行相乘操作。

要重载的下一个运算符是矢量相乘。在数学上,矢量相乘有两种方式,但这里我们感兴趣的是点积或内积,其结果实际上是一个标量。这就是我们介绍这个例子的原因,所以算术运算符不必返回与定义它们的类相同的类型。

在数学上,如果有两个矢量(x, y, z)和(X, Y, Z),其内积就是x*X + y*Y + z*Z的值。两个矢量这样相乘是很奇怪的,但这是很有效的,因为它可以用于计算各种其他的数。当然,如果要使用Direct3D 或DirectDraw编写代码来显示复杂的3D图形,在计算对象放在屏幕上的什么位置时,常常需要编写代码来计算矢量的内积,作为中间步骤。这里我们关心的是编写出double X = a*b,其中a和b是矢量,并计算出它们的点积。相关的运算符重载如下所示:

      public static double operator * (Vector lhs, Vector rhs)

      {

         return lhs.x * rhs.x + lhs.y * rhs.y + lhs.z * rhs.z;

      }

定义了算术运算符后,就可以用一个简单的测试方法来看看它们是否能正常运行:

static void Main()

{

   // stuff to demonstrate arithmetic operations

   Vector vect1, vect2, vect3;

   vect1 = new Vector(1.0, 1.5, 2.0);

   vect2 = new Vector(0.0, 0.0,10.0);

   vect3 = vect1 + vect2;

   Console.WriteLine("vect1 = " + vect1);

   Console.WriteLine("vect2 = " + vect2);

   Console.WriteLine("vect3 = vect1 + vect2 = " + vect3);

   Console.WriteLine("2*vect3 = " + 2*vect3);

   vect3 += vect2;

   Console.WriteLine("vect3+=vect2 gives " + vect3);

   vect3 = vect1*2;

   Console.WriteLine("Setting vect3=vect1*2 gives " + vect3);

   double dot = vect1*vect3;

   Console.WriteLine("vect1*vect3 = " + dot);

}

运行代码(Vectors2.cs),得到如下所示的结果:

Vectors2

vect1 = ( 1 , 1.5 , 2 )

vect2 = ( 0 , 0 ,10 )

vect3 = vect1 + vect2 = ( 1 , 1.5 ,8 )

2*vect3 = ( 2 , 3 ,16 )

vect3+=vect2 gives ( 1 , 1.5 ,18 )

Setting vect3=vect1*2 gives ( 2 , 3 , 4 )

vect1*vect3 = 14.5

这说明,运算符重载会给出正确的结果,但如果仔细看看测试代码,就会惊奇地注意到,实际上我们使用的是没有重载的运算符—— 相加赋值运算符+=:

   vect3 += vect2;

   Console.WriteLine("vect3 += vect2 gives " + vect3);

虽然+=一般用作运算符,但实际上其操作分为两个部分:相加和赋值。与C++不同,C#不允许重载=运算符,但如果重载+运算符,编译器就会自动使用+运算符的重载来执行+=运算符的操作。–=、&=、*=和/=赋值运算符也遵循此规则。

2. 比较运算符的重载

C#中有6个比较运算符,它们分为3对:

       == !=

       > <

       >= <=

C#要求成对重载比较运算符。如果重载了==,也必须重载!=,否则会产生编译错误。另外,比较运算符必须返回bool类型的值。这是它们与算术运算符的根本区别。两个数相加或相减的结果,理论上取决于数的类型。而两个Vector的相乘会得到一个标量。另一个例子是.NET基类System.DateTime,两个DateTime实例相减,得到的结果不是DateTime,而是一个System.TimeSpan实例,但比较运算得到的如果不是bool类型的值,就没有任何意义。

注意:

在重载==!=时,还应重载从System.Object中继承的Equals()GetHashCode()方法,否则会产生一个编译警告。原因是Equals()方法应执行与==运算符相同的相等逻辑。

除了这些区别外,重载比较运算符所遵循的规则与算术运算符相同。但比较两个数并不像想象的那么简单,例如,如果比较两个对象引用,就是比较存储对象的内存地址。比较运算符很少进行这样的比较,所以必须编写运算符,比较对象的值,返回相应的布尔结果。下面给Vector结构重载==和!=运算符。首先是==的执行代码:

      public static bool operator = = (Vector lhs, Vector rhs) 

      {

         if (lhs.x = = rhs.x && lhs.y = = rhs.y && lhs.z = = rhs.z)

            return true;

         else

            return false;

      }

这种方式仅根据矢量组成部分的值,来对它们进行等于比较。对于大多数结构,这就是我们希望的,但在某些情况下,可能需要仔细考虑等于的含义,例如,如果有嵌入的类,是应比较对同一个对象的引用(浅度比较),还是应比较对象的值是否相等(深度比较)?

注意:

不要通过调用从System.Object中继承的Equals()方法的实例版本,来重载比较运算符,如果这么做,在objAnull时计算(objA==objB),这会产生一个异常,因为.NET运行库会试图计算null.Equals(objB)。采用其他方法(重写Equals()方法,调用比较运算符)比较安全。

还需要重载运算符!=,采用的方式如下:

      public static bool operator != (Vector lhs, Vector rhs)

      {

      return ! (lhs == rhs);

      }

像往常一样,用一些测试代码检查重写方法的工作情况,这次定义3个Vector对象,并进行比较:

      static void Main()

      {

         Vector vect1, vect2, vect3;

         vect1 = new Vector(3.0, 3.0,10.0);

         vect2 = new Vector(3.0, 3.0,10.0);

         vect3 = new Vector(2.0, 3.0, 6.0);

         Console.WriteLine("vect1= =vect2 returns  " + (vect1= =vect2));

         Console.WriteLine("vect1= =vect3 returns  " + (vect1= =vect3));

         Console.WriteLine("vect2= =vect3 returns  " + (vect2= =vect3));

         Console.WriteLine();

         Console.WriteLine("vect1!=vect2 returns  " + (vect1!=vect2));

         Console.WriteLine("vect1!=vect3 returns  " + (vect1!=vect3));

         Console.WriteLine("vect2!=vect3 returns  " + (vect2!=vect3));

      }

编译这些代码(下载代码中的Vectors3.cs),会得到一个编译器警告,因为我们没有为Vector重写Equals(),对于本例,这是不重要的,所以忽略它。

csc Vectors3.cs

Microsoft (R) Visual C# 2005 Compiler version 8.00.50215.33

for Microsoft (R) Windows (R) Framework version 2.0.50215

Copyright (C) Microsoft Corporation 2001-2005. All rights reserved.

Vectors3.cs(5,11): warning CS0660: 'Wrox.ProCSharp.OOCSharp.Vector' defines

        operator = = or operator != but does not override Object.Equals(object o)

Vectors3.cs(5,11): warning CS0661: 'Wrox.ProCSharp.OOCSharp.Vector' defines

        operator = = or operator != but does not override Object.GetHashCode()

在命令行上运行该示例,生成如下结果:

Vectors3

vect1= =vect2  returns  True

vect1= =vect3  returns  False

vect2= =vect3  returns  False

vect1!=vect2   returns  False

vect1!=vect3   returns  True

vect2!=vect3   returns  True

3.  可以重载的运算符

并不是所有的运算符都可以重载。可以重载的运算符如表5-5所示。

  5-5

   

   

算术二元运算符

+, *, /, , %

算术一元运算符

+, , ++, ––

按位二元运算符

&, |, ^, <<, >>

按位一元运算符

!, ~, true, false

truefalse运算符必须成对重载

比较运算符

==, !=, >=, <, <=, >

必须成对重载

赋值运算符

+=,=,*=,/=,>>=,<<=,%=

,&=,|=,^=

不能显式重载这些运算符,在重写单个运算符如+,,%等时,它们会被隐式重写

索引运算符

[]

不能直接重载索引运算符。第2章介绍的索引器成员类型允许在类和结构上支持索引运算符

数据类型转换运算符

()

不能直接重载数据类型转换运算符。用户定义的数据类型转换(本章的后面介绍)允许定义定制的数据类型转换

5.5  用户定义的数据类型转换

本章前面介绍了如何在预定义的数据类型之间转换数值,这是通过数据类型转换过程来完成的。C#允许进行两种不同数据类型的转换:隐式转换和显式转换。

显式转换要在代码中显式标记转换,其方法是在圆括号中写出目标数据类型:

   int I = 3;

   long l = I;             // implicit

   short s = (short)I;       // explicit

对于预定义的数据类型,当数据类型转换可能失败或丢失某些数据时,需要显式转换。    例如:

       int转换为short时,因为short可能不够大,不能包含转换的数值。

       把有符号的数据转换为无符号的数据,如果有符号的变量包含一个负值,会得到不正确的结果

       在把浮点数转换为整数数据类型时,数字的小数部分会丢失。

       把可空类型转换为非可空类型,null值会导致异常。

此时应在代码中进行显式转换,告诉编译器你知道这会有丢失数据的危险,因此编写代码时要把这种可能性考虑在内。

C#允许定义自己的数据类型(结构和类),这意味着需要某些工具支持在自己的数据类型之间进行类型转换。方法是把数据类型转换定义为相关类的一个成员运算符,数据类型转换必须标记为隐式或显式,以说明如何使用它。我们应遵循与预定义数据类型转换相同的规则,如果知道无论在源变量中存储什么值,数据类型转换总是安全的,就可以把它定义为隐式转换。另一方面,如果某些数值可能会出错,例如丢失数据或抛出异常,就应把数据类型转换定义为显式转换。

提示:

如果源数据值会使数据类型转换失败,或者可能会抛出异常,就应把定制数据类型转换定义为显式转换。

定义数据类型转换的语法类似于本章前面介绍的重载运算符。但它们是不一致的,数据类型转换在某种情况下可以看作是一种运算符,其作用是从源类型转换为目标类型。为了说明这个语法,下面的代码是从本节后面介绍的结构Currency示例中节选的:

public static implicit operator float (Currency value)

{

   // processing

}

运算符的返回类型定义了数据类型转换操作的目标类型,它有一个参数,即要转换的源对象。这里定义的数据类型转换可以隐式地把Currency的值转换为float型。注意,如果数据类型转换声明为隐式,编译器可以隐式或显式地使用这个转换。如果数据类型转换声明为显式,编译器就只能显式地使用它。与其他运算符重载一样,数据类型转换必须声明为public和static。

注意:

C++开发人员应注意,这种情况与C++是不同的,在C++中,数据类型转换是类的实例成员。

5.5.1  执行用户定义的类型转换

本节将在示例SimpleCurrency(和往常一样,其代码可以下载)中介绍隐式和显式使用用户定义的数据类型转换。在这个示例中,定义一个结构Currency,它包含一个正的USD($)钱款。C#为此提供了decimal类型,但如果要进行比较复杂的财务处理,仍可以编写自己的结构和类来表示钱款,在这样的类上执行特定的方法。

注意:

数据类型转换的语法对于结构和类是一样的。我们的示例定义了一个结构,但如果把Currency声明为类,也是可以的。

首先,结构Currency的定义如下所示。

   struct Currency

   {

      public uint Dollars;

      public ushort Cents;

      public Currency(uint dollars, ushort cents)

      {

         this.Dollars = dollars;

         this.Cents = cents;

      }

      public override string ToString()

      {

         return string.Format("${0}.{1,2:00}", Dollars,Cents);

      }

Dollars和Cents字段使用无符号的数据类型,可以确保Currency实例只能包含正值。这样限制,是为了在后面说明显式转换的一些要点。可以像这样使用一个类来存储公司员工的薪水信息。人们的薪水不会是负值!为了使类比较简单,我们把字段声明为public,但通常应把它们声明为private,并为Dollars和Cents字段定义相应的属性。

下面先假定要把Currency实例转换为float值,其中float值的整数部分表示美元,换言之,应编写下面的代码:

   Currency balance = new Currency(10,50);

   float f = balance; // We want f to be set to 10.5

为此,需要定义一个数据类型转换。给Currency定义添加下述代码:

      public static implicit operator float (Currency value)

      {

         return value.Dollars + (value.Cents/100.0f);

      }

这个数据类型转换是隐式的。在本例中这是一个合理的选择,因为在Currency定义中,可以存储在Currency中的值也都可以存储在float中。在这个转换中,不应出现任何错误。

注意:

这里有一点欺骗性:实际上,当把uint转换为float时,会有精确度的损失,但Microsoft认为这种错误并不重要,因此把从uintfloat的转换都当做隐式转换。

但是,如果把float转换为Currency,就不能保证转换肯定成功了;float可以存储负值,而Currency实例不能,float存储的数值的量级要比Currency的(uint) Dollars字段大得多。所以,如果float包含一个不合适的值,把它转换为Currency就会得到意想不到的结果。因此,从float转换到Currency就应定义为显式转换。下面是我们的第一次尝试,这次不会得到正确的结果,但对解释原因是有帮助的:

      public static explicit operator Currency (float value)

      {

         uint dollars = (uint)value;

         ushort cents = (ushort)((valuedollars)*100);

         return new Currency(dollars, cents);

      }

下面的代码可以成功编译:

   float amount = 45.63f;

   Currency amount2 = (Currency)amount;

但是,下面的代码会抛出一个编译错误,因为试图隐式地使用一个显式的数据类型转换:

   float amount = 45.63f;

   Currency amount2 = amount;   // wrong

把数据类型转换声明为显式,就是警告开发人员要小心,因为可能会丢失数据。但这不是我们希望的Currency结构的执行方式。下面编写一个测试程序,运行示例。其中有一个Main()方法,它实例化了一个Currency结构,试图进行几个转换。在这段代码的开头,以两种不同的方式计算balance的值(因为要使用它们来说明后面的内容):

static void Main()

{

   try

   {

      Currency balance = new Currency(50,35);

      Console.WriteLine(balance);

      Console.WriteLine("balance is " + balance);

      Console.WriteLine("balance is (using ToString()) " +

         balance.ToString());

      float balance2= balance;

      Console.WriteLine("After converting to float, = " + balance2);

      balance = (Currency) balance2;

      Console.WriteLine("After converting back to Currency, = " + balance);

      Console.WriteLine("Now attempt to convert out of range value of " +

                        "$100.00 to a Currency:");

      checked

      {

         balance = (Currency) (50.5);

         Console.WriteLine("Result is " + balance.ToString());

      }

   }

   catch(Exception e)

   {

      Console.WriteLine("Exception occurred: " + e.Message);

   }

}

注意,我们把所有的代码都放在一个try块中,来捕获在数据类型转换过程中发生的任何异常。在checked块中还添加了把超出范围的值转换为Currency的测试代码,所以,负值是肯定会被捕获的。运行这段代码,得到如下所示的结果:

SimpleCurrency

50.35

Balance is $50.35

Balance is (using ToString()) $50.35

After converting to float, = 50.35

After converting back to Currency, = $50.34

Now attempt to convert out of range value of$100.00 to a Currency:

Result is $4294967246.60486

这个结果表示代码并没有像我们希望的那样工作。首先,从float转换回Currency得到一个错误的结果$50.34,而不是$50.35。其次,在试图转换明显超出范围的值时,没有生成异常。

第一个问题是由圆整错误引起的。如果类型转换用于把float转换为uint,计算机就会截去多余的数字,而不是圆整它。计算机以二进制方式存储数字,而不是十进制,小数部分0.35不能用二进制小数来表示(像1/3这样的小数不能表示为小数部分,它应等于循环小数0.3333)。所以,计算机最后存储了一个略小于0.35的值,它可以用二进制格式表示。把该数字乘以100,就会得到一个小于35的数字,截去了美分34。显然在本例中,这种由截去引起的错误是很严重的,避免该错误的方式是确保在数字转换过程中执行智能圆整操作。Microsoft编写了一个类System.Convert来完成该任务。System.Convert包含大量的静态方法来执行各种数字转换,我们需要使用的是Convert.ToUInt16()。注意,在使用System.Convert方法时会产生额外的性能损失,所以只应在需要时才使用它们。

下面看看为什么没有抛出期望的溢出异常。此处的问题是异常实际发生的位置根本不在Main()例程中——这是在转换运算符的代码中发生的,该代码在Main()方法中调用,而且没有标记为checked。

其解决方法是确保类型转换本身也在checked环境下进行。进行了这两个修改后,修订后的转换代码如下所示。

      public static explicit operator Currency (float value)

      {

         checked

         {

            uint dollars = (uint)value;

            ushort cents = Convert.ToUInt16((valuedollars)*100);

            return new Currency(dollars, cents);

         }

      }

注意,使用Convert.ToUInt16()计算小数,如上所示,但没有使用它计算数字的美元部分。在计算美元值时不需要使用System.Convert,因为在此我们希望截去float值。

注意:

System.Convert方法还执行它们自己的溢出检查。因此对于本例的情况,不需要把Convert.ToUInt16()的调用放在checked环境下。但把value显式转换为美元值仍需要checked环境。

这里没有给出这个新checked转换的结果,因为在本节后面还要对SimpleCurrency示例进行一些修改。

注意:

如果定义了一个使用非常频繁的数据类型转换,其性能也非常好,就可以不进行任何错误检查,如果对用户定义的转换和没有检查的错误进行了清晰的说明,这也是一种合法的解决方案。

1. 类之间的数据类型转换

Currency示例仅涉及到与float(一种预定义的数据类型)来回转换的类。实际上任何简单数据类型的转换都是可以自定义的。定义不同结构或类之间的数据类型转换是允许的,但有两个限制:

       如果某个类直接或间接继承了另一个类,就不能在这两个类之间进行数据类型转换(这些类型的类型转换已经存在)

       数据类型转换必须在源或目标数据类型定义的内部定义。

要说明这些要求,假定有如图5-1所示的类层次结构。

  5-1

换言之,类C和D间接派生于A。在这种情况下,在A、B、C或D之间惟一合法的类型转换就是类C和D之间的转换,因为这些类并没有互相派生。这段代码如下所示(假定希望数据类型转换是显式的,这是在用户定义的数据类型之间转换的通常情况):

   public static explicit operator D(C value)

   {

      // and so on

   }

   public static explicit operator C(D value)

   {

      // and so on

   }

对于这些数据类型转换,可以选择放置定义的地方—— 在C的类定义内部,或者在D的类定义内部,但不能在其他地方定义。C#要求把数据类型转换的定义放在源类(或结构)或目标类(或结构)的内部。它的边界效应是不能定义两个类之间的数据类型转换,除非可以编辑它们的源代码。这是因为,这样可以防止第三方把数据类型转换引入类中。

一旦在一个类的内部定义了数据类型转换,就不能在另一个类中定义相同的数据类型转换。显然,只能有一个数据类型转换,否则编译器就不知道该选择哪个数据类型转换了。

2. 基类和派生类之间的数据类型转换

要了解这些数据类型转换是如何工作的,首先看看源和目标的数据类型都是引用类型的情况。考虑两个类MyBase 和 MyDerived,其中MyDerived直接或间接派生于MyBase。

首先是从MyDerived 到 MyBase的转换,代码如下(假定可以使用构造函数):

MyDerived derivedObject = new MyDerived();

MyBase baseCopy = derivedObject;

在本例中,是从MyDerived 隐式地转换为 MyBase。这是因为对类MyBase的任何引用都可以引用类MyBase的对象或派生于MyBase的对象。在OO编程中,派生类的实例实际上是基类的实例,但加上了一些额外的信息。在基类上定义的所有函数和字段也都在派生类上定义了。

下面看看另一种方式,编写下面的代码:

MyBase derivedObject = new MyDerived();

MyBase baseObject = new MyBase();

MyDerived derivedCopy1 = (MyDerived) derivedObject;   // OK

MyDerived derivedCopy2 = (MyDerived) baseObject;      // Throws exception

上面的代码都是合法的C#代码(从句法的角度来看,是合法的),是把基类转换为派生类。但是,最后的一个语句在执行时会抛出一个异常。在进行数据类型转换时,会检查被引用的对象。因为基类引用实际上可以引用一个派生类实例,所以这个对象可能是要转换的派生类的一个实例。如果是这样,转换就会成功,派生的引用被设置为引用这个对象。但如果该对象不是派生类(或者派生于这个类的其他类)的一个实例,转换就会失败,抛出一个异常。

注意,编译器已经提供了基类和派生类之间的转换,这种转换实际上并没有对对象进行任何数据转换。如果要进行的转换是合法的,它们也仅是把新引用设置为对对象的引用。这些转换在本质上与自己定义的转换不同。例如,在前面的SimpleCurrency示例中,我们定义了Currency结构和float之间的转换。在float到Currency的转换中,则实例化了一个新Currency结构,并用要求的值进行初始化。在基类和派生类之间的预定义转换则不是这样。如果要把MyBase实例转换为MyDerived对象,其值根据MyBase实例的内容来确定,就不能使用数据类型转换语法。最合适的选项通常是定义一个派生类的构造函数,它的参数是一个基类实例,让这个构造函数执行相关的初始化:

class DerivedClass : BaseClass

{

   public DerivedClass(BaseClass rhs)

   {

      // initialize object from the Base instance

   }

   // etc.

3. 装箱和拆箱数据类型转换

前面主要讨论了基类和派生类之间的数据类型转换,其中,基类和派生类都是引用类型。其规则也适用于转换值类型,但在转换值类型时,不是仅仅复制引用,还必须复制一些数据。

当然,不能从结构或基本值类型中派生。所以基本结构和派生结构之间的转换总是基本类型或结构与System.Object之间的转换(理论上可以在结构和System.ValueType之间进行转换,但一般很少这么做)。

从结构(或基本类型)到object的转换总是一种隐式转换,因为这种转换是从派生类型到基本类型的转换,即第2章中简要介绍的装箱过程。例如,Currency结构:

Currency balance = new Currency(40,0);

object baseCopy = balance;

在执行上述隐式转换时,balance的内容被复制到堆上,放在一个装箱的对象上,BaseCopy对象引用设置为该对象。在后台发生的情况是:在最初定义Currency结构时,.NET Framework隐式地提供另一个(隐式)类,即装箱的Currency类,它包含与Currency结构相同的所有字段,但却是一个引用类型,存储在堆上。无论这个值类型是一个结构,还是一个枚举,定义它时都存在类似的装箱引用类型,对应于所有的基本值类型,如int、double和 uint。不能也不必在源代码中直接编程访问这些装箱类型,但在把一个值类型转换为object时,它们是在后台工作的对象。在隐式地把Currency 转换为 object时,会实例化一个装箱的 Currency实例,并用Currency结构中的所有数据进行初始化。在上面的代码中,BaseCopy对象引用的就是这个已装箱的Currency实例。通过这种方式,就可以实现从派生类到基类的转换,并且,值类型的语法与引用类型的语法一样。

转换的另一种方式称为拆箱。与在基本引用类型和派生引用类型之间的转换一样,这是一种显式转换,因为如果要转换的对象不是正确的类型,会抛出一个异常:

object derivedObject = new Currency(40,0);

object baseObject = new object();

Currency derivedCopy1 = (Currency)derivedObject;   // OK

Currency derivedCopy2 = (Currency)baseObject;     // Exception thrown

上述代码的工作方式与前面的引用类型一样。把derivedObject转换为 Currency会成功进行,因为derivedObject实际上引用的是装箱 Currency实例—— 转换的过程是把已装箱的 Currency对象的字段复制到一个新的Currency结构中。第二个转换会失败,因为baseObject没有引用已装箱的 Currency对象。

在使用装箱和拆箱时,这两个过程都把数据复制到新装箱和拆箱的对象上,理解这一点是非常重要的。这样,对装箱对象的操作就不会影响原来值类型的内容。

5.5.2  多重数据类型转换

在定义数据类型转换时必须考虑的一个问题是,如果在进行要求的数据类型转换时,C#编译器没有可用的直接转换方式,C#编译器就会寻找一种方式,把几种转换合并起来。例如,在Currency结构中,假定编译器遇到下面的代码:

Currency balance = new Currency(10,50);

long amount = (long)balance;

double amountD = balance;

首先初始化一个Currency实例,再把它转换为一个long。问题是不能定义这样的转换。但是,这段代码仍可以编译成功。因为编译器知道我们要定义一个从Currency到float的隐式转换,而且它知道如何显式地从float 转换为long。所以它会把这行代码编译为中间语言代码,首先把balance转换为float,再把结果转换为long。上述代码的最后一行也是这样,把balance转换为double型时,因为从Currency到 float的转换和从float 到double的转换都是隐式的,就可以在代码中把这个转换当作一种隐式转换。如果要显式地指定转换过程,可以编写如下代码:

Currency balance = new Currency(10,50);

long amount = (long)(float)balance;

double amountD = (double)(float)balance;

但是,在大多数情况下,这会使代码变得比较复杂,因此是不必要的。下面的代码会产生一个编译错误:

Currency balance = new Currency(10,50);

long amount = balance;

原因是编译器可以找到的最佳匹配的转换仍是首先转换为flost,再转换为long,但从float到long的转换需要显式指定。

所有这些都不会带来太多的麻烦。转换的规则是非常直观的,主要是为了防止在开发人员不知情的情况下丢失数据。但是,在定义数据类型转换时如果不小心,编译器就有可能指定一条导致不期望的结果的路径。例如,假定编写Currency结构的其他小组成员要把一个uint转换为Currency,而该uint中包含了美分的总数(美分不是美元,因为我们不希望丢掉美元的小数部分),为此应编写如下代码:

public static implicit operator Currency (uint value)

{

   return new Currency(value/100u, (ushort)(value%100));

} // Don't do this!

注意,在这段代码中,第一个100后面的u可以确保把value/100u解释为uint。如果写成value/100,编译器就会把它解释为一个int型的值,而不是uint型的值。

在这段代码中清楚地注释了“不要这么做”。下面说明其原因。看看下面的代码段,它把包含350的uint转换为一个Currency,再转换回uint。那么在执行完这段代码后,bal2中又将包含什么?

uint bal = 350;

Currency balance = bal;

uint bal2 = (uint)balance;

答案不是350,而是3!这是符合逻辑的。我们把350隐式地转换为Currency,得到balance.Dollars=3,balance.Cents=50。然后编译器进行通常的操作,为转换回uint指定最佳路径。balance最终会被隐式地转换为float型(其值为3.5),然后显式地转换为uint型,其值为3。

当然,转换为另一个数据类型后,再转换回来有时会丢失数据。例如,把包含5.8的float转换为int,再转换回float,会丢失数字中的小数部分,得到5,但这和一个整数被100整除的情况略有区别。Currency现在成了一种相当危险的类,它会对整数进行一些奇怪的操作。

问题是,在转换过程中如何解释整数是有矛盾的。从Currency到float的转换会把整数1解释为1美元,但从uint到Currency的转换会把这个整数解释为1美分,这是很糟糕的。如果希望类易于使用,就应确保所有的转换都按一种互相兼容的方式执行,即这些转换应得到相同的结果。在本例中,显然要重新编写从uint到Currency的转换,把整数值1解释为1美元:

public static implicit operator Currency (uint value)

{

   return new Currency(value, 0);

}

偶尔也会觉得这种新的转换方式可能根本不需要。但实际上这种转换方式是非常有用的。没有它,编译器在执行从uint到Currency的转换时,就只能通过float来进行。此时直接转换的效率要高得多,所以进行这种额外转换会提高性能,但需要确保它的结果与通过float进行转换得到的结果相同。在其他情况下,也可以为不同的预定义数据类型分别定义转换,让更多的转换隐式执行,而不是显式地执行,但本例不是这样。

测试这种转换是否成功,应确定无论使用什么转换路径,它都能得到相同的结果(而不是像在从float到int的转换过程中丢失数据那样)。Currency类就是一个很好的示例。下面的代码:

Currency balance = new Currency(50, 35);

ulong bal = (ulong) balance;

目前,编译器只能采用一种方式来执行这个转换:把Currency隐式地转换为float,再显式地转换为ulong。从float到ulong的转换需要显式指定,本例就显式指定了这个转换,所以编译是成功的。

但假定要添加另一个转换,从Currency隐式地转换为uint,就需要修改Currency结构,添加从uint到Currency的转换和从Currency到uint的转换,这段代码可以下载,作为SimpleCurrency2示例:

      public static implicit operator Currency (uint value)

      {

         return new Currency(value, 0);

      }

      public static implicit operator uint (Currency value)

      {

         return value.Dollars;

      }

现在,编译器从Currency转换到 ulong可以使用另一条路径:先从Currency隐式地转换为uint,再隐式地转换为ulong。该采用哪条路径? C#有一些规则(本书不详细讨论这些规则,读者可参阅MSDN文档说明),告诉编译器如何确定哪条是最佳路径。但最好自己设计转换,让所有的转换都得到相同的结果(但没有精确度的损失),此时编译器选择哪条路径就不重要了(在本例中,编译器会选择Currency→uint→ulong路径,而不是Currency→float→ulong路径)。

为了测试SimpleCurrency2示例,给SimpleCurrency的测试程序添加如下代码:

try

{

   Currency balance = new Currency(50,35);

   Console.WriteLine(balance);

   Console.WriteLine("balance is " + balance);

   Console.WriteLine("balance is (using ToString()) " + balance.ToString());

   uint balance3 = (uint) balance;

   Console.WriteLine("Converting to uint gives " + balance3);

运行这个示例,得到如下所示的结果:

SimpleCurrency2

50

balance is $50.35

balance is (using ToString()) $50.35

Converting to uint gives 50

After converting to float, = 50.35

After converting back to Currency, = $50.34

Now attempt to convert out of range value of$100.00 to a Currency:

Exception occurred: Arithmetic operation resulted in an overflow.

这个结果显示了到uint的转换是成功的,但丢失了Currency的美分部分(小数部分),把负的float 转换为 Currency也产生了预料中的溢出异常,因为float到Currency的转换本身定义了一个checked环境。

但是,这个输出结果也说明了进行转换时最后一个要注意的潜在问题:结果的第一行没有正确显示结余,应显示50,而不是$50.35。在下面的代码中:

   Console.WriteLine(balance);

   Console.WriteLine("balance is " + balance);

   Console.WriteLine("balance is (using ToString()) " + balance.ToString());

只有最后两行把Currency正确显示为一个字符串。这是为什么?问题是在把转换和方法重载合并起来时,会出现另一个不希望的错误源。下面用倒序的方式解释这段代码。

第三行的Console.WriteLine()语句显式调用Currency.ToString()方法,以确保Currency显示为一个字符串。第二行代码没有这么做。字符串"balance is "传送给Console.WriteLine(),告诉编译器这个参数应解释为字符串,因此要隐式地调用Currency.ToString()方法。

但第一行的Console.WriteLine()方法只是把原来的Currency结构传送给Console.Write Line()。目前Console.WriteLine()有许多重载,但它们的参数都不是Currency结构。所以编译器会到处搜索,看看它能把Currency转换为什么,以与Console.WriteLine()的一个重载方法匹配。如上所示,Console.WriteLine()的一个重载方法可以快速而高效地显示uint,且其参数是一个uint。因此应把Currency隐式地转换为uint。

实际上,Console.WriteLine()有另一个重载方法,它的参数是一个double,结果是显示该double的值。如果仔细看看第一个SimpleCurrency示例的结果,就会发现该结果的第一行就是使用这个重载方法把Currency显示为一个double。在这个示例中,没有直接把Currency转换为uint,所以编译器选择Currency→float→double作为可用于Console.WriteLine()重载方法的首选转换方式。但在SimpleCurrency2中可以直接转换为uint,所以编译器会选择后者。

如果方法调用带有多个重载方法,并要给该方法传送参数,而该参数的数据类型不匹配任何重载方法,就可以迫使编译器确定使用哪些转换方式进行数据转换,决定使用哪个重载方法(并进行相应的数据转换)。当然,编译器总是按逻辑和严格的规则来工作,但结果并不是我们所期望的。如果可能会出问题,最好显式指定转换路径。

5.6  小结

本章介绍了C#提供的标准运算符,描述了对象的相等机制,讨论了编译器如何把一种标准数据类型转换为另一种标准数据类型。还阐述了如何使用运算符重载在自己的数据类型上执行定制的运算符。最后,学习了运算符重载的一种特殊类型,即数据类型转换运算符,它允许用户指定如何将实例转换为其他数据类型。

第6章将介绍两个密切相关的成员类型:委托和事件,在自己的类型上也可以实现这两个成员类型,以支持基于事件的对象模型。

6委托和事件

回调(callback)函数是Windows编程的一个重要部分。如果您具备C或C++编程背景,就曾在许多Windows API中使用过回调。VB添加了AddressOf关键字后,开发人员就可以利用以前一度受到限制的API了。回调函数实际上是方法调用的指针,也称为函数指针,是一个非常强大的编程特性。.NET以委托的形式实现了函数指针的概念。它们的特殊之处是,与C函数指针不同,.NET委托是类型安全的。这说明,C中的函数指针只不过是一个指向存储单元的指针,我们无法说出这个指针实际指向什么,像参数和返回类型等就更无从知晓了。如本章所述,.NET把委托作为一种类型安全的操作。本章后面将学习.NET如何将委托用作实现事件的方式。

6.1  委托

当要把方法传送给其他方法时,需要使用委托。要了解它们的含义,可以看看下面的代码:

int i = int.Parse("99");

我们习惯于把数据作为参数传递给方法,如上面的例子所示。所以,给方法传送另一个方法听起来有点奇怪。而有时某个方法执行的操作并不是针对数据进行的,而是要对另一个方法进行操作,这就比较复杂了。在编译时我们不知道第二个方法是什么,这个信息只能在运行时得到,所以需要把第二个方法作为参数传递给第一个方法,这听起来很令人迷惑,下面用几个示例来说明:

       启动线程—— C#中,可以告诉计算机并行运行某些新的执行序列。这种序列就称为线程,在基类System.Threading.Thread的一个实例上使用方法Start(),就可以开始执行一个线程。如果要告诉计算机开始一个新的执行序列,就必须说明要在哪里执行该序列。必须为计算机提供开始执行的方法的细节,即Thread.Start()方法必须带有一个参数,该参数定义了要由线程调用的方法。

       通用库类—— 有许多库包含执行各种标准任务的代码。这些库通常可以自我包含。这样在编写库时,就会知道任务该如何执行。但是有时在任务中还包含子任务,只有使用该库的客户机代码才知道如何执行这些子任务。例如编写一个类,它带有一个对象数组,并把它们按升序排列。但是,排序的部分过程会涉及到重复使用数组中的两个对象,比较它们,看看哪一个应放在前面。如果要编写的类必须能给任何对象数组排序,就无法提前告诉计算机应如何比较对象。处理类中对象数组的客户机代码也必须告诉类如何比较要排序的对象。换言之,客户机代码必须给类传递某个可以进行这种比较的合适方法的细节。

       事件——一般是通知代码发生了什么事件。GUI编程主要是处理事件。在发生事件时,运行库需要知道应执行哪个方法。这就需要把处理事件的方法传送为委托的一个参数。这些将在本章后面讨论。

前面建立了有时把方法的细节作为参数传递给其他方法的规则。下面需要指出如何完成这一过程。最简单的方式是把方法名作为参数传递出去。例如在前面的线程示例中,假定要启动一个新线程,且有一个叫作EntryPoint()的方法,该方法是开始运行线程时的地方。

void EntryPoint()

{

   // do whatever the new thread needs to do

}

也可以用下面的代码开始执行新线程:

Thread NewThread = new Thread();

Thread.Start(EntryPoint);                   // WRONG

实际上,这是一种很简单的方式,在一些语言如C和C++中使用的就是这种方式(在C和C++中,参数EntryPoint是一个函数指针)。

但这种直接的方法会导致一些问题,例如类型的安全性,在进行面向对象编程时,方法很少是孤立存在的,在调用前,通常需要与类实例相关联。而这种方法并没有考虑到这个问题。所以.NET Framework在语法上不允许使用这种直接的方法。如果要传递方法,就必须把方法的细节封装在一种新类型的对象中,即委托。委托只是一种特殊的对象类型,其特殊之处在于,我们以前定义的所有对象都包含数据,而委托包含的只是方法的细节。

6.1.1  C#中声明委托

在C#中使用一个类时,分两个阶段。首先需要定义这个类,即告诉编译器这个类由什么字段和方法组成。然后(除非只使用静态方法)实例化类的一个对象。使用委托时,也需要经过这两个步骤。首先定义要使用的委托,对于委托,定义它就是告诉编译器这种类型的委托代表了哪种类型的方法,然后创建该委托的一个或多个实例。

定义委托的语法如下:

delegate void VoidOperation(uint x);

在这个示例中,定义了一个委托VoidOperation,并指定该委托的每个实例都包含一个方法的细节,该方法带有一个uint参数,并返回void。理解委托的一个要点是它们的类型安全性非常高。在定义委托时,必须给出它所代表的方法的全部细节。

提示:

理解委托的一种好方式是把委托的作用当作是给方法签名指定名称。

假定要定义一个委托TwoLongsOp ,该委托代表的函数有两个long型参数,返回类型为double。可以编写如下代码:

delegate double TwoLongsOp(long first, long second);

或者定义一个委托,它代表的方法不带参数,返回一个string型的值,则可以编写如下代码:

delegate string GetAString();

其语法类似于方法的定义,但没有方法体,定义的前面要加上关键字delegate。因为定义委托基本上是定义一个新类,所以可以在定义类的任何地方定义委托,既可以在另一个类的内部定义,也可以在任何类的外部定义,还可以在命名空间中把委托定义为顶层对象。根据定义的可见性,可以在委托定义上添加一般的访问修饰符:public、 private和 protected等:

public delegate string GetAString();

注意:

实际上,“定义一个委托”是指“定义一个新类”。委托实现为派生于基类System. MulticastDelegate的类,System.MulticastDelegate又派生于基类System.DelegateC#编译器知道这个类,会使用其委托语法,因此我们不需要了解这个类的具体执行情况,这是C#与基类共同合作,使编程更易完成的另一个示例。

定义好委托后,就可以创建它的一个实例,以存储特定方法的细节。

注意:

此处,在术语方面有一个问题。类有两个不同的术语:“类”表示较广义的定义,“对象”表示类的实例。但委托只有一个术语。在创建委托的实例时,所创建的委托的实例仍称为委托。您需要从上下文中确定委托的确切含义。

6.1.2  C#中使用委托

下面的代码段说明了如何使用委托。这是在int上调用ToString()方法的一种相当冗长的方式:

private delegate string GetAString();

static void Main(string[] args)

{

   int x = 40;

   GetAString firstStringMethod = new GetAString(x.ToString);

   Console.WriteLine("String is" + firstStringMethod());

   // With firstStringMethod initialized to x.ToString(),

   // the above statement is equivalent to saying

   // Console.WriteLine("String is" + x.ToString());

在这段代码中,实例化了类型为GetAString的一个委托,并对它进行初始化,使它引用整型变量x的ToString()方法。在C#中,委托在语法上总是带有一个参数的构造函数,这个参数就是委托引用的方法。这个方法必须匹配最初定义委托时的签名。所以在这个示例中,如果用不带参数、返回一个字符串的方法来初始化firstStringMethod,就会产生一个编译错误。注意,int.ToString()是一个实例方法(不是静态方法),所以需要指定实例(x)和方法名来正确初始化委托。

下一行代码使用这个委托来显示字符串。在任何代码中,都应提供委托实例的名称,后面的括号中应包含调用该委托中的方法时使用的参数。所以在上面的代码中,Console.WriteLine()语句完全等价于注释语句中的代码行。

委托的一个特征是它们的类型是安全的,可以确保被调用的方法签名是正确的。但有趣的是,它们不关心调用该方法的是什么类型的对象,甚至不考虑该方法是静态方法,还是实例方法。

提示:

给定委托的实例可以表示任何类型的任何对象上的实例方法或静态方法——只要方法的签名匹配于委托的签名即可。

为了说明这一点,我们扩展上面的代码,让它使用firstStringMethod委托在另一个对象上调用其他两个方法,其中一个方法是实例方法,另一个方法是静态方法。为此,再次使用本章前面定义的Currency结构。

   struct Currency

   {

      public uint Dollars;

      public ushort Cents;

      public Currency(uint dollars, ushort cents)

      {

         this.Dollars = dollars;

         this.Cents = cents;

      }

      public override string ToString()

      {

         return string.Format("${0}.{1,2:00}", Dollars,Cents);

      }

      public static explicit operator Currency (float value)

      {

         checked

         {

            uint dollars =(uint)value;

            ushort cents =(ushort)((value-dollars)*100);

            return new Currency(dollars,cents);

          }

      }

      public static implicit operator float (Currency value)

      {

         return value.Dollars + (value.Cents/100.0f);

      }

public static implicit operator Currency (uint value)

{

   return new Currency(value, 0);

}

      public static implicit operator uint (Currency value)

      {

         return value.Dollars;

      }

   }

Currency结构已经有了自己的ToString()重载方法。为了说明如何使用带有静态方法的委托,再增加一个静态方法,其签名与Currency的签名相同:

   struct Currency

   {

      public static string GetCurrencyUnit()

      {

         return "Dollar";

      }

下面就可以使用GetAString 实例,代码如下所示:

      private delegate string GetAString();

      static void Main(string[] args)

      {

         int x = 40;

         GetAString firstStringMethod = new GetAString(x.ToString);

         Console.WriteLine("String is " + firstStringMethod());

         Currency balance = new Currency(34, 50);

         firstStringMethod = new GetAString(balance.ToString);

         Console.WriteLine("String is " + firstStringMethod());

         firstStringMethod = new GetAString(Currency.GetCurrencyUnit);

         Console.WriteLine("String is " + firstStringMethod());

这段代码说明了如何通过委托来调用方法,然后重新给委托指定在类的不同实例上执行的不同方法,甚至可以指定静态方法,或者在类的不同类型的实例上执行的方法,只要每个方法的签名匹配于委托定义即可。

但是,我们还没有说明把一个委托传递给另一个方法的具体过程,也没有给出任何有用的结果。调用int和Currency对象的ToString()的方法要比使用委托直观得多!在真正领会到委托的用途前,需要用一个相当复杂的示例来说明委托的本质。下面就是两个委托的示例。第一个示例仅使用委托来调用两个不同的操作,说明了如何把委托传递给方法,如何使用委托数组,但这仍没有很好地说明没有委托,就不能完成很多工作。第二个示例就复杂得多了,它有一个类BubbleSorter,执行一个方法,按照升序排列一个对象数组,这个类没有委托是很难编写出来的。

6.2  匿名方法

到目前为止,要想使委托工作,方法必须已经存在(即委托是用方法的签名定义的)。但使用委托还有另外一种方式:即通过匿名方法。匿名方法是用作委托参数的一个代码块。

用匿名方法定义委托的语法与前面的定义并没有什么区别。但在实例化委托时,就有区别了。下面是一个非常简单的控制台应用程序,说明了如何使用匿名方法:

namespace ConsoleApplication1

{

  class Program

  {

    delegate string delegateTest(string val);

    static void Main(string[] args)

    {

      string mid = ", middle part,";

      delegateTest anonDel = delegate(string param)

      {

        param += mid;

        param += " and this was added to the string.";

        return param;

      };

      Console.WriteLine(anonDel("Start of string"));

    }

  }

}

委托delegateTest定义为一个类级变量,它带一个字符串参数。有区别的是Main方法。在定义anonDel时,不是传送已知的方法名,而是使用一个简单的代码块:

{

  param += mid;

  param += " and this was added to the string.";

  return param;

};

可以看出,该代码块使用方法级的字符串变量mid,该变量是在匿名方法的外部定义的,并添加到要传送的参数中。接着代码返回该字符串值。在调用委托时,把一个字符串传送为参数,将返回的字符串输出到控制台上。

匿名方法的优点是减少了系统开销。方法仅在由委托使用时才定义。在为事件定义委托时,这是非常显然的。(本章后面探讨事件。)这有助于降低代码的复杂性,尤其是定义了好几个方法时,代码会显得比较简单。

在使用匿名方法时,必须遵循两个规则。在匿名方法中不能使用跳转语句跳到该匿名方法的外部,反之亦然:匿名方法外部的跳转语句不能跳到该匿名方法的内部。

在匿名方法内部不能访问不安全的代码。另外,也不能访问在匿名方法外部使用的ref和out参数。但可以使用在匿名方法外部定义的其他变量。

6.2.1  简单的委托示例

在这个示例中,定义一个类MathsOperations,它有两个静态方法,对double类型的值执行两个操作,然后使用该委托调用这些方法。这个数学类如下所示:

   class MathsOperations

   {

      public static double MultiplyByTwo(double value)

      {

         return value*2;

      }

      public static double Square(double value)

      {

         return value*value;

      }

   }

下面调用这些方法:

using System;

namespace SimpleDelegate

{

   delegate double DoubleOp(double x);

   class MainEntryPoint

   {

      static void Main()

      {

         DoubleOp [] operations =

            {

               new DoubleOp(MathsOperations.MultiplyByTwo),

               new DoubleOp(MathsOperations.Square)

            };

         for (int i=0 ; i<operations.Length ; i++)

         {

            Console.WriteLine("Using operations[{0}]:", i);

            ProcessAndDisplayNumber(operations[i], 2.0);

            ProcessAndDisplayNumber(operations[i], 7.94);

            ProcessAndDisplayNumber(operations[i], 1.414);

            Console.WriteLine();

         }

      }

      static void ProcessAndDisplayNumber(DoubleOp action, double value)

      {

         double result = action(value);

         Console.WriteLine(

            "Value is {0}, result of operation is {1}", value, result);

      }

在这段代码中,实例化了一个委托数组DoubleOp (记住,一旦定义了委托类,就可以实例化它的实例,就像处理一般的类那样—— 所以把一些委托的实例放在数组中是可以的)。该数组的每个元素都初始化为由MathsOperations类执行的不同操作。然后循环这个数组,把每个操作应用到3个不同的值上。这说明了使用委托的一种方式—— 把方法组合到一个数组中,这样就可以在循环中调用不同的方法了。

这段代码的关键一行是把委托传递给ProcessAndDisplayNumber()方法,例如:

            ProcessAndDisplayNumber(operations[i], 2.0);

其中传递了委托名,但不带任何参数,假定operations[i]是一个委托,其语法是:

       operations[i]表示“这个委托”换言之,就是委托代表的方法。

       operations[i](2.0)表示“调用这个方法,参数放在括号中”。

ProcessAndDisplayNumber()方法定义为把一个委托作为其第一个参数:

      static void ProcessAndDisplayNumber(DoubleOp action, double value)

在这个方法中,调用:

         double result = action(value);

这实际上是调用action委托实例封装的方法,其返回结果存储在result中。

运行这个示例,得到如下所示的结果:

SimpleDelegate

Using operations[0]:

Value is 2, result of operation is 4

Value is 7.94, result of operation is 15.88

Value is 1.414, result of operation is 2.828

Using operations[1]:

Value is 2, result of operation is 4

Value is 7.94, result of operation is 63.0436

Value is 1.414, result of operation is 1.999396

如果在这个例子中使用匿名方法,就可以删除第一个类MathOperations。Main方法应如下所示:

static void Main()

{

  DoubleOp multByTwo = delegate(double val) {return val * 2;}

  DoubleOp square = delegate(double val) {return val * val;}

  DoubleOp [] operations = {multByTwo, square};

  for (int i=0 ; i<operations.Length ; i++)

  {

    Console.WriteLine("Using operations[{0}]:", i);

    ProcessAndDisplayNumber(operations[i], 2.0);

    ProcessAndDisplayNumber(operations[i], 7.94);

    ProcessAndDisplayNumber(operations[i], 1.414);

    Console.WriteLine();

  }

}

运行这个版本,结果与前面的例子相同。其优点是删除了一个类。

6.2.2  BubbleSorter示例

下面的示例将说明委托的用途。我们要编写一个类BubbleSorter,它执行一个静态方法Sort(),这个方法的第一个参数是一个对象数组,把该数组按照升序重新排列。换言之,假定传递的是int数组:{0, 5, 6, 2, 1},则返回的结果应是{0, 1, 2, 5, 6}。

冒泡排序算法非常著名,是一种排序的简单方法。它适合于一小组数字,因为对于大量的数字(超过10个),还有更高效的算法。冒泡排序算法重复遍历数组,比较每一对数字,按照需要交换它们的位置,把最大的数字逐步移动到数组的最后。对于给int排序,进行冒泡排序的方法如下所示:

   for (int i = 0; i < sortArray.Length; i++)

   {

      for (int j = i + 1; j < sortArray.Length; j++)

      {

         if (sortArray[j] < sortArray[i])   // problem with this test

         {

            int temp = sortArray[i];   // swap ith and jth entries

            sortArray[i] = sortArray[j];

            sortArray[j] = temp;

         }

      }

   }

它非常适合于int,但我们希望Sort()方法能给任何对象排序。换言之,如果某段客户机代码包含Currency结构数组或其他类和结构,就需要对该数组排序。这样,上面代码中的if(sortArray[j] < sortArray[i])就有问题了,因为它需要比较数组中的两个对象,看看哪一个更大。可以对int进行这样的比较,但如何对直到运行期间才知道或确定的新类进行比较?答案是客户机代码知道类在委托中传递的是什么方法,封装这个方法就可以进行比较。

定义如下的委托:

   delegate bool CompareOp(object lhs, object rhs);

给Sort方法指定下述签名:

   static public void Sort(object [] sortArray, CompareOp gtMethod)

这个方法的文档说明强调,gtMethod必须表示一个静态方法,该方法带有两个参数,如果第二个参数的值“大于”第一个参数(换言之,它应放在数组中靠后的位置),就返回true。

注意:

这里使用的是委托,但也可以使用接口来解决这个问题。.NET提供的IComparer接口就用于此目的。但是这里使用委托是因为这种问题本身要求使用委托。

设置完毕后,下面定义类BubbleSorter:

   class BubbleSorter

   {

      static public void Sort(object [] sortArray, CompareOp gtMethod)

      {

         for (int i=0 ; i<sortArray.Length ; i++)

         {

            for (int j=i+1 ; j<sortArray.Length ; j++)

            {

               if (gtMethod(sortArray[j], sortArray[i]))

               {

                  object temp = sortArray[i];

                  sortArray[i] = sortArray[j];

                  sortArray[j] = temp;

               }

            }

         }

      }

   }

为了使用这个类,需要定义一些其他类,建立要排序的数组。在本例中,假定Mortimer Phones移动电话公司有一个员工列表,要对照他们的薪水进行排序。每个员工分别由类Employee的一个实例表示,如下所示:

   class Employee

   {

      private string name;

      private decimal salary;

      public Employee(string name, decimal salary)

      {

         this.name = name;

         this.salary = salary;

      }

      public override string ToString()

      {

         return string.Format(name + ", {0:C}", salary);

      }

      public static bool RhsIsGreater(object lhs, object rhs)

      {

         Employee empLhs = (Employee) lhs;

         Employee empRhs = (Employee) rhs;

         return (empRhs.salary > empLhs.salary) ? true : false;

      }

   }

注意,为了匹配CompareOp委托的签名,在这个类中必须定义RhsIsGreater,它的参数是两个对象引用,而不是Employee引用。必须把这些参数的数据类型转换为Employee引用,才能进行比较。

下面编写一些客户机代码,完成排序:

using System;

namespace Wrox.ProCSharp.AdvancedCSharp

{

   delegate bool CompareOp(object lhs, object rhs);

   class MainEntryPoint

   {

      static void Main()

      {

         Employee [] employees =

            {

               new Employee("Bugs Bunny", 20000),

               new Employee("Elmer Fudd ", 10000),

               new Employee("Daffy Duck", 25000),

               new Employee("Wiley Coyote", (decimal)1000000.38),

               new Employee("Foghorn Leghorn", 23000),

               new Employee("RoadRunner'", 50000)};

         CompareOp employeeCompareOp = new CompareOp(Employee.RhsIsGreater);

         BubbleSorter.Sort(employees, employeeCompareOp);

         for (int i=0 ; i<employees.Length ; i++)

            Console.WriteLine(employees[i].ToString());

      }

   }

运行这段代码,正确显示按照薪水排列的Employee,如下所示:

BubbleSorter

Elmer Fudd, $10,000.00

Bugs Bunny, $20,000.00

Foghorn Leghorn, $23,000.00

Daffy Duck, $25,000.00

RoadRunner, $50,000.00

Wiley Coyote, $1,000,000.38

6.2.3  多播委托

前面使用的每个委托都只包含一个方法调用。调用委托的次数与调用方法的次数相同。如果要调用多个方法,就需要多次显式调用这个委托。委托也可以包含多个方法。这种委托称为多播委托。如果调用多播委托,就可以按顺序连续调用多个方法。为此,委托的签名就必须返回void(否则,返回值应送到何处?)。实际上,如果编译器发现某个委托返回void,就会自动假定这是一个多播委托。下面的代码取自于SimpleDelegate示例,尽管其语法与以前相同,但实际上它实例化了一个多播委托Operations:

   delegate void DoubleOp(double value);

//   delegate double DoubleOp(double value);   // can't do this now

   class MainEntryPoint

   {

      static void Main()

      {

         DoubleOp operations = new DoubleOp(MathOperations.MultiplyByTwo);

         operations += new DoubleOp(MathOperations.Square);

在前面的示例中,要存储对两个方法的引用,所以实例化了一个委托数组。而这里只是在一个多播委托中添加两个操作。多播委托可以识别运算符+和+=。还可以扩展上述代码中的最后两行,它们具有相同的效果:

   DoubleOp operation1 = new DoubleOp(MathOperations.MultiplyByTwo);

   DoubleOp operation2 = new DoubleOp(MathOperations.Square);

   DoubleOp operations = operation1 + operation2;

多播委托还识别运算符–和–=,以从委托中删除方法调用。

注意:

根据后面的内容,多播委托是一个派生于System.MulticastDelegate的类,System.Multicast- Delegate又派生于基类System.DelegateSystem.MulticastDelegate的其他成员允许把多个方法调用链接在一起,成为一个列表。

为了说明多播委托的用法,下面把SimpleDelegate示例改写为一个新示例MulticastDelegate。现在需要把委托表示为返回void的方法,就应重写MathOperations类中的方法,让它们显示其结果,而不是返回它们:

   class MathOperations

   {

      public static void MultiplyByTwo(double value)

      {

         double result = value*2;

         Console.WriteLine(

            "Multiplying by 2: {0} gives {1}", value, result);

      }

      public static void Square(double value)

      {

         double result = value*value;

         Console.WriteLine("Squaring: {0} gives {1}", value, result);

      }

   }

为了适应这个改变,也必须重写ProcessAndDisplayNumber:

static void ProcessAndDisplayNumber(DoubleOp action, double value)

{

   Console.WriteLine(""nProcessAndDisplayNumber called with value = " +

                      valueToProcess);

   action(valueToProcess);

}

下面测试多播委托,其代码如下:

      static void Main()

      {

         DoubleOp operations = new DoubleOp(MathOperations.MultiplyByTwo);

         operations += new DoubleOp(MathOperations.Square);

         ProcessAndDisplayNumber(operations, 2.0);

         ProcessAndDisplayNumber(operations, 7.94);

         ProcessAndDisplayNumber(operations, 1.414);

         Console.WriteLine();

      }

现在,每次调用ProcessAndDisplayNumber时,都会显示一个信息,说明它已经被调用。然后,下面的语句会按顺序调用action委托实例中的每个方法:

   action(value);

运行这段代码,得到如下所示的结果:

MulticastDelegate

ProcessAndDisplayNumber called with value = 2

Multiplying by 2: 2 gives 4

Squaring: 2 gives 4

ProcessAndDisplayNumber called with value = 7.94

Multiplying by 2: 7.94 gives 15.88

Squaring: 7.94 gives 63.0436

ProcessAndDisplayNumber called with value = 1.414

Multiplying by 2: 1.414 gives 2.828

Squaring: 1.414 gives 1.999396

如果使用多播委托,就应注意对同一个委托调用方法链的顺序并未正式定义,因此应避免编写依赖于以任意特定顺序调用方法的代码。

6.3  事件

基于Windows的应用程序也是基于消息的。这说明,应用程序是通过Windows来通信的,Windows又是使用预定义的消息与应用程序通信的。这些消息是包含各种信息的结构,应用程序和Windows使用这些信息决定下一步的操作。在MFC等库或VB等开发环境推出之前,开发人员必须处理Windows发送给应用程序的消息。VB和今天的.NET把这些传送来的消息封装在事件中。如果需要响应某个消息,就应处理对应的事件。一个常见的例子是用户单击了窗体中的按钮后,Windows就会给按钮消息处理程序(有时称为Windows过程或WndProc)发送一个WM_MOUSECLICK消息。对于.NET开发人员来说,这就是按钮的OnClick事件。

在开发基于对象的应用程序时,需要使用另一种对象通信方式。在一个对象中发生了有趣的事情时,就需要通知其他对象发生了什么变化。这里又要用到事件。就像.NET Framework把Windows消息封装在事件中那样,也可以把事件用作对象之间的通信介质。

委托就用作应用程序接收到消息时封装事件的方式。

在上一节介绍委托时,仅讨论了理解事件如何工作所需要的内容。但Microsoft设计C#事件的目的是为了让用户无需理解底层的委托,就可以使用它们。所以下面开始从客户软件的角度讨论事件,主要考虑的是需要编写什么代码来接收事件通知,而无需担心后台上究竟发生了什么,从中可以看出事件的处理十分简单。之后,编写一个生成事件的示例,介绍事件和委托之间的关系。

本节的内容对C++开发人员最有用,因为C++没有与事件类似的概念。另一方面,C#事件与VB事件非常类似,但C#中的语法和底层的实现有所不同。

注意:

这里的术语“事件”有两种不同的含义。第一,表示发生了某个有趣的事情;第二,表示C#语言中已定义的一个对象,即处理通知过程的对象。在使用第二个含义时,我们常常把事件表示为C#事件,或者在其含义很容易从上下文中看出时,它就是一个事件。

6.3.1  从客户的角度讨论事件

事件接收器是指在发生某些事情时被通知的任何应用程序、对象或组件。当然,有事件接收器,就有事件发送器。发送器的作用是引发事件。发送器可以是应用程序中的另一个对象或程序集,在系统事件中,例如鼠标单击或键盘按键,发送器就是.NET运行库。注意,事件的发送器并不知道接收器是谁。这就使事件非常有用。

现在,在事件接收器的某个地方有一个方法,它负责处理事件。在每次发生已注册的事件时,就执行这个事件处理程序。此时就要使用委托了。由于发送器对接收器一无所知,所以无法设置两者之间的引用类型,而是使用委托作为中介。发送器定义接收器要使用的委托,接收器将事件处理程序注册到事件中。连接事件处理程序的过程称为封装事件。封装Click事件的简单例子有助于说明这个过程。

首先创建一个简单的Windows窗体应用程序,把一个按钮控件从工具箱拖放到窗体上。在属性窗口中把按钮重命名为btnOne。在代码编辑器中把下面的代码添加到Form1构造函数中:

btnOne.Click += new EventHandler(Button_Click);

在Visual Studio中,注意在输入+=运算符之后,就只需按下Tab键两次,编辑器就会完成剩余的输入工作。在大多数情况下这很不错。但在这个例子中,不使用默认的处理程序名,所以应自己输入文本。

这将告诉运行库,在引发btnOne的Click事件时,应执行Button_Click方法。EventHandler是事件用于把处理程序(Button_Click)赋予事件(Click)的委托。注意使用+=运算符把这个新方法添加到委托列表中。这类似于本章前面介绍的多播示例。也就是说,可以为事件添加多个事件处理程序。由于这是一个多播委托,所以要遵循添加多个方法的所有规则,但是不能保证调用方法的顺序。下面在窗体上再添加一个按钮,把它重命名为btnTwo。把btnTwo的Click事件也连接到同一个Button_Click方法上,如下所示:

btnOne.Click += new EventHandler(Button_Click);

btnTwo.Click += new EventHandler(Button_Click);

EventHandler委托已在.NET Framework中定义了。它位于System命名空间,所有在.NET Framework中定义的事件都使用它。如前所述,委托要求添加到委托列表中的所有方法都必须有相同的签名。显然事件委托也有这个要求。下面是Button_Click方法的定义:

Private void Button_Click(object sender, Eventargs e)

{

}

这个方法有几个重要的地方。首先,它总是没有返回值。事件处理程序不能有返回值。其次是参数。只要使用EventHandler委托,参数就应是object和EventArgs。第一个参数是引发事件的对象,在这个例子中是btnOne或btnTwo,这取决于被单击的按钮。把一个引用发送给引发事件的对象,就可以把同一个的事件处理程序赋予多个对象。例如,可以为几个按钮定义一个按钮单击处理程序,接着根据sender参数确定单击了哪个按钮。

第二个参数EventArgs是包含有关事件的其他有用信息的对象。这个参数可以是任意类型,只要它派生于EventArgs即可。MouseDown事件使用MouseDownEventArgs,它包含所使用按钮的属性、指针的X和Y坐标,以及与事件相关的其他信息。注意,其命名模式是在类型的后面加上EventArgs。本章的后面将介绍如何创建和使用基于EventArgs的定制对象。

方法的命名也应注意。按照约定,事件处理程序应遵循“object_event.object”的命名约定。object就是引发事件的对象,而event就是被引发的事件。从可读性来看,应遵循这个命名约定。

本例最后在处理程序中添加了一些代码,以完成一些工作。记住有两个按钮使用同一个处理程序。所以首先必须确定是哪个按钮引发了事件,接着调用应执行的操作。在本例中,只是在窗体的一个标签控件上输出一些文本。把一个标签控件从工具箱拖放到窗体上,并将其命名为lblInfo,然后在Button_Click方法中编写如下代码:

if(((Button)sender).Name == "btnOne")

   lblInfo.Text = "Button One was pressed";

else

   lblInfo.Text = "Button Two was pressed";

注意,由于sender参数作为对象发送,所以必须把它的数据类型转换为引发事件的对象类型,在本例中就是Button。本例使用Name属性确定是哪个按钮引发了对象,也可以使用其他属性。例如Tag属性就可以处理这种情形,因为它可以包含任何内容。为了了解事件委托的多播功能,给btnTwo的Click事件添加另一个方法,使用默认的方法名。窗体的构造函数如下所示:

btnOne.Click += new EventHandler(Button_Click);

btnTwo.Click += new EventHandler(Button_Click);

btnTwo.Click += new EventHandler(btnTwo_Click);

如果让Visual Studio创建存根(stub),就会在源文件的末尾得到如下方法。但是,必须添加对MessageBox函数的调用:

Private void btnTwo_Click(object sender, EventArgs e)

{

MessageBox.Show("This only happens in Button 2 click event");

}

如果使用匿名方法,就不需要Button_Click方法和btnTwo_Click方法了。事件的代码如下:

btnOne.Click += new EventHandler(lblInfo.Text = "Button One was pressed";);

btnTwo.Click += new EventHandler(lblInfo.Text = "Button Two was pressed";);

btnTwo.Click += new EventHandler(MessageBox.Show

                                  ("This only happens in Button 2 click event"););

在运行这个例子时,单击btnOne会改变标签上的文本。单击btnTwo不仅会改变文本,还会显示消息框。注意,不能保证标签文本在消息框显示之前改变,所以不要在处理程序中编写具有依赖性的代码。

我们已经学习了许多概念,但要在接收器中编写的代码量是很小的。记住,编写事件接收器常常比编写事件发送器要频繁得多。至少在Windows用户界面上,Microsoft已经编写了所有需要的事件发送器(它们都在.NET基类中,在Windows.Forms命名空间中)。

6.3.2  生成事件

接收事件并响应它们仅是事件的一个方面。为了使事件真正发挥作用,还需要在代码中生成和引发事件。下面的例子将介绍如何创建、引发、接收和取消事件。

这个例子包含一个窗体,它会引发另一个类正在监听的事件。在引发事件后,接收对象就确定是否执行一个过程,如果该过程未能继续,就取消事件。本例的目标是确定当前时间的秒数是大于30还是小于30。如果秒数小于30,就用一个表示当前时间的字符串设置一个属性;如果秒数大于30,就取消事件,把时间字符串设置为空。

用于生成事件的窗体包含一个按钮和一个标签。下载的示例代码把按钮命名为btnRaise,标签命名为lblInfo,您也可以给标签使用其他名称。在创建窗体,添加两个控件后,就可以创建事件和相应的委托了。在窗体类的类声明部分,添加如下代码:

public delegate void ActionEventHandler(object sender, ActionCancel EventArgs ev);

public static event ActionEventHandler Action;

这两行代码的作用是什么?首先,我们声明了一种新的委托类型ActionEventHandler。必须创建一种新委托,而不使用.NET Framework预定义的委托,其原因是后面要使用定制的EventArgs类。方法签名必须与委托匹配。有了一个要使用的委托后,下一行代码就定义事件。在本例中定义了Action事件,定义事件的语法要求指定与事件相关的委托。还可以使用在.NET Framework中定义的委托。从EventArgs类中派生出了近100个类,应该可以找到一个自己能使用的类。但本例使用的是定制的EventArgs类,所以必须创建一个与之匹配的新委托类型。

基于EventArgs的新类ActionCancelEventHandler实际上派生于CancelEventArgs,而CancelEventArgs派生于EventArgs。CancelEventArgs添加了Cancel属性,该属性是一个布尔值,它通知sender对象,接收器希望取消或停止事件的处理。在ActionEventHandler类中,还添加了Message属性,这是一个字符串属性,包含事件处理状态的文本信息。下面是ActionCancel-
EventHandler类的代码:

public class ActionCancelEventHandler : System.ComponentModel. CancelEventArgs

{

  string _msg = "";

  public ActionCancelEventArgs()  : base() {}

  public ActionCancelEventArgs(bool cancel)  : base(cancel) {}

  public ActionCancelEventArgs(bool cancel, string message)  : base(cancel)

 {

   _msg = message;

}

  public string Message

  {

    get {return _msg;}

    set {_msg = value;}

  }

}

可以看出,所有基于EventArgs的类都负责在发送器和接收器之间来回传送事件的信息。在大多数情况下,EventArgs类中使用的信息都被事件处理程序中的接收器对象使用。但是,有时事件处理程序可以把信息添加到EventArgs类中,使之可用于发送器。这就是本例使用EventArgs类的方式。注意在EventArgs类中有两个可用的构造函数。这种额外的灵活性增加了该类的可用性。

目前声明了一个事件,定义了一个委托,并创建了EventArgs类。下一步需要引发事件。真正需要做的是用正确的参数调用事件,如本例所示:

ActionCancelEventArgs ev = new CancelEventArgs();

Action(this, ev);

这非常简单。创建新的ActionCancelEventArgs类,并把它作为一个参数传递给事件。但是,这有一个小问题。如果事件不会在任何地方使用,该怎么办?如果还没有为事件定义处理程序,该怎么办?Action事件实际上是空的。如果试图引发该事件,就会得到一个空引用异常。如果要派生一个新的窗体类,并使用该窗体,把Action事件定义为基事件,则只要引发了Action事件,就必须执行其他一些操作。目前,我们必须在派生的窗体中激活另一个事件处理程序,这样才能访问它。为了使这个过程容易一些,并捕获空引用错误,就必须创建一个方法OnEvent Name,其中EventName是事件名。在这个例子中,有一个OnAction方法,下面是OnAction方法的完整代码:

protected void OnAction(object sender, ActionCancelEventArgs ev)

{

  if(Action != null)

     Action(sender, ev);

}

代码并不多,但完成了需要的工作。把该方法声明为protected,就只有派生类可以访问它。事件在引发之前还会进行空引用测试。如果派生一个包含该方法和事件的新类,就必须重写OnAction方法,然后连接事件。为此,必须在重写代码中调用base.OnAction()。否则就不会引发该事件。在整个.NET Framework中都用这个命名约定,并在.NET SDK文档中对这一命名规则进行了说明。

注意传送给OnAction方法的两个参数。它们看起来很熟悉,因为它们与需要传送给事件的参数相同。如果事件需要从另一个对象中引发,而不是从定义方法的对象中引发,就需要把访问修饰符设置为internal或public,而不能设置为protected。有时让类只包含事件声明和从其他类中调用的事件是有意义的。仍可以创建OnEventName方法,但它们是静态方法。

目前,我们已经引发了事件,还需要一些代码来处理它。在项目中创建一个新类,在这个例子中把该类称为BusEntity。本项目的目的是检查当前时间的秒数,如果它小于30,就把一个字符串值设置为时间;如果它大于30,就把字符串设置为::,并取消事件。下面是代码:

using System;

using System.IO;

using System.ComponentModel;

namespace SimpleEvent

{

  public class BusEntity

  {

     string _time ="";

     public BusEntity()

     {

        Form1.Action += new Form1.ActionEventHandler(Form1_Action);

     }

     private void Form1_Action(object sender, ActionCancelEventArgs ev)

     {

        ev.Cancel = !DoAction();

        if(ev.Cancel)

          ev.Message ="Wasn’t the right time.";

      }

      private bool DoAction()

      {

         bool retVal = false;

         DateTime tm = DateTime.Now;

         if(tm.second) < 30)

         {

            _time ="The time is" + DateTime.Now.ToLongTimeString();

            retVal = true;

          }

          else

            _time = "";

     

         return retVal;

      }

      public string TimeString

      {

         get {return _time;}

      }

   }

}

在构造函数中声明了Form1.Action事件的处理程序。注意其语法非常类似于前面Click事件的语法。由于声明事件使用的模式都是相同的,所以语法也应保持一致。还要注意如何获取Action事件的引用,而无需在BusEntity类中有对Form1的引用。在Form1类中,将Action事件声明为静态,这并不是必需的,但这样更易于创建处理程序。我们可以把事件声明为public,但接着需要引用Form1的一个实例。

在构造函数中编写事件时,调用添加到委托列表中的方法Form1_Action,并遵循命名标准。在处理程序中,需要确定是否取消事件。DoActions方法根据前面描述的时间条件返回一个布尔值,并把_time字符串设置为正确的值。

在DoActions返回值后,就把该值赋给ActionCancelEventArgs的Cancel属性。EventArgs类一般仅在事件发送器和接收器之间来回传递值。如果取消了事件(ev.Cancel = true),Message属性就设置为一个字符串值,以说明事件为什么被取消。

如果再次查看btnRaise_Click事件处理程序的代码,就可以看出Cancel属性的使用方式:

private void btnRaise_Click(object sender, EventArgs e)

{

   ActionCancelEventArgs cancelEvent = new ActionCancelEventArgs();

   OnAction(this, cancelEvent);

   If(cancelEvent.Cancel)

      lblInfo.Text = cancelEvent.Message;

   else

      lblInfo.Text = _busEntity.TimeString;

}

注意创建了ActionCancelEventArgs对象。接着引发了事件Action,并传递了新建的ActionCancelEventArgs对象。在调用OnAction方法,引发事件时,BusEntity对象中Action事件处理程序的代码就会执行。如果还有其他对象注册了事件Action,它们也会执行。记住,如果其他对象也处理这个事件,它们就会看到同一个ActionCancelEventArgs对象。如果需要确定是哪个对象取消了事件,而且如果有多个对象取消了事件,就需要在ActionCancelEventArgs类中包含某种基于列表的数据结构。

在与事件委托一起注册的处理程序执行完毕后,就可以查询ActionCancelEventArgs对象,确定它是否被取消了。如果是,lblInfo就包含Message属性值;如果事件没有被取消,lblInfo就会显示当前时间。

本节这基本上说明了如何利用事件和事件中基于EventArgs的对象,在应用程序中传递信息。

6.4  小结

本章介绍了委托和事件的基本知识,解释了如何声明委托,如何给委托列表添加方法,并讨论了声明事件处理程序来响应事件的过程,以及如何创建定制事件,使用引发事件的模式。

.NET开发人员将大量使用委托和事件,特别是开发Windows Forms应用程序。事件是.NET开发人员监视应用程序执行时出现的各种Windows消息的方式,否则就必须监视WndProc,捕获WM_MOUSEDOWN消息,而不是获取按钮的鼠标Click事件。

在设计大型应用程序时,使用委托和事件可以减少依赖性和层的关联,并能开发出具有更高复用性的组件。

posted on 2007-10-09 14:39  Burt  阅读(4205)  评论(4编辑  收藏  举报