【问题标题】:How would you code an efficient Circular Buffer in Java or C#?您将如何在 Java 或 C# 中编写高效的循环缓冲区?
【发布时间】:2010-10-10 01:22:40
【问题描述】:

我想要一个实现固定大小circular buffer 的简单类。它应该是高效的、易于使用的、通用的类型。

现在不必是MT-capable。以后总能加个锁,反正不会是高并发的。

方法应该是:.Add(),我猜是.List(),我在那里检索所有条目。再三考虑,我认为应该通过索引器进行检索。在任何时候,我都希望能够通过 index 检索缓冲区中的任何元素。但请记住,从一个时刻到下一个 Element[n] 可能会有所不同,因为 循环缓冲区 会填满并翻转。这不是堆栈,它是一个循环缓冲区。

关于“overflow”:我希望内部会有一个数组保存项目,并且随着时间的推移 headtail缓冲区将围绕该固定数组旋转。但这对用户来说应该是不可见的。不应有外部可检测到的“溢出”事件或行为。

这不是学校作业 - 它最常用于MRU cache 或固定大小的事务或事件日志。

【问题讨论】:

  • 应该是线程安全的吗?需要哪些操作,只需要入队和出队?还是您还想要:当前长度、随机访问读取、清除?上溢和下溢应该导致异常还是应该使用“bool TryDequeue(out T item)”模式?
  • Codeplex 示例:circularbuffer.codeplex.com
  • 在此处查看此答案:stackoverflow.com/a/7266175/288875(尽管不幸的是 org.apache.commons.collections.buffer.CircularFifoBuffer 似乎不使用泛型)
  • 现在似乎有一个从原始 Commons Collections 派生的版本,它使用了泛型:sourceforge.net/projects/collections(看起来该项目已移至 github)

标签: c# java class-design


【解决方案1】:

我会使用一个 T 数组,一个头尾指针,以及 add 和 get 方法。

赞:(Bug 搜索留给用户)

// Hijack these for simplicity
import java.nio.BufferOverflowException;
import java.nio.BufferUnderflowException;

public class CircularBuffer<T> {

  private T[] buffer;

  private int tail;

  private int head;

  @SuppressWarnings("unchecked")
  public CircularBuffer(int n) {
    buffer = (T[]) new Object[n];
    tail = 0;
    head = 0;
  }

  public void add(T toAdd) {
    if (head != (tail - 1)) {
        buffer[head++] = toAdd;
    } else {
        throw new BufferOverflowException();
    }
    head = head % buffer.length;
  }

  public T get() {
    T t = null;
    int adjTail = tail > head ? tail - buffer.length : tail;
    if (adjTail < head) {
        t = (T) buffer[tail++];
        tail = tail % buffer.length;
    } else {
        throw new BufferUnderflowException();
    }
    return t;
  }

  public String toString() {
    return "CircularBuffer(size=" + buffer.length + ", head=" + head + ", tail=" + tail + ")";
  }

  public static void main(String[] args) {
    CircularBuffer<String> b = new CircularBuffer<String>(3);
    for (int i = 0; i < 10; i++) {
        System.out.println("Start: " + b);
        b.add("One");
        System.out.println("One: " + b);
        b.add("Two");
        System.out.println("Two: " + b);
        System.out.println("Got '" + b.get() + "', now " + b);

        b.add("Three");
        System.out.println("Three: " + b);
        // Test Overflow
        // b.add("Four");
        // System.out.println("Four: " + b);

        System.out.println("Got '" + b.get() + "', now " + b);
        System.out.println("Got '" + b.get() + "', now " + b);
        // Test Underflow
        // System.out.println("Got '" + b.get() + "', now " + b);

        // Back to start, let's shift on one
        b.add("Foo");
        b.get();
    }
  }
}

【讨论】:

  • 发现一个bug:get()方法没有将内部数组的元素取出后设置为null,所以有轻微的内存泄漏。
  • 是的,正确的。 :) 我应该注意到 - 我的错是没有在测试代码中打印列表中的元素。没有不使用标准 API 类的错误那么大,或者在许多用例中线程安全的生产者/消费者队列更明智......
  • 除非我严重误解了规范,否则此代码存在重大缺陷。如果您不尝试检索元素,则可以将无限数量的元素插入队列而不会出现 BufferOverflowException。 cs.utsa.edu/~wagner/CS2213/queue/queue.html 有一个更好的实现。
  • 我敢肯定,在网络论坛上花费超过 5 分钟写答案(我说“留给用户寻找错误”)的人会有更好的实现。
  • 我很担心“互联网”现在实际上认为这是开始使用字符串编写环形缓冲区的第一页。作者没有错,但副作用是人类将遭受所有未猎杀的错误!
