C++ Primer 学习笔记 第四章 表达式
表达式由运算对象组成,对表达式求值得到一个结果。字面值和变量是最简单的表达式,其结果就是字面值和变量的值。把一个运算符和一个或多个运算对象组合起来可以生成较复杂的表达式。
C++定义了一元运算符、二元运算符和三元运算符。作用于一个运算对象的运算符是一元运算符,二三元同理。
函数调用也是一种特殊的运算符,它对运算对象的数量没有限制。
一些符号既可以作为一元运算符也可以作为二元运算符,比如*。它是一元还是二元运算符由它的上下文决定。
表达式求值过程中,运算对象常常由一种类型转换为另外一种类型,如二元运算符通常要求两个运算对象类型相同,但很多时候不相同也没关系,只要它们能转换为一种类型即可。小整数类型(bool、char、short等)通常会被提升成较大的整数类型(主要是int)。
C++定义了运算符作用于内置类型和复合类型对象时所执行的操作,当作用于类类型时,用户可以自行定义其含义,称之为重载运算符。IO库的>>和<<以及string对象、vector对象和迭代器使用的运算符都是重载的运算符。使用重载运算符时,其运算对象类型和返回值类型都是由该运算符定义,但运算对象个数、运算符优先级和结合律都无法改变。
C++表达式不是右值就是左值,这是由C语言继承而来,原来:左值可以位于赋值语句的左侧,右值则不能。在C++中二者区别很大:左值表达式的求值结果是一个对象或一个函数,但以常量对象为代表的某些左值实际不能作为赋值语句左侧运算对象,此外,虽然某些表达式求值结果是对象,但它们是右值而非左值。简单归纳:当对象用作左值时,用的是对象的身份(在内存中的位置),用作右值时,用的是对象的值。一个重要原则是:在需要右值的地方可以用左值来代替,但不能把右值当做左值使用。当一个左值被当成右值使用时,实际使用的是它的内容(值)。
·赋值运算符需要一个非常量左值作为其左侧运算对象,得到的结果也仍然是一个左值。
·取地址符作用于一个左值对象,返回一个指向该运算对象的指针,这个指针是右值。
·内置的解引用运算符、下标运算符、迭代器解引用运算符、string和vector的下标运算符的求值结果是一个左值。
·内置类型和迭代器的递增递减运算符作用于左值对象,其前置版本所得的结果也是左值。
使用decltype时,如果表达式求值结果是左值,decltype作用于该表达式得到引用类型,如p是int*类型,因为解引用生成左值,所以decltype(*p)的结果是int&,又因为取地址生成右值,所以decltype(&p)结果是int**(即不带引用)。
复合表达式指含有两个或多个运算符的表达式。求复合表达式的值时,优先级和结合律决定了运算对象的组合方式,表达式中的括号无视上述规则。
算术运算符满足左结合律,如果运算符优先级相同,将按照从左到右的顺序组合运算对象。
int i = f1() * f2(); // 我们不知道f1先被调用还是f2
int i = 0;
cout << i << " " << ++i << endl; // 未定义的,可能输出0 1也可能1 1,甚至还可能做出其他操作
.运算符优先级高于*解引用高于加减乘除:
vector<int> ivec;
ivec.push_back(2);
cout << *ivec.begin() + 1 << endl; // 输出3
逻辑与&&、逻辑或||、条件运算符(?:)、逗号运算符(,)明确规定了运算对象求值顺序。
运算对象的求值顺序与优先级和结合律无关。
建议:在拿不准时候用括号强制让表达式组合关系符合程序设计要求;如改变了某个运算对象的值,在表达式其他地方不要再出现这个运算对象。
以下为算术运算符(满足左结合律,能作用于任何算术类型以及能转化为算术类型的类型,其运算对象和求值结果都是右值,表达式求值之前小整数类型运算对象被提升成较大的整数类型,所有运算对象最终会转化成同一类型):
运算符 | 功能 |
---|---|
+ | 一元正号 |
- | 一元负号 |
– | – |
* | 乘法 |
/ | 除法 |
% | 求余 |
– | – |
+ | 加法 |
- | 减法 |
以上表格优先级顺序由 - -分隔。
一元正号运算符、加法运算符和减法运算符能作用于指针。当一元正号作用于一个指针或算数值时,返回运算对象值的一个提升后的副本。
bool b = true;
bool b2 = -b; // b2为true,-b用到表达式后b被提升为1,加负号为-1,-1再转化为true
溢出和其它算术运算异常:算术表达式产生未定义结果可能有两种情况,一是数学性质本身,如0做除数;二是计算结果超出了该类型所能表示的范围。
整数相除结果还是整数。小数部分被抛弃掉。
取余%运算符参与运算的对象必须是整数类型:
int i = 32767;
cout << i % 5 << endl; // 正确
cout << i % 5.1 << endl; // 错误,运算对象不全为整数
对于除法运算,两运算对象符号相同商为正,否则为负。C++语言早期版本允许结果为负值的商向上或向下取整数,C++11新标准规定只能向0取整(直接切除小数部分)。
根据取余运算,如果m和n是整数且n非0,则表达式(m / n) * n + m % n的求值结果与m相等,隐含意思是,如果m%n结果不为0,那么m%n符号与m相同。
C++早期允许m%n的符号匹配n的符号,而且m/n的商向负无穷方向取整,这在新标准中被禁用。
21 % 6; // 结果为3
21 % 7; // 结果为0
-21 % -8; // 结果为-5
21 % -5; // 结果为1
逻辑和关系运算符(作用于算术类型或指针类型,逻辑运算符作用于任何能转化为布尔值的类型,逻辑运算符和关系运算符的返回值都是布尔类型。值为0的运算对象(算术类型或指针)表示假,否则表示真。对于这两类运算符,运算对象和求值结果都是右值):
结合律 | 运算符 | 功能 | 用法 |
---|---|---|---|
右 | ! | 逻辑非 | !expr |
– | – | – | – |
左 | < | 小于 | expr < expr |
左 | <= | 小于等于 | expr <= expr |
左 | > | 大于 | expr > expr |
左 | >= | 大于等于 | expr >= expr |
– | – | – | – |
左 | == | 相等 | expr == expr |
左 | != | 不相等 | expr != expr |
– | – | – | – |
左 | && | 逻辑与 | expr && expr |
– | – | – | – |
左 | || | 逻辑或 | expr || expr |
上表中空白格为优先级分界线。
逻辑与和逻辑或运算符都是先求左侧运算对象再求右侧运算对象,当左侧运算对象无法确定表达式结果时才会计算右侧运算对象的值,这种策略称为短路求值:
(1)对逻辑与,只有左侧表达式值为真时才对右侧对象求值。
(2)对逻辑或,只有左侧表达式值为假时才对右侧对象求值。
while(index != s.size() && !isspace(s[index])){
index++;
} // 只有index没到末尾时才对右侧求值,避免了取到范围之外的值
以下为逻辑或的例子:
// 输出vector<string>中内容,如遇到空串或以逗号结尾的串时换行,其余用空格隔开
vector<string> svec;
svec.push_back("ssss");
svec.push_back("ddddd");
svec.push_back("");
svec.push_back("sds");
svec.push_back("sds");
svec.push_back("dsasad,");
svec.push_back("sds");
svec.push_back("sds");
for (const string &s : svec) { // s声明为了const的引用,当串特别大时避免了值的拷贝
cout << s; // 空串输出时控制台什么都不打印
if (s.empty() || s[s.size() - 1] == ',') { // 先判断s是否为空,如s为空表达式为真,就不会再对s最后一位取值判断
cout << endl;
} else {
cout << " ";
}
}
对于比较运算符:
if (i < j < k); // 当i与j比较结果先提升成整型,再与提升成整型的k比较
if (i < j && j < k); // 这才是数学中的i < j < k
当我们测试一个算术对象或指针对象的真值:
if(val); // 如果val是任意非0值,条件为真
if(!val); // 如果val是0时,条件为真
if(val == true); // 如果val值为1时才为真
相等性判断在与运算符和或运算符之前。
const char* cp = "ghjgj"; // 字符串字面值类型为const char *
cout << *cp << endl; // 对cp解引用输出的是char型元素,cp相当于数组名,相当于指针指向第一个字符,故输出g
cout << cp[0] << endl; // 同上
大于等于号的优先级高于相等和不等号。
赋值表达式左侧运算对象必须是一个可修改的左值:
int i = 0, j = 0, k = 0;
const int ci = i;
1024 = k; // 错误,字面值是右值
i + j = k; // 错误,算术表达式是右值
ci = k; // 错误,ci是常量(不可修改)左值
赋值运算的结果是它的左侧运算对象,并且是一个左值。结果的类型就是左侧运算对象的类型,如赋值运算符两侧运算对象类型不同,则右侧运算对象将转换为左侧运算对象的类型:
k = 0; // 结果是int型0
k = 3.14159; // 结果是int型3
C++新标准:
k = {3.14}; // 错误,窄化转换
列表初始化时如果左侧运算对象是内置类型,那么初始化列表只能包含一个值,而且该值即使转化的话其所占空间也不应该大于目标类型空间。如果是类类型,赋值运算的细节由类本身决定。
无论左侧对象是什么,初始值列表都可以为空,编译器会创建一个值初始化的临时量并将其赋给左侧运算对象:
string s = {};
double d = {};
cout << s.size() << endl; // 输出0
cout << d << endl; // 输出0
赋值运算满足右结合律:
int i,j;
i = j = 0; // 正确,ij都赋值为0
int ival, *pval;
ival = pval = 0; // 错误,虽然0能赋值给指针和整型类型,但在多重赋值语句中,对象的类型必须和右侧类型相同或由右侧类型转换得到。此句中int*不能转化为int
pval = ival = 0; // 错误,int不能赋值给int*
赋值运算符优先级较低:
while((i = get_value()) != 42); // 如果不加括号,条件中会先判断get_value()的值是否是42,之后赋值给i一个布尔值
因为赋值运算符优先级低于关系运算符,所以在条件语句中,赋值部分应该加上括号。
复合运算符:+=、-=、*=、/=、%=、<<=、>>=、&=、^=、|=
以上任何复合运算符都等价于a = a op b;
。区别在于普通运算符会求值两次,一次是作为右边子表达式的一部分求值,另一次是作为赋值运算的左侧对象求值。这两种方法除了对程序的性能有些许影响外可以忽略不计。
递增和递减运算符为对象加1和减1提供了一种简洁的书写形式,还可用于迭代器,由于很多迭代器本身不支持算术运算,因此递增和递减是必须的。
递增和递减运算符必须作用于左值运算对象,前置版本将对象作为左值返回,后置版本将对象作为右值返回。
建议除非必须,否则不用后置版本,前置版本把值加1后直接返回改变了的运算对象,而后置版本需要将原始值存储下来以便返回这个未修改的内容,如果我们不需要修改前的版本值,那么后置版本的操作就是一种浪费。
混用解引用和递增运算符:
auto pbeg = v.begin();
cout << *pbeg++ << endl; // 输出pbeg指向的对象,并使pbeg指向下一个对象
cout << *++pbeg << endl; // 输出pbeg指向的下一个对象
由于后置递增运算符优先级高于解引用,因此第一个输出语句等价于*(pbeg++)。而前置递增运算符和解引用运算符优先级相同,且满足右结合律,因此pbeg会先指向下一个对象,然后再解引用,等价于*(++pbeg)。
运算对象可按任意顺序求值:
// 以下循环目的是将string对象的第一个单词大写,但实际上以下循环行为未定义
while (beg != s.end() && !isspace(*beg))
*beg = toupper(*beg++);
问题在于,赋值运算符两端都用到了beg,并且右侧对象还改变了beg的值,编译器可能按以下思路处理:
*beg = toupper(*beg); // 先处理左侧的值
*(beg + 1) = toupper(*beg); // 先处理右侧的值
也可能按其他方式处理。
成员访问运算符有点运算符.和箭头运算符->,并且ptr->mem
等价于(*ptr).mem
。因为解引用优先级低于点运算符。
箭头运算符作用在指针类型的运算对象,结果是左值;点运算符作用的对象的成员是左(右)值时,结果就是左(右)值。
iter++->empty(); // iter是vector<string>::iterator类型,此句含义为先返回iter指向对象的empty()成员,再令iter指向下一个成员。
条件运算符cond ? expr1 : expr2
允许我们把简单if-else语句嵌入单个表达式中:
string finalgrade = (grade < 60) ? "fail" : "pass";
条件运算符只对expr1和expr2其中一个求值。
当条件运算符的两个表达式都是左值或能转换成同一种左值类型时,运算结果就是左值,否则就是右值。
条件运算符可以嵌套:
finalgrade = (grade > 90) ? "high pass" : (grade < 60) ? "fail" : "pass"; // 90以上high pass,60-90pass,60以下fail
条件运算符满足右结合律。
条件运算符优先级非常低,当输出语句中使用时:
cout << ((grade < 60) ? "fail" : "pass") << endl; // 正确
cout << grade < 60 ? "fail" : "pass"; // 错误,试图比较cout和60
cout << (grade < 60) ? "fail" : "pass"; // 错误,输出0或1
位运算符作用于整数类型和bitset类型的运算对象,并把运算对象看做二进制位集合。
以下为位运算符(左结合律):
运算符 | 功能 | 用法 |
---|---|---|
~ | 位求反 | ~ expr |
– | – | – |
<< | 左移 | expr1 << expr2 |
>> | 右移 | expr1 >> expr2 |
– | – | – |
& | 位与 | expr & expr |
– | – | – |
^ | 位异或 | expr ^ expr |
– | – | – |
| | 位或 | expr | expr |
表中–表示优先级分界线。
一般,如果运算对象是“小整型”,则它的值会被自动提升为较大的整数类型。运算对象可以是带符号的,也可以是无符号的,如果运算对象是带符号的且它的值为负,那么位运算符如何处理运算对象的“符号位”依赖于机器,而且,此时的左移操作可能会改变符号位的值,因此是未定义行为。
移位运算符右侧运算对象一定不能为负,而且值必须严格小于结果的位数,否则会产生未定义行为。二进制位或者向左移或者向右移,移出边界之外的位就被舍弃掉了:
unsigned char bits = 0233; // bits为八进制数
bits << 8; // bits提成为了int类型,然后左移8位
左移运算符在右侧插入0,右移运算符时如果运算对象是无符号类型则在左侧插入0,如果是带符号类型在左侧插入符号位的副本或值为0的副本。
位求反运算符将各位0置为1,1置为0:
unsigned char bits = 0227;
~bit; // bit被提升为int类型,之后1变0,0变1
使用位运算符:
// 该班有30名学生,用每一位代表一个学生是否通过测验
unsigned long quiz1 = 0; // unsigned long保证最低32位,能容纳所有学生
quiz1 |= (1UL << 27); // 代表27号学生通过,使quiz1和只有27位为1的unsigned long的值位或,使quiz1第27位置为1
quiz1 &= ~(1UL << 27); // 代表27号学生未通过,使quiz1和只有第27位为0的unsigned long的值位与,使quiz1第27位置的值为0
bool status = quiz1 & (1UL << 27); // 查看第27号学生是否通过测验
移位运算符(又叫IO运算符)满足左结合律。
sizeof运算符返回一条表达式或一个类型名字所占的字节数,满足右结合律,其所得的值是一个size_t类型的常量表达式,其运算对象有两种形式:
sizeof (type);
sizeof expr; // 返回表达式结果类型的大小,但并不实际计算其运算对象的值。
Sales_data data, *p;
sizeof(Sales_data); // 存储Sales_data类型的对象所占的空间大小
sizeof data; // data的类型的大小,同上
sizeof p; // 指针所占的大小
sizeof *p; // p所指类型的空间大小,即sizeof(Sales_data)
sizeof data.revenue; // Sales_data的revenue成员对应类型的大小
sizeof Sales_data::revenue; // 同上,C++11新标准,在不创建类的对象时也能获取成员的大小
以上代码中,sizeof *p的*和sizeof优先级一样并且sizeof满足右结合律,表达式从右往左顺序组合,它等价于sizeof(*p),而且sizeof不会实际求运算对象的值,即使p是一个无效(未初始化)的指针也能获取其所指类型的大小。
对数组执行sizeof运算得到整个数组所占空间大小。
对string或vector对象执行sizeof运算只返回该类型固定部分的大小,不会计算对象中元素占了多少空间。
获取数组元素个数:
int num = sizeof(ia) / sizeof(*ia);
int x[10];
int* p = x;
cout << sizeof(x) / sizeof(*x) << endl; // 输出数组元素个数10
cout << sizeof(p) / sizeof(*p) << endl; // 输出指针所占空间与其所指的元素类型所占空间的比值,p实际指向数组x的第一个元素,因此输出值为1
逗号运算符含两个运算对象,按从左往右顺序求值,和逻辑与、逻辑或以及条件运算符一样,它也规定了运算对象求值的顺序。对于逗号运算符,首先对左侧的表达式求值,然后将求值结果丢弃掉,逗号运算符的真正结果是右侧表达式的值,如果右侧运算对象是左值,那么最终的求值结果也是左值:
// 把从size到1的值赋给ivec中的元素
vector<int>::size_type cnt = ivec.size();
for (vector<int>::size_type ix = 0; ix != ivec.size(); ++ix, --cnt){
ivec[ix] = cnt;
}
如果两种类型可以相互转换,那么它们就是关联的。
隐式转换:
int ival = 3.541 + 3; // ival被赋值为6
以上代码中C++不会直接将两个不同类型的值相加,而是先根据类型转换规则设法将运算对象的类型统一后再求值,上述类型转换是自动进行的,无需程序员介入,有时甚至不需要程序员了解,因此它们被称为隐式转换。
算术类型之间的隐式转换被设计得尽可能避免损失精度,很多时候,如果表达式中既有整数类型也有浮点数类型时,整型会转换为浮点型,上面例子中,3转换为double类型,然后执行浮点数加法,所得结果类型是double。接下来进行初始化,初始值被转换为被初始化对象的类型,即double转化为int。
何时发生隐式转换:
(1)大多数表达式中,比int类型小的整型值先提升为较大的整数类型。
(2)条件中,非布尔值转化为布尔类型。
(3)初始化过程中,初始值转化为变量类型;赋值语句中,右侧运算对象转换成左侧运算对象类型。
(4)如果算术运算或关系运算的运算对象有多种类型,需要转换成同一种类型。(如带符号数和无符号比较大小时)
(5)函数调用时。
算术转换含义是把一种算术类型转换为另外一种算术类型。算术转换的规则定义了一套类型转换的层次,其中运算符的运算对象将转换成最宽的类型(如比较大的运算对象类型是long double,那么另外一个运算对象类型也会转化为long double)。还有当表达式中既有浮点数又有整数类型时,整数值将转换成相应的浮点类型。
整型提升负责把小整数类型转换成较大的整数类型。对于bool、char、signed char、unsigned char、short、和unsigned short等类型,只要它们所有可能的值都能存在int中,它们就被提升为int,否则提升成unsigned int类型。较大的char类型(wchar_t、char16_t、char32_t)提升成int、unsigned int、long、unsigned long、long long、和unsigned long long 中最小的一种类型,前提是转换后的类型要能容纳原类型所有可能的值。
某个运算符运算对象类型不一致,首先执行整型提升,如果提升后两者类型相同,无需进行下一步转换,否则如果提升后的结果都是带符号或不带符号的时,则两者转化为较大的类型。如果一个运算对象是带符号的,一个是无符号的,并且无符号类型大于等于带符号类型,那么带符号运算对象转化成无符号的。最后一种结果是带符号类型大于无符号类型,此时转换结果依赖于机器,此时如果无符号类型的所有值都能存在该带符号类型中,则无符号类型的运算对象转换为带符号类型,否则,带符号类型转化为无符号类型(例如long和unsigned int,long带符号类型大于unsigned int无符号类型,当int和long的大小相同时,都转化为unsigned int,如果long类型占用空间比int大,则转化为long类型,其实以上都是选择最小的能盛下所有可能数的类型),以下为类型转换具体规则:
-
如果int的字节长度小于long的字节长度
类型等级由高到低依次为:long double、double、float、 unsigned long long、long long、unsigned long、long、unsigned int、int
-
如果int的字节长度等于long的字节长度
类型等级由高到低依次为:long double、double、float、unsigned long long、long long、unsigned long、unsigned int、long、int
在大多数用到数组的表达式中,数组自动转化成指针,在数组用作decltype关键字参数、取地址符、sizeof以及typeid等运算符对象时,不会转化为指针,如果引用一个数组int (&arrRef)[10] = array;
,上述转换也不会发生。
指针的转换:常量整数值0或字面值nullptr能转换成任意指针类型;指向任意非常量的指针都能转化成void*;指向任意对象的指针都能转化成const void*。
条件判断时,如果指针或算术类型的值为0,转换为false,否则转换成true。
允许将指向非常量类型的指针转换成指向相应常量类型的指针,对于引用也是这样。
类类型定义的转换:
string s = "a value"; // 字符串字面值转化成string类型
while (cin >> s); // istream类型转换成布尔类型
显式转换:将对象显式地转换成另外一种类型:
int i = 5, j = 2;
double slope = i / j; // slope值仍然是2,要用强制类型转换把i和/或j显式转换为浮点型才能得到2.5
命名的强制类型转换:
cast-name<type>(expression);
其中,type是转换的目标类型而expression是要转换的值。如果type是引用类型,则结果是左值。cast-name是static_cast、dynamic_cast、const_cast和reinterpret_cast中的一种。dynamic_cast支持运行时类型识别。
static_cast:任何具有明确定义的类型转换,只要不包含底层const,都可以使用static_cast:
double slope = static_cast<double>(j) / i;
当我们需要把较大的算术类型赋值给较小类型时,一般编译器会给出警告,使用static_cast可以消除警告。
我们也可以使用static_cast找回存在于void*指针中的值:
void *p = &d;
double *dp = static_cast<double*>(p); // 我们必须确保转换后的类型与指针所指类型相同,否则会产生未定义后果
const_cast:只能改变运算对象的底层const:
const char *pc;
char *p = const_cast<char*>(pc); // 正确,但通过p写值是未定义行为
如果对象本身不是一个常量,通过这种方法获得写权限是合法行为,但如果对象是一个常量,那么写操作会产生未定义后果。
static_cast和const_cast都能添加对象的const属性,但只有const_cast能移除对象的const属性。
只有const_cast能改变表达式的常量属性,使用其他形式的命名强制类型转换改变表达式的常量属性都将引发编译器错误,同样地,也不能用const_cast改变表达式的类型:
const char* cp;
char* q = static_cast<char*>(cp); // 错误,static_cast不能转换掉const性质
static_cast<string>(cp); // 正确,字符串字面值转换成string类型
const_cast<string>(cp); // 错误,const_cast只能改变常量属性
reinterpret_cast:通常为运算对象的位模式提供较低层次上的重新解释,可以在任意指针(或引用)类型之间转换;指针到足够大的整数类型的转换;从无视大小的整数类型(包括枚举类型)到指针类型的转换:
int *ip;
char *pc = reinterpret_cast<char*>(ip);
我们必须牢记pc所指的真实对象是一个int而非字符,如把pc当成普通的字符指针使用就可能在运行时发生错误。
const int a = 10;
int* p = (int*)(&a); // 等价于const_cast<int*>(&a),强制类型转换
*p = 20; // 含义为通过p改变a的值,但由于a是const变量,具体行为未定义,取决于编译器,我用的编译器可以更改a
cout << "a = " << a << ", *p = " << *p << endl; // 输出a = 10, *p = 20
const int *c = &a; // 令c也指向a
cout << *c << endl; // 输出20
以上代码中第一句输出时,a的值还是10,这是因为由于a是常量表达式,所以在编译时就用10直接替换掉a,而*p=20是在运行时才知道的,因此实际上a的值已经被修改,但a输出还是10。这时再用一个指针取a中的值就可以取到20了。
reinterpret_cast本质上依赖于机器。
建议避免强制类型转换。
旧式的强制类型转换:
type (expr); // 函数形式的强制类型转换
(type) expr; // C语言风格的强制类型转换
const string* ps;
void* pv;
pv = (void*)ps;
pv = static_cast<void*>(const_cast<string*>(ps)); // 与上句等价
int i;
char* pc;
i = int(*pc);
i = static_cast<int>(*pc); // 与上句等价
double d;
pv = &d;
pv = static_cast<void*>(&d); // 与上句等价
pc = (char*)pv;
pc = static_cast<char*>(pv);
运算符优先级表:
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
· 开发者必知的日志记录最佳实践
· winform 绘制太阳,地球,月球 运作规律
· AI与.NET技术实操系列(五):向量存储与相似性搜索在 .NET 中的实现
· 超详细:普通电脑也行Windows部署deepseek R1训练数据并当服务器共享给他人
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 上周热点回顾(3.3-3.9)