预处理器

欲练神功,引刀自宫。为了避免内存管理的烦恼,Java咔嚓一下,把指针砍掉了。当年.Net也追随潮流,咔嚓了一下,化名小桂子,登堂入室进了皇宫。康熙往下面一抓:咦?还在?——原来是假太监韦小宝。

打开unsafe选项,C#指针就biu的一下子蹦出来了。指针很强大,没必要抛弃这一强大的工具。诚然,在大多数情况下用不上指针,但在特定的情况下还是需要用到的。比如:

(1)大规模的运算中使用指针来提高性能;

(2)与非托管代码进行交互;

(3)在实时程序中使用指针,自行管理内存和对象的生命周期,以减少GC的负担。

目前使用指针的主要语言是C和C++。但是由于语法限制,C和C++中的指针的玩法很单调,在C#中,可以进行更优雅更好玩的玩法。本文是《重新认识C#: 玩转指针》一文的续篇,主要是对《重新认识C#: 玩转指针》内容进行总结和改进。

C#下使用指针有两大限制:

(1)使用指针只能操作struct,不能操作class;

(2)不能在泛型类型代码中使用未定义类型的指针。

第一个限制没办法突破,因此需要将指针操作的类型设为struct。struct + 指针,恩,就把C#当更好的C来用吧。对于第二个限制,写一个预处理器来解决问题。