【解决方案2】:

这就是我将(或做过)在 Java 中编写高效循环缓冲区的方式。它由一个简单的数组支持。对于我的特定用例,我需要高并发吞吐量,因此我使用 CAS 来分配索引。然后,我创建了可靠副本的机制,包括整个缓冲区的 CAS 副本。我在short article 中详细介绍的案例中使用了它。

import java.util.concurrent.atomic.AtomicLong;
import java.lang.reflect.Array;

/**
 * A circular array buffer with a copy-and-swap cursor.
 *
 * <p>This class provides an list of T objects who's size is <em>unstable</em>.
 * It's intended for capturing data where the frequency of sampling greatly
 * outweighs the frequency of inspection (for instance, monitoring).</p>
 *
 * <p>This object keeps in memory a fixed size buffer which is used for
 * capturing objects.  It copies the objects to a snapshot array which may be
 * worked with.  The size of the snapshot array will vary based on the
 * stability of the array during the copy operation.</p>
 *
 * <p>Adding buffer to the buffer is <em>O(1)</em>, and lockless.  Taking a
 * stable copy of the sample is <em>O(n)</em>.</p>
 */
public class ConcurrentCircularBuffer <T> {
    private final AtomicLong cursor = new AtomicLong();
    private final T[]      buffer;
    private final Class<T> type;

    /**
     * Create a new concurrent circular buffer.
     *
     * @param type The type of the array.  This is captured for the same reason
     * it's required by {@link java.util.List.toArray()}.
     *
     * @param bufferSize The size of the buffer.
     *
     * @throws IllegalArgumentException if the bufferSize is a non-positive
     * value.
     */
    public ConcurrentCircularBuffer (final Class <T> type, 
                                     final int bufferSize) 
    {
        if (bufferSize < 1) {
            throw new IllegalArgumentException(
                "Buffer size must be a positive value"
                );
        }

        this.type    = type;
        this.buffer = (T[]) new Object [ bufferSize ];
    }

    /**
     * Add a new object to this buffer.
     *
     * <p>Add a new object to the cursor-point of the buffer.</p>
     *
     * @param sample The object to add.
     */
    public void add (T sample) {
        buffer[(int) (cursor.getAndIncrement() % buffer.length)] = sample;
    }

    /**
     * Return a stable snapshot of the buffer.
     *
     * <p>Capture a stable snapshot of the buffer as an array.  The snapshot
     * may not be the same length as the buffer, any objects which were
     * unstable during the copy will be factored out.</p>
     * 
     * @return An array snapshot of the buffer.
     */
    public T[] snapshot () {
        T[] snapshots = (T[]) new Object [ buffer.length ];

        /* Determine the size of the snapshot by the number of affected
         * records.  Trim the size of the snapshot by the number of records
         * which are considered to be unstable during the copy (the amount the
         * cursor may have moved while the copy took place).
         *
         * If the cursor eliminated the sample (if the sample size is so small
         * compared to the rate of mutation that it did a full-wrap during the
         * copy) then just treat the buffer as though the cursor is
         * buffer.length - 1 and it was not changed during copy (this is
         * unlikley, but it should typically provide fairly stable results).
         */
        long before = cursor.get();

        /* If the cursor hasn't yet moved, skip the copying and simply return a
         * zero-length array.
         */
        if (before == 0) {
            return (T[]) Array.newInstance(type, 0);
        }

        System.arraycopy(buffer, 0, snapshots, 0, buffer.length);

        long after          = cursor.get();
        int  size           = buffer.length - (int) (after - before);
        long snapshotCursor = before - 1;

        /* Highly unlikely, but the entire buffer was replaced while we
         * waited...so just return a zero length array, since we can't get a
         * stable snapshot...
         */
        if (size <= 0) {
            return (T[]) Array.newInstance(type, 0);
        }

        long start = snapshotCursor - (size - 1);
        long end   = snapshotCursor;

        if (snapshotCursor < snapshots.length) {
            size   = (int) snapshotCursor + 1;
            start  = 0;
        }

        /* Copy the sample snapshot to a new array the size of our stable
         * snapshot area.
         */
        T[] result = (T[]) Array.newInstance(type, size);

        int startOfCopy = (int) (start % snapshots.length);
        int endOfCopy   = (int) (end   % snapshots.length);

        /* If the buffer space wraps the physical end of the array, use two
         * copies to construct the new array.
         */
        if (startOfCopy > endOfCopy) {
            System.arraycopy(snapshots, startOfCopy,
                             result, 0, 
                             snapshots.length - startOfCopy);
            System.arraycopy(snapshots, 0,
                             result, (snapshots.length - startOfCopy),
                             endOfCopy + 1);
        }
        else {
            /* Otherwise it's a single continuous segment, copy the whole thing
             * into the result.
             */
            System.arraycopy(snapshots, startOfCopy,
                             result, 0, endOfCopy - startOfCopy + 1);
        }

        return (T[]) result;
    }

