从一段代码开始
一个 .cs 文件里写着:
csharp
namespace MyApp;
public class UserService
{
public string GetUserName() => "Alice";
}
这段代码涉及三层组织:文件是物理存放单位,namespace MyApp 给类型起了完整名字 MyApp.UserService,class UserService 里封装了方法。
namespace 不是文件夹路径决定的,不是项目名决定的。源码里写什么就是什么。它只做一件事:让 UserService 的完整名字变成 MyApp.UserService,避免和其他命名空间里的同名 UserService 冲突。
到这为止,一个文件的内部结构是清晰的。但实际程序不可能只有一个文件。
多个文件怎么连在一起
text
UserService.cs
OrderService.cs
Program.cs
这三个文件之间可以互相调用吗?
可以。前提是它们属于同一个 .csproj 项目。
.csproj 是编译单位。它会把项目目录下所有 .cs 文件收集起来一起编译,输出一个程序集(assembly,通常是一个 .dll)。所以三个文件虽然在物理上是分开的,但在编译层面被合并进了同一个产物。
这就是同一个项目内的文件能互相访问的根本原因------不是因为 using 引入了文件,而是因为它们一起参与了编译。
一个 .csproj 的典型内容:
xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
OutputType:
Exe:控制台程序,编译后生成可执行文件,有入口点(Main或顶级语句),可以直接运行Library:类库,编译后生成.dll,没有入口点,被其他项目引用
TargetFramework:
- 决定编译器面向哪个 .NET 版本,当前是
net9.0 - 决定哪些 API 可以使用(.NET 9 提供的新 API 在
net8.0下不可用) - 决定运行时需要什么版本的 .NET 运行时
- 自动带入框架基础程序集,所以
System.Console、List<T>、Task等不需要额外引用
ImplicitUsings:
enable(默认):编译器自动生成一组常用全局using,包括System、System.Collections.Generic、System.IO、System.Linq、System.Threading、System.Threading.Tasks等。控制台项目的新模板默认开启disable:关闭此行为,所有using必须手写。学习阶段建议关闭,避免不知道某个类型来自哪个命名空间- 不影响显式写的
using
Nullable:
enable:开启可空引用类型分析。编译器会在引用类型可能为null时给出警告,帮助在编译期发现潜在的空引用异常- 是项目级设置。因为一个类型在这个文件里可能是
null、在另一个文件里不是,分析结果必须跨文件一致,所以不能文件级开关
程序集是一道边界
同一个项目编译出的程序集(assembly),不仅是编译产物的容器,还是一道访问控制边界。
internal 关键字的意思就是:当前程序集内可见。
csharp
internal class UserService { }
UserService 可以被同项目的其他文件访问,因为它们编译进同一个程序集。但如果另一个项目引用了这个 .dll,它看不到这个 internal 类。
所以文件之间能互相访问,不是因为"都在同一个文件夹",也不是因为"都写了 using",而是因为:
text
同一 .csproj → 编译进同一 assembly → internal 允许访问
到此解决了"一个项目内部怎么组织"的问题。接下来是"项目之间怎么组织"。
多个项目怎么组织
当代码规模变大,需要拆成多个项目。比如一个类库项目,一个控制台项目。
.sln(解决方案)就是用来把多个 .csproj 项目组织在一起的文件。它本身不定义编译规则,只是一个项目清单。真正的编译规则仍然在各自的 .csproj 里。
控制台项目想用类库项目的代码,需要在 .csproj 里写:
xml
<ItemGroup>
<ProjectReference Include="..\MyLib\MyLib.csproj" />
</ItemGroup>
这叫项目引用。编译时,构建系统会先编译 MyLib,再把编译产物 MyLib.dll 作为引用传入当前项目。所以项目引用本质上是程序集引用------最终两个项目仍然是各自独立的程序集。
正因为是各自独立的程序集,internal 的边界生效了:MyLib 里的 internal 类型,引用方是访问不到的。要跨项目共享,必须用 public。
引用第三方库
项目引用解决的是"我自己的多个项目之间怎么协作"。但如果想用的是别人写的库呢?比如 JSON 解析、HTTP 客户端、数据库连接。
NuGet 是 .NET 的包管理机制。在 .csproj 里写:
xml
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
dotnet restore 会把 Newtonsoft.Json 这个包下载到本地缓存,编译时把包里的程序集作为引用传入。本质上和项目引用一样,最终都是程序集引用。区别是:项目引用指向你自己的源码(需要先编译),包引用指向别人编译好的二进制(直接可用)。
框架自带的能力
还有一个问题:System.Console 不需要 ProjectReference,也不需要 PackageReference,为什么直接就能用?
因为 TargetFramework 不只是"选一个 .NET 版本号"。它还隐式引入了框架基础程序集。
xml
<TargetFramework>net9.0</TargetFramework>
这一行让编译器可以使用 .NET 9 框架提供的所有基础类型。包括:
System.Console(控制台输入输出)System.Collections.Generic.List<T>(泛型集合)System.Threading.Tasks.Task(异步任务)System.Linq.Enumerable(LINQ 查询)System.IO.File(文件读写)- 等等
这些不是"自动安装了 NuGet 包",而是 .NET 运行时本身自带的。它们和你的程序在同一个运行时环境里,不需要单独下载。
所以一个 C# 项目能用的类型实际来自三种渠道:
text
自己写的 → 当前项目源码
自己的其他项目 → ProjectReference
外部库 → PackageReference
框架自带 → TargetFramework 隐式引入
using 只是在这三种渠道都就绪后,帮你简化类型名的写法。
using 不负责引入类型
不论项目引用、包引用还是框架引用,做的都是同一件事:让编译器能看到对应的程序集和其中的类型。
using 做的是另一件事:简化名字。
csharp
using MyApp;
有了这一行,下文可以写 UserService 而不是 MyApp.UserService。
但反过来,如果只有 using MyApp; 而没有项目引用,编译器仍然找不到 UserService。因为它只知道"短名字对应的命名空间",但根本看不到包含这个类型的程序集。
分工很明确:
text
引用 → 编译器能不能看到类型
using → 能不能用短名字写
两者缺一不可,且不可互相替代。
编译后生成了什么
执行 dotnet build 后,项目下会出现两个目录:obj 和 bin。
obj 是编译器的中间工作目录。里面放编译缓存、自动生成的源码、中间版本的 dll。一般不需要手动关注。
bin 是最终输出目录,按配置和框架分层,如 bin/Debug/net9.0/。里面的关键文件:
.dll:程序集本体,真正承载了你写的 C# 代码.exe:启动器,负责调用 .NET 运行时来运行.dll。在 Linux 上可以直接用dotnet xxx.dll运行,不需要.exe.pdb:调试符号表,存的是源码行号和变量名等信息,断点调试靠它.deps.json:依赖清单,列出运行时需要加载哪些程序集.runtimeconfig.json:运行时配置,声明目标框架版本
两种构建方式
写代码时要能调试,发出去时要跑得快。这两件事需要的编译器优化策略不同。
Debug 构建关闭所有优化。代码执行顺序和源码尽可能一致,方便设断点、看变量。dotnet build 默认就是 Debug,产物输出到 bin/Debug/net9.0/。
Release 构建开启优化。生成的代码和源码可能顺序不同、变量可能被消除、调试信息精简。需要显式指定:dotnet build -c Release,产物输出到 bin/Release/net9.0/。
两个配置各自输出到不同目录,互不覆盖。
三个名字长得很像但不是一回事
当前项目中:
- 项目名:
L002.ProgramShape(.csproj文件名) - 程序集名:
L002.ProgramShape.dll(默认等于项目名) - 命名空间:
L002.ProgramShape(源码里写的namespace)
三者刚好一样,但这不是自动同步的。它们各自独立决定:
- 改
.csproj文件名 → 程序集名跟着变,命名空间不变 - 在
.csproj里改<AssemblyName>→ 输出dll名字变,其他不变 - 在源码里改
namespace→ 类型完整名变,其他不变
所以它们可以相同,这是惯例。但不是同一个概念。文件夹路径同样与它们都不绑定。
.csproj 为什么可以这么短
当前 .csproj 没有列出任何 .cs 文件,为什么项目目录下的 Program.cs 和 ProgramStructure.cs 仍然被编译了?
因为 Project Sdk="Microsoft.NET.Sdk" 启用了 SDK-style 项目的默认规则。核心规则之一是:项目目录下所有 .cs 文件自动作为编译输入。
其他默认规则包括:
ImplicitUsings设为enable时,自动生成常用的全局using(System、System.Linq等),省去手写。设disable后必须显式写using。Nullable是项目级设置,因为空值检查必须项目内一致。不能一个文件开一个关。
需要排除某文件时可以写 <Compile Remove="..." />,需要引入目录外的文件时可以写 <Compile Include="..." Link="..." />。
从全局命名空间找名字
所有命名空间之上还有一个"全局命名空间"。global:: 表示从这个根部开始找。
csharp
global::System.Console
global::L002.ProgramShape.ProgramStructure
它的作用是:当你自己写了一个也叫 System 的类,编译器可能会混淆,这时候用 global::System.Console 明确告诉它从根开始找。
但 global:: 只解决"名字从哪开始找"的问题。它不能用来绕过 internal、private 等访问修饰符。权限边界不由写法决定。
完整拼图
text
.sln → 项目集合,管多项目工作区
.csproj → 编译单位,决定怎么编译、引用什么、输出什么
.cs → 源码输入,多个文件一起编译进同一个 assembly
namespace → 逻辑命名层,决定类型完整名(namespace + 类名)
assembly → 编译产物边界,internal 的可见范围
dll / exe → 文件产物
当前项目源码 + ProjectReference + PackageReference + FrameworkReference
→ 四种类型来源,编译器能看到时才能使用
using
→ 只负责简写。没有对应引用,写再多 using 也没用
学会读 .csproj 不是为了背配置项,而是为了看懂一个项目依赖了什么、面向什么平台、有哪些编译行为。这也是后面项目结构、多项目协作、ASP.NET Core 工程文件的基础。
常见误区
- 以为
using就能引入外部类型。实际还需对应的项目引用、包引用或框架引用 - 以为
internal是 namespace 级别。实际是 assembly 级别 - 以为一个文件就是一个独立编译单元。实际以
.csproj整体为编译单位 - 以为 namespace 由文件夹路径自动决定。实际由源码里的
namespace关键字决定 - 以为
global::能绕过访问限制。实际只解决名字查找起点