下面是我写的简单的C#预处理器的代码,不到200行:
1 using System;
2 using System.Collections.Generic;
3 using System.IO;
4 using System.Text;
5 using System.Text.RegularExpressions;
6
7 namespace Orc.Util.Csmacro
8 {
9 class Program
10 {
11 static Regex includeReg = new Regex("#region\s+include.+\s+#endregion");
12 static Regex mixinReg = new Regex("(?<=#region\s+mixin\s)[\s|\S]+(?=#endregion)");
13 ///
14 /// Csmacro [dir|filePath]
15 ///
16 /// 语法:
17 /// #region include ""
18 /// #endregion
19 ///
20 ///
21 ///
22 static void
23 #region include<>
24 Main
25 #endregion
26 (string[] args)
27 {
28 if (args.Length != 1)
29 {
30 PrintHelp();
31 return;
32 }
33
34 String filePath = args[0];
35
36 Path.GetDirectoryName(filePath);
37 String dirName = Path.GetDirectoryName(filePath);
38 #if DEBUG
39 Console.WriteLine("dir:" + dirName);
40 #endif
41 String fileName = Path.GetFileName(filePath);
42 #if DEBUG
43 Console.WriteLine("file:" + fileName);
44 #endif
45
46 if (String.IsNullOrEmpty(fileName))
47 {
48 Csmacro(new DirectoryInfo(dirName));
49 }
50 else
51 {
52 if (fileName.EndsWith(".cs") == false)
53 {
54 Console.WriteLine("Csmacro只能处理后缀为.cs的源程序.");
55 }
56 else
57 {
58 Csmacro(new FileInfo(fileName));
59 }
60 }
61
62 Console.WriteLine("[Csmacro]:处理完毕.");
63
64 #if DEBUG
65 Console.ReadKey();
66 #endif
67 }
68
69 static void Csmacro(DirectoryInfo di)
70 {
71 Console.WriteLine("[Csmacro]:进入目录" + di.FullName);
72
73 foreach (FileInfo fi in di.GetFiles("*.cs", SearchOption.AllDirectories))
74 {
75 Csmacro(fi);
76 }
77 }
78
79 static void Csmacro(FileInfo fi)
80 {
81 String fullName = fi.FullName;
82 if (fi.Exists == false)
83 {
84 Console.WriteLine("[Csmacro]:文件不存在-" + fullName);
85 }
86 else if (fullName.EndsWith("_Csmacro.cs"))
87 {
88 return;
89 }
90 else
91 {
92 String text = File.ReadAllText(fullName);
93
94 DirectoryInfo parrentDirInfo = fi.Directory;
95
96 MatchCollection mc = includeReg.Matches(text);
97 if (mc == null || mc.Count == 0) return;
98 else
99 {
100 Console.WriteLine("[Csmacro]:处理文件" + fullName);
101
102 StringBuilder sb = new StringBuilder();
103
104 Int32 from = 0;
105 foreach (Match item in mc)
106 {
107 sb.Append(text.Substring(from, item.Index - from));
108 from = item.Index + item.Length;
109 sb.Append(Csmacro(parrentDirInfo, item.Value));
110 }
111
112 sb.Append(text.Substring(from, text.Length - from));
113
114 String newName = fullName.Substring(0, fullName.Length - 3) + "_Csmacro.cs";
115 if (File.Exists(newName))
116 {
117 Console.WriteLine("[Csmacro]:删除旧文件" + newName);
118 }
119 File.WriteAllText(newName, sb.ToString());
120 Console.WriteLine("[Csmacro]:生成文件" + newName);
121 }
122 }
123 }
124
125 static String Csmacro(DirectoryInfo currentDirInfo, String text)
126 {
127 String outfilePath = text.Replace("#region", String.Empty).Replace("#endregion", String.Empty).Replace("include",String.Empty).Replace(""",String.Empty).Trim();
128 try
129 {
130 if (Path.IsPathRooted(outfilePath) == false)
131 {
132 outfilePath = currentDirInfo.FullName + @"" + outfilePath;
133 }
134 FileInfo fi = new FileInfo(outfilePath);
135 if (fi.Exists == false)
136 {
137 Console.WriteLine("[Csmacro]:文件" + fi.FullName + "不存在.");
138 return text;
139 }
140 else
141 {
142 return GetMixinCode(File.ReadAllText(fi.FullName));
143 }
144 }
145 catch (Exception ex)
146 {
147 Console.WriteLine("[Csmacro]:出现错误(" + outfilePath + ")-" + ex.Message);
148 }
149 finally
150 {
151 }
152 return text;
153 }
154
155 static String GetMixinCode(String txt)
156 {
157 Match m = mixinReg.Match(txt);
158 if (m.Success == true)
159 {
160 return m.Value;
161 }
162 else return String.Empty;
163 }
164
165 static void PrintHelp()
166 {
167 Console.WriteLine("Csmacro [dir|filePath]");
168 }
169 }
170 }

然后编译为 Csmacro.exe ,放入系统路径下。在需要使用预处理器的项目中添加 Pre-build event command lind:

Csmacro.exe $(ProjectDir)

Visual Studio 有个很好用的关键字 “region” ,我们就把它当作我们预处理器的关键字。include 一个文件的语法是:

region include "xxx.cs"

endregion

一个文件中可以有多个 #region include 块。

被引用的文件不能全部引用,因为一个C#文件中一般包含有 using,namespace … 等,全部引用的话会报编译错误。因此,在被引用文件中,需要通过关键字来规定被引用的内容:

region mixin

endregion

这个预处理器比较简单。被引用的文件中只能存在一个 #region mixin 块,且在这个region的内部,不能有其它的region块。

预处理器 Csmacro.exe 的作用就是找到所有 cs 文件中的 #region include 块,根据 #region include 路径找到被引用文件,将该文件中的 #region mixin 块 取出,替换进 #region include 块中,生成一个以_Csmacro.cs结尾的新文件 。

由于C#的两个语法糖“partial” 和 “using”,预处理器非常好用。如果没有这两个语法糖,预处理器会很丑陋不堪。(谁说语法糖没价值!一些小小的语法糖,足以实现新的编程范式。)

partial 关键字 可以保证一个类型的代码存在几个不同的源文件中,这保证了预处理器的执行,您可以像写正常的代码一样编写公共部分代码,并且正常编译。

using 关键字可以为类型指定一个的别名。这是一个不起眼的语法糖,却在本文中非常重要:它可以为不同的类型指定一个相同的类型别名。之所以引入预处理器,就是为了复用包含指针的代码。我们可以将代码抽象成两部分:变化部分和不变部分。一般来说,变化部分是类型的型别,如果还有其它非类型的变化,我们也可以将这些变化封装成新的类型。这样一来,我们可以将变化的类型放在源文件的顶端,使用using 关键字,命名为固定的别名。然后把不变部分的代码,放在 #region mixin 块中。这样的话,让我们需要 #region include 时,只需要在 #region include 块的前面(需要在namespace {} 的外部)为类型别名指定新的类型。

举例说明,位图根据像素的格式可以分为很多种,这里假设有两种图像,一种是像素是一个Byte的灰度图像ImageU8,一个是像素是一个Argb32的彩色图像ImageArgb32。ImageU8代码如下:

1 public class ImageU8
2 {
3 public Int32 Width { get; set; }
4 public Int32 Height { get; set; }
5
6 public unsafe Byte* Pointer;
7 public unsafe void SetValue(Int32 row, Int32 col, Byte value)
8 {
9 Pointer[row * Width + col] = value;
10 }
11 }

在 ImageArgb32 中,我们也要写重复的代码:

1 public class ImageArgb32
2 {
3 public Int32 Width { get; set; }
4 public Int32 Height { get; set; }
5
6 public unsafe Argb32* Pointer;
7 public unsafe void SetValue(Int32 row, Int32 col, Argb32 value)
8 {
9 Pointer[row * Width + col] = value;
10 }
11 }

对于 Width和Height属性,我们可以建立基类来进行抽象和复用,然而,对于m_pointer和SetValue方法,如果放在基类中,则需要抹去类型信息,且变的十分丑陋。由于C#不支持泛型类型的指针,也无法提取为泛型代码。

使用 Csmacro.exe 预处理器,我们就可以很好的处理。

首先,建立一个模板文件 Image_Template.cs

代码

1 using TPixel = System.Byte;
2
3 using System;
4
5 namespace XXX.Hidden
6 {
7 class Image_Template
8 {
9 public Int32 Width { get; set; }
10 public Int32 Height { get; set; }
11
12 #region mixin
13
14 public unsafe TPixel* Pointer;
15 public unsafe void SetValue(Int32 row, Int32 col, TPixel value)
16 {
17 Pointer[row * Width + col] = value;
18 }
19
20 #endregion
21 }
22 }

然后建立一个基类 BaseImage,再从BaseImage派生ImageU8和ImageArgb32。两个派生类都是 partial 类:

下面我们建立一个 ImageU8_ClassHelper.cs 文件,来 #region include 引用上面的模板文件:

1 using TPixel = System.Byte;
2
3 using System;
4 namespace XXX
5 {
6 public partial class ImageU8
7 {
8 #region include "Image_Template.cs"
9 #endregion
10 }
11 }

编译,编译器会自动生成文件 “ImageU8_ClassHelper_Csmacro.cs” 。将这个文件引入项目中,编译通过。这个文件内容是:

代码

1 using TPixel = System.Byte;
2
3 using System;
4 namespace XXX
5 {
6 public partial class ImageU8
7 {
8
9 public unsafe TPixel* Pointer;
10 public unsafe void SetValue(Int32 row, Int32 col, TPixel value)
11 {
12 Pointer[row * Width + col] = value;
13 }
14
15 }
16 }

对于 ImageArgb32 类也可以进行类似操作。

从这个例子可以看出,使用 partial 关键字,能够让原代码、模板代码、ClassHelper代码三者共存。使用 using 关键字,可以分离出代码中变化的部分出来。

下面是我写的图像操作的一些模板代码:

(1)通过模板提供指针和索引器:

代码

1 using TPixel = System.Byte;
2 using TCache = System.Int32;
3 using TKernel = System.Int32;
4
5 using System;
6 using System.Collections.Generic;
7 using System.Text;
8
9 namespace Orc.SmartImage.Hidden
10 {
11 public abstract class Image_Template : UnmanagedImage
12 {
13 private Image_Template()
14 : base(1,1)
15 {
16 throw new NotImplementedException();
17 }
18
19 #region mixin
20
21 public unsafe TPixel* Start { get { return (TPixel)this.StartIntPtr; } }
22
23 public unsafe TPixel this[int index]
24 {
25 get
26 {
27 return Start[index];
28 }
29 set
30 {
31 Start[index] = value;
32 }
33 }
34
35 public unsafe TPixel this[int row, int col]
36 {
37 get
38 {
39 return Start[row * this.Width + col];
40 }
41 set
42 {
43 Start[row * this.Width + col] = value;
44 }
45 }
46
47 public unsafe TPixel
Row(Int32 row)
48 {
49 if (row < 0 || row >= this.Height) throw new ArgumentOutOfRangeException("row");
50 return Start + row * this.Width;
51 }
52
53 #endregion
54 }
55 }

(2)通过模板提供常用的操作和Lambda表达式支持

代码

1 using TPixel = System.Byte;
2 using TCache = System.Int32;
3 using TKernel = System.Int32;
4
5 using System;
6 using System.Collections.Generic;
7 using System.Text;
8
9 namespace Orc.SmartImage.Hidden
10 {
11 static class ImageClassHelper_Template
12 {
13 #region mixin
14
15 public unsafe delegate void ActionOnPixel(TPixel* p);
16 public unsafe delegate void ActionWithPosition(Int32 row, Int32 column, TPixel* p);
17 public unsafe delegate Boolean PredicateOnPixel(TPixel* p);
18
19 public unsafe static void ForEach(this UnmanagedImage src, ActionOnPixel handler)
20 {
21 TPixel* start = (TPixel)src.StartIntPtr;
22 TPixel
end = start + src.Length;
23 while (start != end)
24 {
25 handler(start);
26 ++start;
27 }
28 }
29
30 public unsafe static void ForEach(this UnmanagedImage src, ActionWithPosition handler)
31 {
32 Int32 width = src.Width;
33 Int32 height = src.Height;
34
35 TPixel* p = (TPixel)src.StartIntPtr;
36 for (Int32 r = 0; r < height; r++)
37 {
38 for (Int32 w = 0; w < width; w++)
39 {
40 handler(w, r, p);
41 p++;
42 }
43 }
44 }
45
46 public unsafe static void ForEach(this UnmanagedImage src, TPixel
start, uint length, ActionOnPixel handler)
47 {
48 TPixel* end = start + src.Length;
49 while (start != end)
50 {
51 handler(start);
52 ++start;
53 }
54 }
55
56 public unsafe static Int32 Count(this UnmanagedImage src, PredicateOnPixel handler)
57 {
58 TPixel* start = (TPixel)src.StartIntPtr;
59 TPixel
end = start + src.Length;
60 Int32 count = 0;
61 while (start != end)
62 {
63 if (handler(start) == true) count++;
64 ++start;
65 }
66 return count;
67 }
68
69 public unsafe static Int32 Count(this UnmanagedImage src, Predicate handler)
70 {
71 TPixel* start = (TPixel)src.StartIntPtr;
72 TPixel
end = start + src.Length;
73 Int32 count = 0;
74 while (start != end)
75 {
76 if (handler(start) == true) count++;
77 ++start;
78 }
79 return count;
80 }
81
82 public unsafe static List Where(this UnmanagedImage src, PredicateOnPixel handler)
83 {
84 List list = new List();
85
86 TPixel
start = (TPixel)src.StartIntPtr;
87 TPixel
end = start + src.Length;
88 while (start != end)
89 {
90 if (handler(start) == true) list.Add(start);
91 ++start;
92 }
93
94 return list;
95 }
96
97 public unsafe static List Where(this UnmanagedImage src, Predicate handler)
98 {
99 List list = new List();
100
101 TPixel
start = (TPixel)src.StartIntPtr;
102 TPixel
end = start + src.Length;
103 while (start != end)
104 {
105 if (handler(start) == true) list.Add(start);
106 ++start;
107 }
108
109 return list;
110 }
111
112 ///
113 /// 查找模板。模板中值代表实际像素值。负数代表任何像素。返回查找得到的像素的左上端点的位置。
114 ///
115 ///
116 ///
117 public static unsafe List<System.Drawing.Point> FindTemplate(this UnmanagedImage src, int[,] template)
118 {
119 List<System.Drawing.Point> finds = new List<System.Drawing.Point>();
120 int tHeight = template.GetUpperBound(0) + 1;
121 int tWidth = template.GetUpperBound(1) + 1;
122 int toWidth = src.Width - tWidth + 1;
123 int toHeight = src.Height - tHeight + 1;
124 int stride = src.Width;
125 TPixel* start = (TPixel)src.SizeOfType;
126 for (int r = 0; r < toHeight; r++)
127 {
128 for (int c = 0; c < toWidth; c++)
129 {
130 TPixel
srcStart = start + r * stride + c;
131 for (int rr = 0; rr < tHeight; rr++)
132 {
133 for (int cc = 0; cc < tWidth; cc++)
134 {
135 int pattern = template[rr, cc];
136 if (pattern >= 0 && srcStart[rr * stride + cc] != pattern)
137 {
138 goto Next;
139 }
140 }
141 }
142
143 finds.Add(new System.Drawing.Point(c, r));
144
145 Next:
146 continue;
147 }
148 }
149
150 return finds;
151 }
152
153 #endregion
154 }
155 }

配合lambda表达式,用起来很爽。在方法“FindTemplate”中,有这一句:

if (pattern >= 0 && srcStart[rr * stride + cc] != pattern)

其中 srcStart[rr * stride + cc] 是 TPixel 不定类型,而 pattern 是 int 类型,两者之间需要进行比较,但是并不是所有的类型都提供和整数之间的 != 操作符。为此,我建立了个新的模板 TPixel_Template。

(3)通过模板提供 != 操作符 的定义

1 using TPixel = System.Byte;
2 using System;
3
4 namespace Orc.SmartImage.Hidden
5 {
6 public struct TPixel_Template
7 {
8 /*
9 #region mixin
10
11 public static Boolean operator ==(TPixel lhs, int rhs)
12 {
13 throw new NotImplementedException();
14 }
15
16 public static Boolean operator !=(TPixel lhs, int rhs)
17 {
18 throw new NotImplementedException();
19 }
20
21 public static Boolean operator ==(TPixel lhs, double rhs)
22 {
23 throw new NotImplementedException();
24 }
25
26 public static Boolean operator !=(TPixel lhs, double rhs)
27 {
28 throw new NotImplementedException();
29 }
30
31 public static Boolean operator ==(TPixel lhs, float rhs)
32 {
33 throw new NotImplementedException();
34 }
35
36 public static Boolean operator !=(TPixel lhs, float rhs)
37 {
38 throw new NotImplementedException();
39 }
40
41 #endregion
42
43 */
44 }
45 }

这里,在 #region mixin 块被注释掉了,不注释掉编译器会报错。注释之后,不会影响程序预处理。

通过 ClassHelper类来使用模板:

代码

1 using System;
2 using System.Collections.Generic;
3 using System.Text;
4
5 namespace Orc.SmartImage
6 {
7 using TPixel = Argb32;
8 using TCache = System.Int32;
9 using TKernel = System.Int32;
10
11 public static partial class ImageArgb32ClassHelper
12 {
13 #region include "ImageClassHelper_Template.cs"
14 #endregion
15 }
16
17 public partial class ImageArgb32
18 {
19 #region include "Image_Template.cs"
20 #endregion
21 }
22
23 public partial struct Argb32
24 {
25 #region include "TPixel_Template.cs"
26 #endregion
27 }
28 }

由于 Argb32 未提供和 int 之间的比较,因此,在这里 #region include "TPixel_Template.cs"。而Byte可以与int比较,因此,在ImageU8中,就不需要#region include "TPixel_Template.cs":

3 using System;
4 using System.Collections.Generic;
5 using System.Text;
6
7 namespace Orc.SmartImage
8 {
9 using TPixel = System.Byte;
10 using TCache = System.Int32;
11 using TKernel = System.Int32;
12
13 public static partial class ImageU8ClassHelper
14 {
15 #region include "ImageClassHelper_Template.cs"
16 #endregion
17 }
18
19 public partial class ImageU8
20 {
21 #region include "Image_Template.cs"
22 #endregion
23 }
24 }

是不是很有意思呢?强大的指针,结合C#强大的语法和快速编译,至少在图像处理这一块是很好用的。

 预处理器指令的开头都有符号#。

  1. define 和 #undef

  #define 的用法如下所示: #define DEBUG

  它告诉编译器存在给定名称的符号,在本例中是DEBUG。这有点类似于声明一个变量,但这个变量并没有真正的值,只是存在而已。

  这个符号不是实际代码的一部分,而只在编译器编译代码时存在。在C#代码中它没有任何意义。

  #undef 正好相反—— 它删除符号的定义: #undef DEBUG

  如果符号不存在,#undef 就没有任何作用。同样,如果符号已经存在,则#define 也不起作用。必须把#define 和#undef 命令放在C#源文件的开头位置,在声明要编译的任何对象的代码之前。

  #define 本身并没有什么用,但与其他预处理器指令(特别是#if)结合使用时,它的功能就非常强大了。

  这里应注意一般C#语法的一些变化。预处理器指令不用分号结束,一般一行上只有一条命令。这是因为对于预处理器指令,C#不再要求命令使用分号进行分隔。如果它遇到一条预处理器指令,就会假定下一条命令在下一行上。

  1. if、#elif、#else 和#endif

  这些指令告诉编译器是否要编译某个代码块。考虑下面的方法:

1 int DoSomeWork(double x)
2 {
3 // do something
4 #if DEBUG
5 Console.WriteLine("x is " + x);
6 #endif
7 }

  这段代码会像往常那样编译,但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 特性提供了另一种机制。
  #if 和#elif 还支持一组逻辑运算符“!”、“==”、“!=”和“||”。如果符号存在,就被认为是true,否则为false,例如:

1 #if W2K && (ENTERPRISE==false) // if W2K is defined but ENTERPRISE isn't

  1. warning 和 #error

  另两个非常有用的预处理器指令是#warning 和#error,当编译器遇到它们时,会分别产生警告或错误。如果编译器遇到#warning 指令,会给用户显示#warning 指令后面的文本,之后编译继续进行。如果编译器遇到#error 指令,就会给用户显示后面的文本,作为一条编译错误消息,然后会立即退出编译,不会生成IL 代码。

  使用这两条指令可以检查#define 语句是不是做错了什么事,使用#warning 语句可以提醒自己执行某个操作:

1 #if DEBUG && RELEASE
2 #error "You've defined DEBUG and RELEASE simultaneously!"
3 #endif
4 #warning "Don't forget to remove this line before the boss tests the code!"
5 Console.WriteLine("I hate this job.");

  1. region 和#endregion

  #region 和#endregion 指令用于把一段代码标记为有给定名称的一个块,如下所示。

1 #region Member Field Declarations
2 int x;
3 double d;
4 Currency balance;
5 #endregion

  这看起来似乎没有什么用,它不影响编译过程。这些指令的优点是它们可以被某些编辑器识别,包括Visual Studio .NET 编辑器。这些编辑器可以使用这些指令使代码在屏幕上更好地布局。

  1. line

  #line 指令可以用于改变编译器在警告和错误信息中显示的文件名和行号信息。这条指令用得并不多。

  如果编写代码时,在把代码发送给编译器前,要使用某些软件包改变输入的代码,就可以使用这个指令,因为这意味着编译器报告的行号或文件名与文件中的行号或编辑的文件名不匹配。

  #line 指令可以用于还原这种匹配。也可以使用语法#line default 把行号还原为默认的行号:

1 #line 164 "Core.cs" // We happen to know this is line 164 in the file
2 // Core.cs, before the intermediate
3 // package mangles it.
4 // later on
5 #line default // restores default line numbering

  1. pragma

  #pragma 指令可以抑制或还原指定的编译警告。与命令行选项不同,#pragma 指令可以在类或方法级别执行,对抑制警告的内容和抑制的时间进行更精细的控制。

  下面的例子禁止“字段未使用”警告,然后在编译MyClass 类后还原该警告。

1 #pragma warning disable 169
2 public class MyClass
3 {
4 int neverUsedField;
5 }
6 #pragma warning restore 169

转载于:https://www.cnblogs.com/bb-love-dd/p/5930169.html

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值