    /**
     * Get a stable snapshot of the complete buffer.
     *
     * <p>This operation fetches a snapshot of the buffer using the algorithm
     * defined in {@link snapshot()}.  If there was concurrent modification of
     * the buffer during the copy, however, it will retry until a full stable
     * snapshot of the buffer was acquired.</p>
     *
     * <p><em>Note, for very busy buffers on large symmetric multiprocessing
     * machines and supercomputers running data processing intensive
     * applications, this operation has the potential of being fairly
     * expensive.  In practice on commodity hardware, dualcore processors and
     * non-processing intensive systems (such as web services) it very rarely
     * retries.</em></p>
     *
     * @return A full copy of the internal buffer.
     */
    public T[] completeSnapshot () {
        T[] snapshot = snapshot();

        /* Try again until we get a snapshot that's the same size as the
         * buffer...  This is very often a single iteration, but it depends on
         * how busy the system is.
         */
        while (snapshot.length != buffer.length) {
            snapshot = snapshot();
        }

        return snapshot;
    }

    /**
     * The size of this buffer.
     */
    public int size () {
        return buffer.length;
    }
}

【讨论】:

  • 当您的缓冲区已处理 Integer.MAX_VALUE 元素时,此代码将严重失败,因为您将访问带有负索引的缓冲区 []
  • @nos — 是的,很好,尽管任何程序运行的时间足够长,可以通过这个缓冲区放置这么多东西,这将是相当令人印象深刻的,我已经继续并转换为 long 用于光标跟踪.对于所有实际目的,这应该是足够的值......
【解决方案3】:

这是我在生产代码中使用的即用型CircularArrayList implementation for Java。通过以 Java 推荐的方式覆盖 AbstractList,它支持 Java 集合框架中标准 List 实现所期望的所有功能(通用元素类型、子列表、迭代等)。

以下调用在 O(1) 内完成:

  • add(item) - 在列表末尾添加
  • remove(0) - 从列表的开头删除
  • get(i) - 检索列表中的随机元素

【讨论】:

    【解决方案4】:

    我会根据需要使用ArrayBlockingQueue 或其他预构建的队列实现之一。很少需要自己实现这样的数据结构(除非是学校作业)。

    编辑:既然您已经添加了“按索引检索缓冲区中的任何元素”的要求,我想您需要实现自己的类(除非google-collections 或其他一些库提供了一个)。正如 JeeBee 的示例所示,循环缓冲区很容易实现。你也可以看看 ArrayBlockingQueue 的源码——它的代码很干净,只是去掉了锁定和不需要的方法,并添加了通过索引访问它的方法。

    【讨论】:

    • 我也这么认为——我认为固定大小的 circ 缓冲区会在工具包中,但由于某种原因,我在普通 Java 基类库或 . NET 基类库。
    • (这不是学校作业!)
    • 我需要一个固定大小的队列,当两个条件之一触发时实现持久性机制:1 一个定期计时器,用于批处理所有未保存的消息,以及第二个缓冲区来保存过期的消息还没得救。我没有尝试过,我目前正在使用持久立即队列,希望当我的消息流量从零扩展到 1000 秒/秒时我不会遇到性能问题。
    【解决方案5】:

    使用Java的ArrayDeque

    【讨论】:

    • "任何时候我都希望能够按索引检索缓冲区中的任何元素。" ArrayDeque 只提供getFirst()getLast()
    【解决方案6】:

    只使用别人的实现:

    Power CollectionsDeque&lt;T&gt; 由循环缓冲区实现。

    power collections 库不完整,但 Deque 是完全可以接受的扩展循环缓冲区。

    由于您表示不希望扩展而是希望覆盖,您可以相当轻松地修改要覆盖的代码。这将简单地涉及删除对逻辑上相邻的指针的检查并且无论如何都只是写入。同时可以将私有缓冲区设为只读。

    【讨论】:

    • 扩展?这是否意味着它会在我无法控制的情况下变大?
    • 是的,但是将其修改为拒绝(异常)或覆盖最后一个值并不难...
    • 每次在 circbuffer 中前 n 项后添加项时抛出异常?哎呀,这听起来像个坏习惯。
    • 取决于语义,如果您不希望缓冲区扩展,那么您有三个选择:1)调用代码应该检查它是否已满而不是添加(所以添加应该抛出)。 2) 尝试简单地添加消失 3) 已经在缓冲区中的数据被覆盖。
    • 如果您不希望缓冲区中的数据或希望在缓冲区中的数据丢失,唯一合理的反应是抛出如果有什么事情搞砸了(或变得更大以应对)更多数据)
    【解决方案7】:

    System.Collections.Generic.Queue - 内部是简单的循环缓冲区(T[] 带有头部和尾部,就像在 sample from JeeBee 中一样)。

    【讨论】:

      【解决方案8】:

      Guava 15 中,我们引入了EvictingQueue,这是一个非阻塞、有界队列,当尝试将元素添加到完整队列时,它会自动从队列头部驱逐(移除)元素。这与传统的有界队列不同,后者在满时阻塞或拒绝新元素。

      听起来这应该适合您的需求,并且界面比直接使用ArrayDeque 简单得多(不过它在后台使用了一个!)。

      更多信息可以在here找到。

      【讨论】:

        【解决方案9】:

        我想从java的角度来回答这个问题。

        要用java实现一个循环缓冲区,你可能需要三样东西,包括:一个循环缓冲区类,泛型和对它的一些操作(为了了解你需要哪些操作以及这些操作中的内部机制,你可能需要先读wiki for circular buffer)。

        其次,缓冲区满或空的判断要非常小心。 这里我给出两个本能的全/空判断解决方案。在解决方案一中,您需要创建两个整数变量来存储缓冲区的当前大小和缓冲区的最大大小。显然,如果当前大小等于最大大小,则缓冲区已满。

        在另一种解决方案中,我们将最后一个存储位置设置为空闲(对于大小为 7 的循环缓冲区,我们将存储设置为空闲的 7)。据此,当满足(rp+1)%MAXSIZE == fp;表达式时,我们可以确定缓冲区已满。

        为了更清楚,这里给出了一种使用 java 语言的实现。

        import java.nio.BufferOverflowException;
        import java.nio.BufferUnderflowException;        
        
        public class CircularBuffer<T> {
            private int front;
            private int rear;
            private int currentSize;
            private int maxSize;
            private T[] buffer;
        
            public CircularBuffer(int n) {
                buffer = (T[]) new Object[n];
                front = 0;
                rear = 0;
                currentSize = 0;
                maxSize = n;
            }
        
            public void push(T e) {
                if (!isFull()) {
                    buffer[rear] = e;
                    currentSize++;
                    rear = (rear + 1) % maxSize;
                } else throw new BufferOverflowException();
            }
        
            public T pop() {
                if (!isEmpty()) {
                    T temp = buffer[front];
                    buffer[front] = null;
                    front = (front + 1) % maxSize;
                    currentSize--;
                    return temp;
                } else throw new BufferUnderflowException();
            }
        
            public T peekFirst() {
                if (!isEmpty()) {
                    return buffer[front];
                } else  return null;
            }
        
            public T peekLast() {
                if (!isEmpty()) {
                    return buffer[rear - 1];
                } else return null;
            }
        
            public int size() {
                return currentSize;
            }
        
            public boolean isEmpty() {
                if (currentSize == 0) {
                    return true;
                } else return false;
            }
        
            public boolean isFull() {
                if (currentSize == maxSize) {
                    return true;
                } else return false;
            }
        
            public boolean clean() { 
                front = 0;          
                rear = 0;
                while (rear != 0) {
                    buffer[rear] = null;
                    rear = (rear + 1) % maxSize;
                }   
                return true;
            }
        
            public static void main(String[] args) {
                CircularBuffer<Integer> buff = new CircularBuffer<>(7);
                buff.push(0);
                buff.push(1);
                buff.push(2);
                System.out.println(buff.size());
                System.out.println("The head element is: " + buff.pop());
                System.out.println("Size should be twoo: " + buff.size());
                System.out.println("The last element is one: " + buff.peekLast());
                System.out.println("Size should be two: " + buff.size());
                buff.clean();
                System.out.println("Size should be zero: " + buff.size());
        
            }
        }
        

        【讨论】:

          【解决方案10】:
          【解决方案11】:

          这是我为自己使用而编写的一个实现,但它可能很有用。

          缓冲区包含最大的固定项集。该集合是循环的,旧项目会自动删除。调用者可以通过绝对增量索引(长)获取项目尾部,但项目可能在时间太远的调用之间丢失。这个类是完全线程安全的。

          public sealed class ConcurrentCircularBuffer<T> : ICollection<T>
          {
              private T[] _items;
              private int _index;
              private bool _full;
          
              public ConcurrentCircularBuffer(int capacity)
              {
                  if (capacity <= 1) // need at least two items
                      throw new ArgumentException(null, "capacity");
          
                  Capacity = capacity;
                  _items = new T[capacity];
              }
          
              public int Capacity { get; private set; }
              public long TotalCount { get; private set; }
          
              public int Count
              {
                  get
                  {
                      lock (SyncObject) // full & _index need to be in sync
                      {
                          return _full ? Capacity : _index;
                      }
                  }
              }
          
              public void AddRange(IEnumerable<T> items)
              {
                  if (items == null)
                      return;
          
                  lock (SyncObject)
                  {
                      foreach (var item in items)
                      {
                          AddWithLock(item);
                      }
                  }
              }
          
              private void AddWithLock(T item)
              {
                  _items[_index] = item;
                  _index++;
                  if (_index == Capacity)
                  {
                      _full = true;
                      _index = 0;
                  }
                  TotalCount++;
              }
          
              public void Add(T item)
              {
                  lock (SyncObject)
                  {
                      AddWithLock(item);
                  }
              }
          
              public void Clear()
              {
                  lock (SyncObject)
                  {
                      _items = new T[Capacity];
                      _index = 0;
                      _full = false;
                      TotalCount = 0;
                  }
              }
          
              // this gives raw access to the underlying buffer. not sure I should keep that
              public T this[int index]
              {
                  get
                  {
                      return _items[index];
                  }
              }
          
              public T[] GetTail(long startIndex)
              {
                  long lostCount;
                  return GetTail(startIndex, out lostCount);
              }
          
              public T[] GetTail(long startIndex, out long lostCount)
              {
                  if (startIndex < 0 || startIndex >= TotalCount)
                      throw new ArgumentOutOfRangeException("startIndex");
          
                  T[] array = ToArray();
                  lostCount = (TotalCount - Count) - startIndex;
                  if (lostCount >= 0)
                      return array;
          
                  lostCount = 0;
          
                  // this maybe could optimized to not allocate the initial array
                  // but in multi-threading environment, I suppose this is arguable (and more difficult).
                  T[] chunk = new T[TotalCount - startIndex];
                  Array.Copy(array, array.Length - (TotalCount - startIndex), chunk, 0, chunk.Length);
                  return chunk;
              }
          
              public T[] ToArray()
              {
                  lock (SyncObject)
                  {
                      T[] items = new T[_full ? Capacity : _index];
                      if (_full)
                      {
                          if (_index == 0)
                          {
                              Array.Copy(_items, items, Capacity);
                          }
                          else
                          {
                              Array.Copy(_items, _index, items, 0, Capacity - _index);
                              Array.Copy(_items, 0, items, Capacity - _index, _index);
                          }
                      }
                      else if (_index > 0)
                      {
                          Array.Copy(_items, items, _index);
                      }
                      return items;
                  }
              }
          
              public IEnumerator<T> GetEnumerator()
              {
                  return ToArray().AsEnumerable().GetEnumerator();
              }
          
              IEnumerator IEnumerable.GetEnumerator()
              {
                  return GetEnumerator();
              }
          
              bool ICollection<T>.Contains(T item)
              {
                  return _items.Contains(item);
              }
          
              void ICollection<T>.CopyTo(T[] array, int arrayIndex)
              {
                  if (array == null)
                      throw new ArgumentNullException("array");
          
                  if (array.Rank != 1)
                      throw new ArgumentException(null, "array");
          
                  if (arrayIndex < 0)
                      throw new ArgumentOutOfRangeException("arrayIndex");
          
                  if ((array.Length - arrayIndex) < Count)
                      throw new ArgumentException(null, "array");
          
                  T[] thisArray = ToArray();
                  Array.Copy(thisArray, 0, array, arrayIndex, thisArray.Length);
              }
          
              bool ICollection<T>.IsReadOnly
              {
                  get
                  {
                      return false;
                  }
              }
          
              bool ICollection<T>.Remove(T item)
              {
                  return false;
              }
          
              private static object _syncObject;
              private static object SyncObject
              {
                  get
                  {
                      if (_syncObject == null)
                      {
                          object obj = new object();
                          Interlocked.CompareExchange(ref _syncObject, obj, null);
                      }
                      return _syncObject;
                  }
              }
          }
          

          【讨论】:

            【解决方案12】:

            这是另一个使用 Apache 公共集合的 BoundedFifoBuffer 的实现。如果您使用来自 Apache 的最新 JAR,请使用 CircularFifoQueue,因为不推荐使用以下类

                BoundedFifoBuffer apiCallHistory = new BoundedFifoBuffer(20);
            
                for(int i =1 ; i < 25; i++){
            
                    if(apiCallHistory.isFull()){
                      System.out.println("removing :: "+apiCallHistory.remove());
                    }
                    apiCallHistory.add(i);
            
            }
            

            【讨论】:

              【解决方案13】:
              // The following is in C#
              
              public class fqueue
              {
              
                // The following code implements a circular queue of objects
              
                //private data:
              
                  private bool empty;
                  private bool full;
              
                  private int begin, end;
              
                  private object[] x;
              
                //public data:
              
                  public fqueue()
                  {
                      empty = !(full = false);
                      begin = end = 0xA2;
              
                      x = new object[256];
                      return;
                  }
              
                  public fqueue(int size)
                  {
                      if (1 > size) throw new Exception("fqueue: Size cannot be zero or negative");
              
                      empty = !(full = false);
                      begin = end = 0xA2;
              
                      x = new object[size];
                      return;
                  }
              
                  public object write
                  {
                      set
                      {
                          if(full) throw new Exception("Write error: Queue is full");
              
                          end = empty ? end : (end + 1) % x.Length;
              
                          full = ((end + 1) % x.Length) == begin;
                          empty = false;
              
                          x[end] = value;
                      }
                  }
              
                  public object read
                  {
                      get
                      {
                          if(empty) throw new Exception("Read error: Queue is empty");
                          full = false;
              
                          object ret = x[begin];
              
                          begin = (empty=end==begin) ?
                              begin :
                              (begin + 1) % x.Length;
              
                          return ret;
                      }
                  }
              
                  public int maxSize
                  {
                      get
                      {
                          return x.Length;
                      }
                  }
              
                  public int queueSize
                  {
                      get
                      {
                          return end - begin + (empty ? 0 : 1 + ((end < begin) ? x.Length : 0));
                      }
                  }
              
                  public bool isEmpty
                  {
                      get
                      {
                          return empty;
                      }
                  }
              
                  public bool isFull
                  {
                      get
                      {
                          return full;
                      }
                  }
              
                  public int start
                  {
                      get
                      {
                          return begin;
                      }
                  }        
              
                  public int finish
                  {
                      get
                      {
                          return end;
                      }
                  }
              }
              

              【讨论】:

              • 讨厌这么说,但这是我见过的最糟糕的 C# 代码。 empty = !(full = false);wtf? begin = end = 0xA2; 幻数超光速。只写属性本身就是一个 wtf,称为write 的只写属性是双重的,因为想象像这样使用它myqueue.write = someObj。为什么read 是属性而不是普通函数? queueSize 函数非常复杂。这将在模糊的 C# 竞赛中成为一个可爱的条目,但如果在生产环境中使用,可能会被解雇。
              猜你喜欢
              • 1970-01-01
              • 1970-01-01
              • 2011-06-12
              • 1970-01-01
              • 2016-06-11
              • 1970-01-01
              • 1970-01-01
              相关资源
              最近更新 更多